mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web/e2e: Playwright end-to-end test runner (#16014)
* web: Flesh out Playwright. web: Flesh out slim tests. * web/e2e: Sessions * web: Update tests. * web: Fix missing git hash when using docker as backend. * Fix selectors. * web: Flesh out a11y in wizard elements. * web: Flesh out provider tests.
This commit is contained in:
@@ -25,6 +25,8 @@ lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { expect, Page } from "@playwright/test";
|
||||
|
||||
export class FormFixture extends PageFixture {
|
||||
static fixtureName = "Form";
|
||||
|
||||
//#region Selector Methods
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Field Methods
|
||||
|
||||
/**
|
||||
* Set the value of a text input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public fill = async (
|
||||
fieldName: string,
|
||||
value: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent
|
||||
.getByRole("textbox", {
|
||||
name: fieldName,
|
||||
})
|
||||
.or(
|
||||
parent.getByRole("spinbutton", {
|
||||
name: fieldName,
|
||||
}),
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
await control.fill(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param value the value to set.
|
||||
*/
|
||||
public setInputCheck = async (
|
||||
fieldName: string,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.locator("ak-switch-input", {
|
||||
hasText: fieldName,
|
||||
});
|
||||
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
|
||||
await expect(control, `Field (${fieldName}) should be visible`).toBeVisible();
|
||||
|
||||
const currentChecked = await control
|
||||
.getAttribute("checked")
|
||||
.then((value) => value !== null);
|
||||
|
||||
if (currentChecked === value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await control.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a radio or checkbox input.
|
||||
*
|
||||
* @param fieldName The name of the form element.
|
||||
* @param pattern the value to set.
|
||||
*/
|
||||
public setRadio = async (
|
||||
groupName: string,
|
||||
fieldName: string,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const group = parent.getByRole("group", { name: groupName });
|
||||
|
||||
await expect(group, `Field "${groupName}" should be visible`).toBeVisible();
|
||||
const control = parent.getByRole("radio", { name: fieldName });
|
||||
|
||||
await control.setChecked(true, {
|
||||
force: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the value of a search select input.
|
||||
*
|
||||
* @param fieldLabel The name of the search select element.
|
||||
* @param pattern The text to match against the search select entry.
|
||||
*/
|
||||
public selectSearchValue = async (
|
||||
fieldLabel: string,
|
||||
pattern: string | RegExp,
|
||||
parent: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
const control = parent.getByRole("textbox", { name: fieldLabel });
|
||||
|
||||
await expect(
|
||||
control,
|
||||
`Search select control (${fieldLabel}) should be visible`,
|
||||
).toBeVisible();
|
||||
|
||||
const fieldName = await control.getAttribute("name");
|
||||
|
||||
if (!fieldName) {
|
||||
throw new Error(`Unable to find name attribute on search select (${fieldLabel})`);
|
||||
}
|
||||
|
||||
// Find the search select input control and activate it.
|
||||
await control.click();
|
||||
|
||||
const button = this.page
|
||||
// ---
|
||||
.locator(`div[data-managed-for*="${fieldName}"] button`, {
|
||||
hasText: pattern,
|
||||
});
|
||||
|
||||
if (!button) {
|
||||
throw new Error(
|
||||
`Unable to find an ak-search-select entry matching ${fieldLabel}:${pattern.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
await button.click();
|
||||
await this.page.keyboard.press("Tab");
|
||||
await control.blur();
|
||||
};
|
||||
|
||||
public setFormGroup = async (
|
||||
pattern: string | RegExp,
|
||||
value: boolean = true,
|
||||
parent: LocatorContext = this.page,
|
||||
) => {
|
||||
const control = parent
|
||||
.locator("ak-form-group", {
|
||||
hasText: pattern,
|
||||
})
|
||||
.first();
|
||||
|
||||
const currentOpen = await control.getAttribute("open").then((value) => value !== null);
|
||||
|
||||
if (currentOpen === value) {
|
||||
this.logger.debug(`Form group ${pattern} is already ${value ? "open" : "closed"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(`Toggling form group ${pattern} to ${value ? "open" : "closed"}`);
|
||||
|
||||
await control.click();
|
||||
|
||||
if (value) {
|
||||
await expect(control).toHaveAttribute("open");
|
||||
} else {
|
||||
await expect(control).not.toHaveAttribute("open");
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ConsoleLogger, FixtureLogger } from "#logger/node";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export interface PageFixtureOptions {
|
||||
page: Page;
|
||||
testName: string;
|
||||
}
|
||||
|
||||
export abstract class PageFixture {
|
||||
/**
|
||||
* The name of the fixture.
|
||||
*
|
||||
* Used for logging.
|
||||
*/
|
||||
static fixtureName: string;
|
||||
|
||||
protected readonly logger: FixtureLogger;
|
||||
protected readonly page: Page;
|
||||
protected readonly testName: string;
|
||||
|
||||
constructor({ page, testName }: PageFixtureOptions) {
|
||||
this.page = page;
|
||||
this.testName = testName;
|
||||
|
||||
const Constructor = this.constructor as typeof PageFixture;
|
||||
|
||||
this.logger = ConsoleLogger.fixture(Constructor.fixtureName, this.testName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
import type { LocatorContext } from "#e2e/selectors/types";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export type GetByRoleParameters = Parameters<Page["getByRole"]>;
|
||||
export type ARIARole = GetByRoleParameters[0];
|
||||
export type ARIAOptions = GetByRoleParameters[1];
|
||||
|
||||
export type ClickByName = (name: string) => Promise<void>;
|
||||
export type ClickByRole = (
|
||||
role: ARIARole,
|
||||
options?: ARIAOptions,
|
||||
context?: LocatorContext,
|
||||
) => Promise<void>;
|
||||
|
||||
export class PointerFixture extends PageFixture {
|
||||
public static fixtureName = "Pointer";
|
||||
|
||||
public click = (
|
||||
name: string,
|
||||
optionsOrRole?: ARIAOptions | ARIARole,
|
||||
context: LocatorContext = this.page,
|
||||
): Promise<void> => {
|
||||
if (typeof optionsOrRole === "string") {
|
||||
return context.getByRole(optionsOrRole, { name }).click();
|
||||
}
|
||||
|
||||
const options = {
|
||||
...optionsOrRole,
|
||||
name,
|
||||
};
|
||||
|
||||
return (
|
||||
context
|
||||
// ---
|
||||
.getByRole("button", options)
|
||||
.or(context.getByRole("link", options))
|
||||
.click()
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { PageFixture } from "#e2e/fixtures/PageFixture";
|
||||
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const GOOD_USERNAME = "test-admin@goauthentik.io";
|
||||
export const GOOD_PASSWORD = "test-runner";
|
||||
|
||||
export const BAD_USERNAME = "bad-username@bad-login.io";
|
||||
export const BAD_PASSWORD = "-this-is-a-bad-password-";
|
||||
|
||||
export interface LoginInit {
|
||||
username?: string;
|
||||
password?: string;
|
||||
to?: URL | string;
|
||||
}
|
||||
|
||||
export class SessionFixture extends PageFixture {
|
||||
static fixtureName = "Session";
|
||||
|
||||
public static readonly pathname = "/if/flow/default-authentication-flow/";
|
||||
|
||||
//#region Selectors
|
||||
|
||||
public $identificationStage = this.page.locator("ak-stage-identification");
|
||||
|
||||
/**
|
||||
* The username field on the login page.
|
||||
*/
|
||||
public $usernameField = this.page.getByLabel("Username");
|
||||
|
||||
public $passwordStage = this.page.locator("ak-stage-password");
|
||||
public $passwordField = this.page.getByLabel("Password");
|
||||
|
||||
/**
|
||||
* The button to submit the the login flow,
|
||||
* typically redirecting to the authenticated interface.
|
||||
*/
|
||||
public $submitButton = this.page.locator('button[type="submit"]');
|
||||
|
||||
/**
|
||||
* A possible authentication failure message.
|
||||
*/
|
||||
public $authFailureMessage = this.page.getByRole("alert", {
|
||||
name: /(?:failed to authenticate)|(?:invalid password)/i,
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
constructor(page: Page, testName: string) {
|
||||
super({ page, testName });
|
||||
}
|
||||
|
||||
//#region Specific interactions
|
||||
|
||||
public checkAuthenticated = async (): Promise<boolean> => {
|
||||
// TODO: Check if the user is authenticated via API
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Log into the application.
|
||||
*/
|
||||
public async login({
|
||||
username = GOOD_USERNAME,
|
||||
password = GOOD_PASSWORD,
|
||||
to = SessionFixture.pathname,
|
||||
}: LoginInit = {}) {
|
||||
this.logger.info("Logging in...");
|
||||
|
||||
const initialURL = new URL(this.page.url());
|
||||
|
||||
if (initialURL.pathname === SessionFixture.pathname) {
|
||||
this.logger.info("Skipping navigation because we're already in a authentication flow");
|
||||
} else {
|
||||
await this.page.goto(to.toString());
|
||||
}
|
||||
|
||||
await this.$usernameField.fill(username);
|
||||
|
||||
const passwordFieldVisible = await this.$passwordField.isVisible();
|
||||
|
||||
if (!passwordFieldVisible) {
|
||||
await this.$submitButton.click();
|
||||
|
||||
await this.$passwordField.waitFor({ state: "visible" });
|
||||
}
|
||||
|
||||
await this.$passwordField.fill(password);
|
||||
|
||||
await this.$submitButton.click();
|
||||
|
||||
const expectedPathname = typeof to === "string" ? to : to.pathname;
|
||||
|
||||
await this.page.waitForURL(`**${expectedPathname}`);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Navigation
|
||||
|
||||
public async toLoginPage() {
|
||||
await this.page.goto(SessionFixture.pathname);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* @file Playwright e2e test helpers.
|
||||
*/
|
||||
|
||||
import { FormFixture } from "#e2e/fixtures/FormFixture";
|
||||
import { PointerFixture } from "#e2e/fixtures/PointerFixture";
|
||||
import { SessionFixture } from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
import { test as base } from "@playwright/test";
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
|
||||
/* eslint-disable react-hooks/rules-of-hooks */
|
||||
|
||||
interface E2EFixturesTestScope {
|
||||
session: SessionFixture;
|
||||
pointer: PointerFixture;
|
||||
form: FormFixture;
|
||||
}
|
||||
|
||||
interface E2EWorkerScope {
|
||||
selectorRegistration: void;
|
||||
}
|
||||
|
||||
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({
|
||||
session: async ({ page }, use, { title }) => {
|
||||
await use(new SessionFixture(page, title));
|
||||
},
|
||||
|
||||
form: async ({ page }, use, { title }) => {
|
||||
await use(new FormFixture(page, title));
|
||||
},
|
||||
|
||||
pointer: async ({ page }, use, { title }) => {
|
||||
await use(new PointerFixture({ page, testName: title }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
|
||||
export type LocatorContext = Pick<
|
||||
Locator,
|
||||
| "locator"
|
||||
| "getByRole"
|
||||
| "getByTestId"
|
||||
| "getByText"
|
||||
| "getByLabel"
|
||||
| "getByAltText"
|
||||
| "getByTitle"
|
||||
| "getByPlaceholder"
|
||||
>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
|
||||
import {
|
||||
adjectives,
|
||||
colors,
|
||||
Config as NameConfig,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator";
|
||||
|
||||
/**
|
||||
* Given a dictionary of words, slice the dictionary to only include words that start with the given letter.
|
||||
*/
|
||||
export function alliterate(dictionary: string[], letter: string): string[] {
|
||||
let firstIndex = 0;
|
||||
|
||||
for (let i = 0; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] === letter) {
|
||||
firstIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lastIndex = firstIndex;
|
||||
|
||||
for (let i = firstIndex; i < dictionary.length; i++) {
|
||||
if (dictionary[i][0] !== letter) {
|
||||
lastIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary.slice(firstIndex, lastIndex);
|
||||
}
|
||||
|
||||
export function createRandomName({
|
||||
seed = IDGenerator.randomID(),
|
||||
...config
|
||||
}: Partial<NameConfig> = {}) {
|
||||
const randomLetterIndex =
|
||||
typeof seed === "number"
|
||||
? seed
|
||||
: Array.from(seed).reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
|
||||
const letter = adjectives[randomLetterIndex % adjectives.length][0];
|
||||
|
||||
const availableAdjectives = alliterate(adjectives, letter);
|
||||
|
||||
const availableColors = alliterate(colors, letter);
|
||||
|
||||
const name = uniqueNamesGenerator({
|
||||
dictionaries: [availableAdjectives, availableAdjectives, availableColors],
|
||||
style: "capital",
|
||||
separator: " ",
|
||||
length: 3,
|
||||
seed,
|
||||
...config,
|
||||
});
|
||||
|
||||
return name;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Application logger.
|
||||
*
|
||||
* @import { LoggerOptions, Logger, Level, ChildLoggerOptions } from "pino"
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import { pino } from "pino";
|
||||
|
||||
//#region Constants
|
||||
|
||||
/**
|
||||
* Default options for creating a Pino logger.
|
||||
*
|
||||
* @category Logger
|
||||
* @satisfies {LoggerOptions<never, false>}
|
||||
*/
|
||||
export const DEFAULT_PINO_LOGGER_OPTIONS = {
|
||||
enabled: true,
|
||||
level: "info",
|
||||
transport: {
|
||||
target: "./transport.js",
|
||||
options: /** @satisfies {PrettyOptions} */ ({
|
||||
colorize: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Functions
|
||||
|
||||
/**
|
||||
* Read the log level from the environment.
|
||||
* @return {Level}
|
||||
*/
|
||||
export function readLogLevel() {
|
||||
return process.env.AK_LOG_LEVEL || DEFAULT_PINO_LOGGER_OPTIONS.level;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Logger} FixtureLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* @this {Logger}
|
||||
* @param {string} fixtureName
|
||||
* @param {string} [testName]
|
||||
* @param {ChildLoggerOptions} [options]
|
||||
* @returns {FixtureLogger}
|
||||
*/
|
||||
function createFixtureLogger(fixtureName, testName, options) {
|
||||
return this.child(
|
||||
{ name: fixtureName },
|
||||
{
|
||||
msgPrefix: `[${testName}] `,
|
||||
...options,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {object} CustomLoggerMethods
|
||||
* @property {typeof createFixtureLogger} fixture
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Logger & CustomLoggerMethods} ConsoleLogger
|
||||
*/
|
||||
|
||||
/**
|
||||
* A singleton logger instance for Node.js.
|
||||
*
|
||||
* ```js
|
||||
* import { ConsoleLogger } from "#logger/node";
|
||||
*
|
||||
* ConsoleLogger.info("Hello, world!");
|
||||
* ```
|
||||
*
|
||||
* @runtime node
|
||||
* @type {ConsoleLogger}
|
||||
*/
|
||||
export const ConsoleLogger = Object.assign(
|
||||
pino({
|
||||
...DEFAULT_PINO_LOGGER_OPTIONS,
|
||||
level: readLogLevel(),
|
||||
}),
|
||||
{ fixture: createFixtureLogger },
|
||||
);
|
||||
|
||||
/**
|
||||
* @typedef {ReturnType<ConsoleLogger['child']>} ChildConsoleLogger
|
||||
*/
|
||||
|
||||
//#region Aliases
|
||||
|
||||
export const info = ConsoleLogger.info.bind(ConsoleLogger);
|
||||
export const debug = ConsoleLogger.debug.bind(ConsoleLogger);
|
||||
export const warn = ConsoleLogger.warn.bind(ConsoleLogger);
|
||||
export const error = ConsoleLogger.error.bind(ConsoleLogger);
|
||||
|
||||
//#endregion
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @file Pretty transport for Pino
|
||||
*
|
||||
* @import { PrettyOptions } from "pino-pretty"
|
||||
*/
|
||||
|
||||
import PinoPretty from "pino-pretty";
|
||||
|
||||
/**
|
||||
* @param {PrettyOptions} options
|
||||
*/
|
||||
function prettyTransporter(options) {
|
||||
const pretty = PinoPretty({
|
||||
...options,
|
||||
ignore: "pid,hostname",
|
||||
translateTime: "SYS:HH:MM:ss",
|
||||
});
|
||||
|
||||
return pretty;
|
||||
}
|
||||
|
||||
export default prettyTransporter;
|
||||
Generated
+1382
-621
File diff suppressed because it is too large
Load Diff
+22
-8
@@ -26,7 +26,9 @@
|
||||
"storybook:build": "wireit",
|
||||
"test": "wireit",
|
||||
"test:e2e": "wireit",
|
||||
"test:e2e:next": "playwright test",
|
||||
"test:e2e:watch": "wireit",
|
||||
"test:next": "vitest",
|
||||
"test:watch": "wireit",
|
||||
"tsc": "wireit",
|
||||
"watch": "run-s build-locales esbuild:watch"
|
||||
@@ -69,6 +71,9 @@
|
||||
"#flow/*": "./src/flow/*.js",
|
||||
"#locales/*": "./src/locales/*.js",
|
||||
"#stories/*": "./src/stories/*.js",
|
||||
"#tests/*": "./tests/*.js",
|
||||
"#e2e": "./e2e/index.ts",
|
||||
"#e2e/*": "./e2e/*.ts",
|
||||
"#*/browser": {
|
||||
"types": "./out/*/browser.d.ts",
|
||||
"import": "./*/browser.js"
|
||||
@@ -113,6 +118,7 @@
|
||||
"@openlayers-elements/maps": "^0.4.0",
|
||||
"@patternfly/elements": "^4.2.0",
|
||||
"@patternfly/patternfly": "^4.224.2",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@sentry/browser": "^10.5.0",
|
||||
"@spotlightjs/spotlight": "^3.0.2",
|
||||
"@storybook/addon-docs": "^9.1.2",
|
||||
@@ -128,6 +134,7 @@
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@vitest/browser": "^3.2.4",
|
||||
"@webcomponents/webcomponentsjs": "^2.8.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"change-case": "^5.4.4",
|
||||
@@ -158,6 +165,9 @@
|
||||
"md-front-matter": "^1.0.4",
|
||||
"mermaid": "^11.10.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"pino": "^9.7.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"playwright": "^1.54.1",
|
||||
"prettier": "^3.6.2",
|
||||
"pseudolocale": "^2.1.0",
|
||||
"rapidoc": "^9.3.8",
|
||||
@@ -178,7 +188,10 @@
|
||||
"turnstile-types": "^1.2.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.40.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vite": "^7.0.6",
|
||||
"vitest": "^3.2.4",
|
||||
"webcomponent-qr-code": "^1.3.0",
|
||||
"wireit": "^0.14.12",
|
||||
"yaml": "^2.8.1"
|
||||
@@ -190,11 +203,12 @@
|
||||
"@rollup/rollup-darwin-arm64": "^4.46.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "^4.46.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.46.3",
|
||||
"@wdio/browser-runner": "^9.19.1",
|
||||
"@wdio/cli": "^9.19.1",
|
||||
"@wdio/spec-reporter": "^9.19.1",
|
||||
"@wdio/browser-runner": "^9.19.2",
|
||||
"@wdio/cli": "^9.19.2",
|
||||
"@wdio/spec-reporter": "^9.19.2",
|
||||
"@web/test-runner": "^0.20.2",
|
||||
"chromedriver": "^139.0.1"
|
||||
"chromedriver": "^139.0.1",
|
||||
"p-iteration": "^1.1.8"
|
||||
},
|
||||
"wireit": {
|
||||
"build": {
|
||||
@@ -303,14 +317,14 @@
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"command": "wdio ./wdio.conf.ts --logLevel=warn",
|
||||
"command": "wdio ./wdio.conf.mjs --logLevel=warn",
|
||||
"env": {
|
||||
"CI": "true",
|
||||
"TS_NODE_PROJECT": "tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
"test:e2e": {
|
||||
"command": "wdio run ./tests/wdio.conf.ts",
|
||||
"command": "wdio run ./tests/wdio.conf.mjs",
|
||||
"dependencies": [
|
||||
"build"
|
||||
],
|
||||
@@ -320,7 +334,7 @@
|
||||
}
|
||||
},
|
||||
"test:e2e:watch": {
|
||||
"command": "wdio run ./tests/wdio.conf.ts",
|
||||
"command": "wdio run ./tests/wdio.conf.mjs",
|
||||
"dependencies": [
|
||||
"build"
|
||||
],
|
||||
@@ -329,7 +343,7 @@
|
||||
}
|
||||
},
|
||||
"test:watch": {
|
||||
"command": "wdio run ./wdio.conf.ts",
|
||||
"command": "wdio run ./wdio.conf.mjs",
|
||||
"dependencies": [
|
||||
"build"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @file Load the contents of an environment file into `process.env`.
|
||||
*/
|
||||
import { MonoRepoRoot } from "#paths/node";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const envFilePath = join(MonoRepoRoot, ".env");
|
||||
|
||||
if (existsSync(envFilePath)) {
|
||||
console.debug(`Loading environment from ${envFilePath}`);
|
||||
|
||||
try {
|
||||
process.loadEnvFile(envFilePath);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load environment from ${envFilePath}:`, error);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,10 @@ export function readGitBuildHash() {
|
||||
export function readBuildIdentifier() {
|
||||
const { GIT_BUILD_HASH } = process.env;
|
||||
|
||||
if (!GIT_BUILD_HASH) return AuthentikVersion;
|
||||
if (!GIT_BUILD_HASH) {
|
||||
console.warn("GIT_BUILD_HASH is not set, falling back to authentik version.");
|
||||
return AuthentikVersion;
|
||||
}
|
||||
|
||||
return [AuthentikVersion, GIT_BUILD_HASH].join("+");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* @file Playwright configuration.
|
||||
*
|
||||
* @see https://playwright.dev/docs/test-configuration
|
||||
*
|
||||
* @import { LogFn, Logger } from "pino"
|
||||
*/
|
||||
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const CI = !!process.env.CI;
|
||||
|
||||
/**
|
||||
* @type {Map<string, Logger>}
|
||||
*/
|
||||
const LoggerCache = new Map();
|
||||
|
||||
const baseURL = process.env.AK_TEST_RUNNER_PAGE_URL ?? "http://localhost:9000";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./test/browser",
|
||||
fullyParallel: true,
|
||||
forbidOnly: CI,
|
||||
retries: CI ? 2 : 0,
|
||||
workers: CI ? 1 : undefined,
|
||||
reporter: CI
|
||||
? "github"
|
||||
: [
|
||||
// ---
|
||||
["list", { printSteps: true }],
|
||||
["html", { open: "never" }],
|
||||
],
|
||||
use: {
|
||||
testIdAttribute: "data-test-id",
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
launchOptions: {
|
||||
logger: {
|
||||
isEnabled() {
|
||||
return true;
|
||||
},
|
||||
log: (name, severity, message, args) => {
|
||||
let logger = LoggerCache.get(name);
|
||||
|
||||
if (!logger) {
|
||||
logger = ConsoleLogger.child({
|
||||
name: `Playwright ${name.toUpperCase()}`,
|
||||
});
|
||||
LoggerCache.set(name, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {LogFn}
|
||||
*/
|
||||
let log;
|
||||
|
||||
switch (severity) {
|
||||
case "verbose":
|
||||
log = logger.debug;
|
||||
break;
|
||||
case "warning":
|
||||
log = logger.warn;
|
||||
break;
|
||||
case "error":
|
||||
log = logger.error;
|
||||
break;
|
||||
default:
|
||||
log = logger.info;
|
||||
break;
|
||||
}
|
||||
|
||||
if (name === "api") {
|
||||
log = logger.debug;
|
||||
}
|
||||
|
||||
log.call(logger, message.toString(), args);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file ESBuild script for building the authentik web UI.
|
||||
*/
|
||||
|
||||
import "@goauthentik/core/environment/load/node";
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
|
||||
|
||||
@@ -36,9 +36,7 @@ export class ProviderWizard extends AKElement {
|
||||
providerTypes: TypeCreate[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
finalHandler: () => Promise<void> = () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
public finalHandler?: () => Promise<void>;
|
||||
|
||||
@query("ak-wizard")
|
||||
wizard?: Wizard;
|
||||
@@ -56,9 +54,7 @@ export class ProviderWizard extends AKElement {
|
||||
.steps=${["initial"]}
|
||||
header=${msg("New provider")}
|
||||
description=${msg("Create a new provider.")}
|
||||
.finalHandler=${() => {
|
||||
return this.finalHandler();
|
||||
}}
|
||||
.finalHandler=${this.finalHandler}
|
||||
>
|
||||
<ak-wizard-page-type-create
|
||||
name="selectProviderType"
|
||||
@@ -82,7 +78,15 @@ export class ProviderWizard extends AKElement {
|
||||
</ak-wizard-page-form>
|
||||
`;
|
||||
})}
|
||||
<button slot="trigger" class="pf-c-button pf-m-primary">${this.createText}</button>
|
||||
<button
|
||||
aria-label=${msg("New Provider")}
|
||||
aria-description="${msg("Open the wizard to create a new provider.")}"
|
||||
type="button"
|
||||
slot="trigger"
|
||||
class="pf-c-button pf-m-primary"
|
||||
>
|
||||
${msg("Create")}
|
||||
</button>
|
||||
</ak-wizard>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -292,7 +292,7 @@ export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "au
|
||||
* @todo Can this be handled with a Lit Mixin?
|
||||
*/
|
||||
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
|
||||
const element = document.body.querySelector<T>("[data-ak-interface-root]");
|
||||
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
|
||||
|
||||
if (!element) {
|
||||
throw new Error(
|
||||
|
||||
@@ -23,7 +23,7 @@ function renderError(detail: string) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" aria-live="polite">
|
||||
return html`<p class="pf-c-form__helper-text pf-m-error" role="alert" aria-label=${detail}>
|
||||
<span class="pf-c-form__helper-text-icon">
|
||||
<i class="fas fa-exclamation-circle" aria-hidden="true"></i> </span
|
||||
>${detail}
|
||||
|
||||
@@ -224,6 +224,7 @@ export abstract class WizardStep extends AKElement {
|
||||
renderCloseButton(button: WizardButton) {
|
||||
return html`<div class="pf-c-wizard__footer-cancel">
|
||||
<button
|
||||
data-test-id="wizard-navigation-abort"
|
||||
class=${classMap(this.getButtonClasses(button))}
|
||||
type="button"
|
||||
@click=${this.onWizardCloseEvent}
|
||||
@@ -300,43 +301,58 @@ export abstract class WizardStep extends AKElement {
|
||||
return this.wizardStepState.currentStep === this.getAttribute("slot")
|
||||
? html` <div class="pf-c-modal-box ak-wizard-box">
|
||||
<div class="pf-c-wizard">
|
||||
<div class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
|
||||
<header class="pf-c-wizard__header" data-ouid-component-id="wizard-header">
|
||||
${this.canCancel ? this.renderHeaderCancelIcon() : nothing}
|
||||
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">
|
||||
<h1
|
||||
class="pf-c-title pf-m-3xl pf-c-wizard__title"
|
||||
data-test-id="wizard-title"
|
||||
>
|
||||
${this.wizardTitle}
|
||||
</h1>
|
||||
<p class="pf-c-wizard__description">${this.wizardDescription}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="pf-c-wizard__outer-wrap">
|
||||
<div class="pf-c-wizard__inner-wrap">
|
||||
<nav class="pf-c-wizard__nav" data-ouid-component-id="wizard-navbar">
|
||||
<aside
|
||||
class="pf-c-wizard__nav"
|
||||
role="group"
|
||||
aria-label="${msg("Wizard steps")}"
|
||||
>
|
||||
<ol class="pf-c-wizard__nav-list">
|
||||
${map(
|
||||
this.wizardStepState.stepLabels,
|
||||
this.renderSidebarStep,
|
||||
)}
|
||||
</ol>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="pf-c-wizard__main">
|
||||
<div
|
||||
id="main-content"
|
||||
class="pf-c-wizard__main-body"
|
||||
data-ouid-component-id="wizard-body"
|
||||
>
|
||||
<div id="main-content" class="pf-c-wizard__main-body">
|
||||
${this.renderMain()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<footer
|
||||
class="pf-c-wizard__footer"
|
||||
data-ouid-component-id="wizard-footer"
|
||||
>
|
||||
<nav class="pf-c-wizard__footer" aria-label="${msg("Wizard navigation")}">
|
||||
${this.buttons.map(this.renderButton)}
|
||||
</footer>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WizardNavigationTestIDMap {
|
||||
abort: HTMLButtonElement;
|
||||
}
|
||||
|
||||
interface WizardTestIDMap {
|
||||
navigation: WizardNavigationTestIDMap;
|
||||
title: HTMLHeadingElement;
|
||||
}
|
||||
|
||||
interface TestIDSelectorMap {
|
||||
wizard: WizardTestIDMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { NavigationEventInit, WizardNavigationEvent } from "./events.js";
|
||||
import { WizardStepState } from "./types.js";
|
||||
import { WizardStepLabel, WizardStepState } from "./types.js";
|
||||
import { wizardStepContext } from "./WizardContexts.js";
|
||||
import { type WizardStep } from "./WizardStep.js";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { Context, ContextProvider } from "@lit/context";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
@@ -26,11 +26,11 @@ import { customElement, property } from "lit/decorators.js";
|
||||
@customElement("ak-wizard-steps")
|
||||
export class WizardStepsManager extends AKElement {
|
||||
@property({ type: String, attribute: true })
|
||||
currentStep?: string;
|
||||
public currentStep?: string;
|
||||
|
||||
wizardStepContext!: ContextProvider<{ __context__: WizardStepState | undefined }>;
|
||||
protected wizardStepContext!: ContextProvider<Context<symbol, WizardStepState>>;
|
||||
|
||||
slots: WizardStep[] = [];
|
||||
protected slots: WizardStep[] = [];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -57,13 +57,12 @@ export class WizardStepsManager extends AKElement {
|
||||
return target;
|
||||
}
|
||||
|
||||
get stepLabels() {
|
||||
protected get stepLabels(): WizardStepLabel[] {
|
||||
return this.slots
|
||||
.filter((slot) => !slot.hide)
|
||||
.map((slot) => ({
|
||||
label: slot.label,
|
||||
id: slot.slot,
|
||||
active: true,
|
||||
enabled: slot.enabled,
|
||||
}));
|
||||
}
|
||||
@@ -77,14 +76,18 @@ export class WizardStepsManager extends AKElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this.findSlots();
|
||||
this.findStepLabels();
|
||||
|
||||
if (!this.currentStep && this.slots.length > 0) {
|
||||
const currentStep = this.slots[0].getAttribute("slot");
|
||||
if (!currentStep) {
|
||||
throw new Error("All steps managed by this component must have a slot definition.");
|
||||
}
|
||||
|
||||
this.currentStep = currentStep;
|
||||
|
||||
this.wizardStepContext.setValue({
|
||||
stepLabels: this.stepLabels,
|
||||
currentStep: currentStep,
|
||||
|
||||
@@ -10,12 +10,11 @@ export type NavigableButton = Extract<WizardButton, { destination: string }>;
|
||||
|
||||
export type ButtonKind = Extract<WizardButton["kind"], PropertyKey>;
|
||||
|
||||
export type WizardStepLabel = {
|
||||
export interface WizardStepLabel {
|
||||
label: string;
|
||||
id: string;
|
||||
active: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export type WizardStepState = {
|
||||
currentStep?: string;
|
||||
|
||||
@@ -28,6 +28,6 @@ export abstract class Interface extends AKElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.dataset.akInterfaceRoot = this.tagName.toLowerCase();
|
||||
this.dataset.testId = "interface-root";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ type ContentValue = SlottedTemplateResult | undefined;
|
||||
*/
|
||||
export function akLoadingOverlay(
|
||||
properties: ILoadingOverlay = {},
|
||||
content: ILoadingOverlayContent = {},
|
||||
content: string | ILoadingOverlayContent = {},
|
||||
) {
|
||||
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
|
||||
// slot-name.
|
||||
|
||||
@@ -448,12 +448,16 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
|
||||
}
|
||||
|
||||
return html`<div class="pf-c-form__alert">
|
||||
${this.nonFieldErrors.map((err) => {
|
||||
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
|
||||
${this.nonFieldErrors.map((err, idx) => {
|
||||
return html`<div
|
||||
class="pf-c-alert pf-m-inline pf-m-danger"
|
||||
role="alert"
|
||||
aria-labelledby="error-message-${idx}"
|
||||
>
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<i aria-hidden="true" class="fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h4 class="pf-c-alert__title">${err}</h4>
|
||||
<p id="error-message-${idx}" class="pf-c-alert__title">${err}</p>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TypeCreate } from "@goauthentik/api";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { createRef, ref, Ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
@@ -29,7 +30,7 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
types: TypeCreate[] = [];
|
||||
|
||||
@property({ attribute: false })
|
||||
selectedType?: TypeCreate;
|
||||
public selectedType: TypeCreate | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
layout: TypeCreateWizardPageLayouts = TypeCreateWizardPageLayouts.list;
|
||||
@@ -63,21 +64,22 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
|
||||
public reset = () => {
|
||||
super.reset();
|
||||
this.selectedType = undefined;
|
||||
|
||||
this.selectedType = null;
|
||||
this.formRef.value?.reset();
|
||||
};
|
||||
|
||||
activeCallback = (): void => {
|
||||
public override activeCallback = (): void => {
|
||||
const form = this.formRef.value;
|
||||
|
||||
this.host.isValid = form?.checkValidity() ?? false;
|
||||
|
||||
if (this.selectedType) {
|
||||
this.selectDispatch(this.selectedType);
|
||||
this.#selectDispatch(this.selectedType);
|
||||
}
|
||||
};
|
||||
|
||||
private selectDispatch(type: TypeCreate) {
|
||||
#selectDispatch = (type: TypeCreate) => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("select", {
|
||||
detail: type,
|
||||
@@ -85,48 +87,56 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
renderGrid(): TemplateResult {
|
||||
protected renderGrid(): TemplateResult {
|
||||
return html`<div
|
||||
role="listbox"
|
||||
aria-label="${msg("Select a provider type")}"
|
||||
class="pf-l-grid pf-m-gutter"
|
||||
data-ouid-component-type="ak-type-create-grid"
|
||||
>
|
||||
${this.types.map((type, idx) => {
|
||||
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
|
||||
const disabled = !!(type.requiresEnterprise && !this.hasEnterpriseLicense);
|
||||
|
||||
const selected = this.selectedType === type;
|
||||
|
||||
// It's valid to pass in a local modelName or the full name with application
|
||||
// part. If the latter, we only want the part after the dot to appear as our
|
||||
// OUIA tag for test automation.
|
||||
const componentName = type.modelName.includes(".")
|
||||
? (type.modelName.split(".")[1] ?? "--unknown--")
|
||||
: type.modelName;
|
||||
return html`<div
|
||||
class="pf-l-grid__item pf-m-3-col pf-c-card ${requiresEnterprise
|
||||
? "pf-m-non-selectable-raised"
|
||||
: "pf-m-selectable-raised"} ${this.selectedType === type
|
||||
? "pf-m-selected-raised"
|
||||
: ""}"
|
||||
class=${classMap({
|
||||
"pf-l-grid__item": true,
|
||||
"pf-m-3-col": true,
|
||||
"pf-c-card": true,
|
||||
"pf-m-non-selectable-raised": disabled,
|
||||
"pf-m-selectable-raised": !disabled,
|
||||
"pf-m-selected-raised": selected,
|
||||
})}
|
||||
tabindex=${idx}
|
||||
data-ouid-component-type="ak-type-create-grid-card"
|
||||
data-ouid-component-name=${componentName}
|
||||
role="option"
|
||||
aria-disabled="${disabled ? "true" : "false"}"
|
||||
aria-selected="${selected ? "true" : "false"}"
|
||||
aria-label="${type.name}"
|
||||
aria-describedby="${type.description}"
|
||||
@click=${() => {
|
||||
if (requiresEnterprise) return;
|
||||
if (disabled) return;
|
||||
|
||||
this.selectDispatch(type);
|
||||
this.#selectDispatch(type);
|
||||
this.selectedType = type;
|
||||
}}
|
||||
>
|
||||
${type.iconUrl
|
||||
? html`<div class="pf-c-card__header">
|
||||
<div class="pf-c-card__header-main">
|
||||
<img src=${type.iconUrl} alt=${msg(str`${type.name} Icon`)} />
|
||||
? html`<div role="presentation" class="pf-c-card__header">
|
||||
<div role="presentation" class="pf-c-card__header-main">
|
||||
<img
|
||||
aria-hidden="true"
|
||||
src=${type.iconUrl}
|
||||
alt=${msg(str`${type.name} Icon`)}
|
||||
/>
|
||||
</div>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="pf-c-card__title">${type.name}</div>
|
||||
<div class="pf-c-card__body">${type.description}</div>
|
||||
${requiresEnterprise
|
||||
<div role="heading" aria-level="2" class="pf-c-card__title">${type.name}</div>
|
||||
<div role="presentational" class="pf-c-card__body">${type.description}</div>
|
||||
${disabled
|
||||
? html`<div class="pf-c-card__footer">
|
||||
<ak-license-notice></ak-license-notice>
|
||||
</div> `
|
||||
@@ -140,34 +150,37 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) {
|
||||
return html`<form
|
||||
${ref(this.formRef)}
|
||||
class="pf-c-form pf-m-horizontal"
|
||||
data-ouid-component-type="ak-type-create-list"
|
||||
role="radiogroup"
|
||||
aria-label=${msg("Select a provider type")}
|
||||
>
|
||||
${this.types.map((type) => {
|
||||
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
|
||||
const disabled = !!(type.requiresEnterprise && !this.hasEnterpriseLicense);
|
||||
const inputID = `${type.component}-${type.modelName}`;
|
||||
const selected = this.selectedType === type;
|
||||
|
||||
return html`<div
|
||||
class="pf-c-radio"
|
||||
data-ouid-component-type="ak-type-create-list-card"
|
||||
data-ouid-component-name=${type.modelName.split(".")[1] ?? "--unknown--"}
|
||||
>
|
||||
return html`<div class="pf-c-radio">
|
||||
<input
|
||||
class="pf-c-radio__input"
|
||||
type="radio"
|
||||
name="type"
|
||||
id=${`${type.component}-${type.modelName}`}
|
||||
id=${`${inputID}`}
|
||||
aria-label=${type.name}
|
||||
aria-describedby=${`${inputID}-description`}
|
||||
@change=${() => {
|
||||
this.selectDispatch(type);
|
||||
this.#selectDispatch(type);
|
||||
}}
|
||||
?disabled=${requiresEnterprise}
|
||||
?disabled=${disabled}
|
||||
/>
|
||||
<label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`}
|
||||
<label
|
||||
aria-selected="${selected ? "true" : "false"}"
|
||||
aria-labelledby="${inputID}"
|
||||
class="pf-c-radio__label"
|
||||
for="${inputID}"
|
||||
>${type.name}</label
|
||||
>
|
||||
<span class="pf-c-radio__description"
|
||||
<span id="${inputID}-description" class="pf-c-radio__description"
|
||||
>${type.description}
|
||||
${requiresEnterprise
|
||||
? html`<ak-license-notice></ak-license-notice>`
|
||||
: nothing}
|
||||
${disabled ? html`<ak-license-notice></ak-license-notice>` : nothing}
|
||||
</span>
|
||||
</div>`;
|
||||
})}
|
||||
|
||||
@@ -273,25 +273,42 @@ export class Wizard extends ModalButton {
|
||||
this.activeStepElement = nextPage;
|
||||
}
|
||||
};
|
||||
return html`<div class="pf-c-wizard">
|
||||
<div class="pf-c-wizard__header">
|
||||
return html`<div class="pf-c-wizard" role="presentation">
|
||||
<header class="pf-c-wizard__header">
|
||||
${this.canCancel
|
||||
? html`<button
|
||||
data-test-id="wizard-close"
|
||||
class="pf-c-button pf-m-plain pf-c-wizard__close"
|
||||
type="button"
|
||||
aria-label="${msg("Close")}"
|
||||
aria-label="${msg("Close wizard")}"
|
||||
@click=${this.#reset}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>`
|
||||
: nothing}
|
||||
<h1 class="pf-c-title pf-m-3xl pf-c-wizard__title">${this.header}</h1>
|
||||
<p class="pf-c-wizard__description">${this.description}</p>
|
||||
</div>
|
||||
<div class="pf-c-wizard__outer-wrap">
|
||||
<h1
|
||||
id="modal-title"
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
class="pf-c-title pf-m-3xl pf-c-wizard__title"
|
||||
data-test-id="wizard-heading"
|
||||
>
|
||||
${this.header}
|
||||
</h1>
|
||||
<p
|
||||
role="heading"
|
||||
aria-level="2"
|
||||
id="modal-description"
|
||||
class="pf-c-wizard__description"
|
||||
>
|
||||
${this.description}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div role="presentation" class="pf-c-wizard__outer-wrap">
|
||||
<div class="pf-c-wizard__inner-wrap">
|
||||
<nav class="pf-c-wizard__nav">
|
||||
<ol class="pf-c-wizard__nav-list">
|
||||
<nav aria-label="${msg("Wizard steps")}" class="pf-c-wizard__nav">
|
||||
<ol role="presentation" class="pf-c-wizard__nav-list">
|
||||
${this.steps.map((step, idx) => {
|
||||
const stepEl = this.getStepElementByName(step);
|
||||
|
||||
@@ -300,7 +317,7 @@ export class Wizard extends ModalButton {
|
||||
const sidebarLabel = stepEl.sidebarLabel();
|
||||
|
||||
return html`
|
||||
<li class="pf-c-wizard__nav-item">
|
||||
<li role="presentation" class="pf-c-wizard__nav-item">
|
||||
<button
|
||||
class=${classMap({
|
||||
"pf-c-wizard__nav-link": true,
|
||||
@@ -319,14 +336,15 @@ export class Wizard extends ModalButton {
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
<main class="pf-c-wizard__main">
|
||||
<div class="pf-c-wizard__main-body">
|
||||
<main aria-label="${msg("Wizard content")}" class="pf-c-wizard__main">
|
||||
<div role="presentation" class="pf-c-wizard__main-body">
|
||||
<slot name=${this.activeStepElement?.slot || this.steps[0]}></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<footer class="pf-c-wizard__footer">
|
||||
<nav class="pf-c-wizard__footer" aria-label="${msg("Wizard navigation")}">
|
||||
<button
|
||||
data-test-id="wizard-navigation-next"
|
||||
class="pf-c-button pf-m-primary"
|
||||
?disabled=${!this.isValid}
|
||||
type="button"
|
||||
@@ -339,6 +357,7 @@ export class Wizard extends ModalButton {
|
||||
: 0) > 0 && this.canBack
|
||||
? html`
|
||||
<button
|
||||
data-test-id="wizard-navigation-previous"
|
||||
class="pf-c-button pf-m-secondary"
|
||||
type="button"
|
||||
@click=${navigatePrevious}
|
||||
@@ -348,8 +367,9 @@ export class Wizard extends ModalButton {
|
||||
`
|
||||
: nothing}
|
||||
${this.canCancel
|
||||
? html`<div class="pf-c-wizard__footer-cancel">
|
||||
? html`<div class="pf-c-wizard__footer-abort">
|
||||
<button
|
||||
data-test-id="wizard-navigation-cancel"
|
||||
class="pf-c-button pf-m-link"
|
||||
type="button"
|
||||
@click=${this.#reset}
|
||||
@@ -358,7 +378,7 @@ export class Wizard extends ModalButton {
|
||||
</button>
|
||||
</div>`
|
||||
: nothing}
|
||||
</footer>
|
||||
</nav>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -370,4 +390,18 @@ declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-wizard": Wizard;
|
||||
}
|
||||
|
||||
interface WizardNavigationTestIDMap {
|
||||
next: HTMLButtonElement;
|
||||
previous: HTMLButtonElement;
|
||||
cancel: HTMLButtonElement;
|
||||
}
|
||||
|
||||
interface WizardTestIDMap {
|
||||
navigation: WizardNavigationTestIDMap;
|
||||
}
|
||||
|
||||
interface TestIDSelectorMap {
|
||||
wizard: WizardTestIDMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,12 +97,18 @@ export abstract class BaseStage<
|
||||
}
|
||||
|
||||
return html`<div class="pf-c-form__alert">
|
||||
${nonFieldErrors.map((err) => {
|
||||
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
|
||||
${nonFieldErrors.map((err, idx) => {
|
||||
return html`<div
|
||||
role="alert"
|
||||
aria-labelledby="error-message-${idx}"
|
||||
class="pf-c-alert pf-m-inline pf-m-danger"
|
||||
>
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<i aria-hidden="true" class="fas fa-exclamation-circle"></i>
|
||||
</div>
|
||||
<h4 class="pf-c-alert__title">${pluckErrorDetail(err)}</h4>
|
||||
<p id="error-message-${idx}" class="pf-c-alert__title">
|
||||
${pluckErrorDetail(err)}
|
||||
</p>
|
||||
</div>`;
|
||||
})}
|
||||
</div>`;
|
||||
|
||||
@@ -71,7 +71,7 @@ export class LibraryPageApplicationEmptyList
|
||||
return html` <div class="pf-c-empty-state pf-m-full-height">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">${msg("No Applications available.")}</h1>
|
||||
<h2 class="pf-c-title pf-m-lg">${msg("No Applications available.")}</h2>
|
||||
<div class="pf-c-empty-state__body">
|
||||
${msg("Either no applications are defined, or you don’t have access to any.")}
|
||||
</div>
|
||||
|
||||
@@ -182,13 +182,16 @@ export class LibraryPage extends AKElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<main role="main" class="pf-c-page__main" tabindex="-1" id="main-content">
|
||||
<div class="pf-c-content header">
|
||||
<h1 role="heading" aria-level="1" id="library-page-title">
|
||||
${msg("My applications")}
|
||||
</h1>
|
||||
return html`<main
|
||||
aria-label=${msg("Applications library")}
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
>
|
||||
<header class="pf-c-content header">
|
||||
<h1>${msg("My applications")}</h1>
|
||||
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
|
||||
</div>
|
||||
</header>
|
||||
<section class="pf-c-page__main-section">${this.renderState()}</section>
|
||||
</main>`;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<main class="pf-c-page__main">
|
||||
<div class="pf-c-page__main">
|
||||
<ak-router-outlet
|
||||
class="pf-l-bullseye__item pf-c-page__main"
|
||||
tabindex="-1"
|
||||
@@ -231,7 +231,7 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
|
||||
.routes=${ROUTES}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-notification-drawer
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import { createRandomName } from "#e2e/utils/generators";
|
||||
import { ConsoleLogger } from "#logger/node";
|
||||
|
||||
import { IDGenerator } from "@goauthentik/core/id";
|
||||
import { series } from "@goauthentik/core/promises";
|
||||
|
||||
test.describe("Provider Wizard", () => {
|
||||
const providerNames = new Map<string, string>();
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
test.beforeEach("Configure Providers", async ({ page, session }, { testId }) => {
|
||||
const seed = IDGenerator.randomID(6);
|
||||
const providerName = `${createRandomName({ seed })} (${seed})`;
|
||||
|
||||
providerNames.set(testId, providerName);
|
||||
|
||||
const wizard = page.getByRole("dialog", { name: "New provider" });
|
||||
|
||||
await test.step("Authenticate", async () => {
|
||||
await session.login({
|
||||
to: "/if/admin/#/core/providers",
|
||||
});
|
||||
});
|
||||
|
||||
await test.step("Navigate to provider wizard", async () => {
|
||||
await expect(wizard, "Wizard is initially closed").toBeHidden();
|
||||
|
||||
await page.getByRole("button", { name: "New Provider" }).click();
|
||||
|
||||
await expect(wizard, "Wizard opens after clicking on New Provider").toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole("listbox", { name: "Select a provider type" }),
|
||||
"Wizard opens with a list of provider types",
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
wizard.getByRole("navigation").getByRole("button", {
|
||||
name: /next|finish/i,
|
||||
}),
|
||||
"Wizard can't be navigated to next step",
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test.afterEach("Verification", async ({ page }, { testId }) => {
|
||||
//#region Confirm provider
|
||||
|
||||
const providerName = providerNames.get(testId)!;
|
||||
|
||||
const $provider = await test.step("Find provider via search", async () => {
|
||||
const searchInput = page.getByRole("search").getByPlaceholder("Search for providers");
|
||||
|
||||
await searchInput.fill(providerName);
|
||||
|
||||
// We have to wait for the provider to appear in the table,
|
||||
// but several UI elements will be rendered asynchronously.
|
||||
// We attempt several times to find the provider to avoid flakiness.
|
||||
|
||||
const tries = 10;
|
||||
let found = false;
|
||||
|
||||
for (let i = 0; i < tries; i++) {
|
||||
await searchInput.press("Enter");
|
||||
await searchInput.blur();
|
||||
|
||||
const $rowEntry = page.getByRole("row", {
|
||||
name: providerName,
|
||||
});
|
||||
|
||||
ConsoleLogger.info(
|
||||
`${i + 1}/${tries} Waiting for provider ${providerName} to appear in the table`,
|
||||
);
|
||||
|
||||
found = await $rowEntry
|
||||
.waitFor({
|
||||
timeout: 1500,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
if (found) {
|
||||
ConsoleLogger.info(`Provider ${providerName} found in the table`);
|
||||
return $rowEntry;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Provider ${providerName} not found in the table`);
|
||||
});
|
||||
|
||||
await expect($provider, "Provider is visible").toBeVisible();
|
||||
|
||||
//#endregion
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region OAuth2
|
||||
|
||||
test("Simple OAuth2 Provider", async ({ form, pointer }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, selectSearchValue } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "OAuth2/OpenID", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[
|
||||
selectSearchValue,
|
||||
"Authorization flow",
|
||||
/default-provider-authorization-explicit-consent/,
|
||||
],
|
||||
[click, "Finish"],
|
||||
);
|
||||
});
|
||||
|
||||
test("Complete OAuth2 Provider", async ({ page, form, pointer }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
|
||||
const { fill, selectSearchValue, setFormGroup, setRadio, setInputCheck } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
const $clientSecretInput = page.getByRole("textbox", { name: "Client Secret" });
|
||||
|
||||
await series(
|
||||
[click, "OAuth2/OpenID", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[
|
||||
selectSearchValue,
|
||||
"Authorization flow",
|
||||
/default-provider-authorization-explicit-consent/,
|
||||
],
|
||||
[setFormGroup, "Protocol settings", true],
|
||||
[setRadio, "Client Type", "Public"],
|
||||
[
|
||||
expect(
|
||||
$clientSecretInput,
|
||||
"Client Secret should be hidden when Client Type is Public",
|
||||
).toBeHidden,
|
||||
],
|
||||
[setRadio, "Client Type", "Confidential"],
|
||||
[
|
||||
expect(
|
||||
$clientSecretInput,
|
||||
"Client Secret should be visible when Client Type is Confidential",
|
||||
).toBeVisible,
|
||||
],
|
||||
[selectSearchValue, "Signing Key", /authentik Self-signed Certificate/],
|
||||
[selectSearchValue, "Encryption Key", /authentik Self-signed Certificate/],
|
||||
[setFormGroup, "Advanced flow settings", true],
|
||||
[selectSearchValue, "Authentication flow", /default-source-authentication/],
|
||||
[selectSearchValue, "Invalidation flow", /default-invalidation-flow/],
|
||||
[setFormGroup, "Advanced protocol settings", true],
|
||||
[fill, "Access code validity", "minutes=2"],
|
||||
[fill, "Access token validity", "minutes=10"],
|
||||
[fill, "Refresh token validity", "days=40"],
|
||||
[setInputCheck, "Include claims in id_token", false],
|
||||
[setRadio, "Subject mode", "Based on the User's username"],
|
||||
[setRadio, "Issuer mode", "Same identifier is used for all providers"],
|
||||
[setFormGroup, "Machine-to-Machine authentication settings", true],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region LDAP
|
||||
|
||||
test("Complete LDAP Provider", async ({ page, pointer, form }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, setFormGroup, selectSearchValue, setInputCheck, setRadio } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "LDAP", "option"],
|
||||
[click, "Next"],
|
||||
|
||||
[fill, "Provider name", providerName],
|
||||
[setFormGroup, "Flow settings", true],
|
||||
[setFormGroup, "Protocol settings", true],
|
||||
[selectSearchValue, "Bind flow", /default-authentication-flow/],
|
||||
[fill, "Base DN", "DC=ldap-2,DC=goauthentik,DC=io"],
|
||||
[selectSearchValue, "Certificate", /authentik Self-signed Certificate/],
|
||||
[fill, "TLS Server name", "goauthentik.io"],
|
||||
[fill, "UID start number", "2001"],
|
||||
[fill, "GID start number", "4001"],
|
||||
[setRadio, "Search mode", "Direct querying"],
|
||||
[setRadio, "Bind mode", "Direct binding"],
|
||||
[setInputCheck, "MFA Support", false],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region RADIUS
|
||||
|
||||
test("Complete RADIUS Provider", async ({ page, pointer, form }, testInfo) => {
|
||||
const providerName = providerNames.get(testInfo.testId)!;
|
||||
const { fill, selectSearchValue } = form;
|
||||
const { click } = pointer;
|
||||
|
||||
await series(
|
||||
[click, "RADIUS", "option"],
|
||||
[click, "Next"],
|
||||
[fill, "Provider name", providerName],
|
||||
[selectSearchValue, "Authentication flow", /default-authentication-flow/],
|
||||
[click, "Finish", "button", page.getByRole("dialog", { name: "New Provider" })],
|
||||
);
|
||||
});
|
||||
|
||||
//#endregion
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from "#e2e";
|
||||
import {
|
||||
BAD_PASSWORD,
|
||||
BAD_USERNAME,
|
||||
GOOD_PASSWORD,
|
||||
GOOD_USERNAME,
|
||||
} from "#e2e/fixtures/SessionFixture";
|
||||
|
||||
test.beforeEach(async ({ session }) => {
|
||||
await session.toLoginPage();
|
||||
});
|
||||
|
||||
test.describe("Session management", () => {
|
||||
test("Login with valid credentials", async ({ session, page }) => {
|
||||
await session.login({ username: GOOD_USERNAME, password: GOOD_PASSWORD });
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
level: 1,
|
||||
}),
|
||||
).toHaveText("My applications");
|
||||
});
|
||||
|
||||
test("Reject bad username", async ({ session }) => {
|
||||
await session.login({ username: BAD_USERNAME, password: GOOD_PASSWORD });
|
||||
|
||||
await expect(session.$authFailureMessage).toBeVisible();
|
||||
});
|
||||
|
||||
test("Reject bad password", async ({ session }) => {
|
||||
await session.login({ username: GOOD_USERNAME, password: BAD_PASSWORD });
|
||||
|
||||
await expect(session.$authFailureMessage).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Vitest browser utilities for Lit.
|
||||
*
|
||||
* @import { LocatorSelectors } from '@vitest/browser/context'
|
||||
* @import { PrettyDOMOptions } from '@vitest/browser/utils'
|
||||
* @import { RenderOptions as LitRenderOptions } from 'lit'
|
||||
*/
|
||||
|
||||
import { debug, getElementLocatorSelectors } from "@vitest/browser/utils";
|
||||
|
||||
import { render as renderLit } from "lit";
|
||||
|
||||
/**
|
||||
* @implements {Disposable}
|
||||
*/
|
||||
export class LitViteContext {
|
||||
/**
|
||||
* @type {Set<Disposable>}
|
||||
*/
|
||||
static #resources = new Set();
|
||||
|
||||
/**
|
||||
* @param {unknown} template
|
||||
* @param {HTMLElement} [container]
|
||||
* @param {LitRenderOptions} [options]
|
||||
*
|
||||
* @returns {LitViteContext}
|
||||
*/
|
||||
static render = (template, container = document.createElement("div"), options) => {
|
||||
const context = new LitViteContext(container);
|
||||
context.render(template, options);
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
static [Symbol.dispose] = () => {
|
||||
this.#resources.forEach((resource) => resource[Symbol.dispose]());
|
||||
this.#resources.clear();
|
||||
};
|
||||
|
||||
static cleanup = () => {
|
||||
return this[Symbol.dispose]();
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {unknown} template
|
||||
* @param {LitRenderOptions} [options]
|
||||
*/
|
||||
render(template, options) {
|
||||
return renderLit(template, this.container, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {HTMLElement} container
|
||||
*/
|
||||
container;
|
||||
|
||||
/**
|
||||
* @type {LocatorSelectors}
|
||||
*/
|
||||
$;
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.$ = getElementLocatorSelectors(container);
|
||||
}
|
||||
|
||||
toFragment() {
|
||||
return document.createRange().createContextualFragment(this.container.innerHTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} [maxLength]
|
||||
* @param {PrettyDOMOptions} [options]
|
||||
*/
|
||||
debug(maxLength, options) {
|
||||
return debug(this.container, maxLength, options);
|
||||
}
|
||||
|
||||
[Symbol.dispose] = () => {
|
||||
this.container.remove();
|
||||
LitViteContext.#resources.delete(this);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { LitViteContext } from "./rendering.js";
|
||||
|
||||
import { page } from "@vitest/browser/context";
|
||||
import { beforeEach } from "vitest";
|
||||
|
||||
page.extend({
|
||||
// @ts-ignore
|
||||
renderLit: LitViteContext.render,
|
||||
[Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup,
|
||||
});
|
||||
|
||||
beforeEach(() => LitViteContext.cleanup());
|
||||
@@ -19,7 +19,7 @@ class LoginPage extends Page {
|
||||
}
|
||||
|
||||
async inputPassword() {
|
||||
return await $(">>>input#ak-stage-password-input");
|
||||
return await $('>>>input[name="password"]');
|
||||
}
|
||||
|
||||
async passwordBtnSubmit() {
|
||||
@@ -53,7 +53,7 @@ class LoginPage extends Page {
|
||||
await this.pause();
|
||||
await this.password(password);
|
||||
await this.pause();
|
||||
await this.pause(">>>div.header h1");
|
||||
await this.pause(">>>header h1");
|
||||
return UserLibraryPage;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class UserLibraryPage extends Page {
|
||||
*/
|
||||
|
||||
public async pageHeader() {
|
||||
return await $('>>>h1[aria-level="1"]');
|
||||
return $(">>>header h1");
|
||||
}
|
||||
|
||||
public async goToAdmin() {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import LoginPage from "../pageobjects/login.page.js";
|
||||
import { BAD_PASSWORD, GOOD_USERNAME } from "../utils/constants.js";
|
||||
|
||||
import { expect } from "@wdio/globals";
|
||||
|
||||
describe("Log into authentik", () => {
|
||||
it("should fail on a bad password", async () => {
|
||||
await LoginPage.open();
|
||||
await LoginPage.username(GOOD_USERNAME);
|
||||
await LoginPage.pause();
|
||||
await LoginPage.password(BAD_PASSWORD);
|
||||
const failure = await LoginPage.authFailure();
|
||||
await expect(failure).toBeDisplayedInViewport();
|
||||
await expect(failure).toHaveText("Invalid password");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import LoginPage from "../pageobjects/login.page.js";
|
||||
import { BAD_USERNAME, GOOD_PASSWORD } from "../utils/constants.js";
|
||||
|
||||
import { expect } from "@wdio/globals";
|
||||
|
||||
describe("Log into authentik", () => {
|
||||
it("should fail on a bad username", async () => {
|
||||
await LoginPage.open();
|
||||
await LoginPage.username(BAD_USERNAME);
|
||||
await LoginPage.pause();
|
||||
await LoginPage.password(GOOD_PASSWORD);
|
||||
const failure = await LoginPage.authFailure();
|
||||
await expect(failure).toBeDisplayedInViewport();
|
||||
await expect(failure).toHaveText("Invalid password");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { login } from "../utils/login.js";
|
||||
|
||||
describe("Log into authentik", () => {
|
||||
it("should login with valid credentials and reach the UserLibrary", login);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "node",
|
||||
"module": "ESNext",
|
||||
|
||||
+11
-13
@@ -4,14 +4,15 @@
|
||||
* @see https://webdriver.io/docs/configurationfile.html
|
||||
*/
|
||||
|
||||
import { cwd } from "node:process";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { addCommands } from "../commands.mjs";
|
||||
|
||||
import litCSS from "#bundler/vite-plugin-lit-css/node";
|
||||
import { createBundleDefinitions } from "#bundler/utils/node";
|
||||
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
|
||||
import { PackageRoot } from "#paths/node";
|
||||
|
||||
const NODE_ENV = process.env.NODE_ENV || "development";
|
||||
const headless = !!process.env.HEADLESS || !!process.env.CI;
|
||||
const headless = !process.env.HEADLESS || !!process.env.CI;
|
||||
const lemmeSee = !!process.env.WDIO_LEMME_SEE;
|
||||
|
||||
/**
|
||||
@@ -70,27 +71,24 @@ if (process.env.WDIO_TEST_FIREFOX) {
|
||||
*/
|
||||
const browserRunnerOptions = {
|
||||
viteConfig: {
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(NODE_ENV),
|
||||
"process.env.CWD": JSON.stringify(cwd()),
|
||||
"process.env.AK_API_BASE_PATH": JSON.stringify(process.env.AK_API_BASE_PATH || ""),
|
||||
},
|
||||
define: createBundleDefinitions(),
|
||||
plugins: [
|
||||
// ---
|
||||
// @ts-ignore WDIO's Vite is out of date.
|
||||
litCSS(),
|
||||
inlineCSSPlugin(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @satisfies {WebdriverIO.Config}
|
||||
*/
|
||||
export const config = {
|
||||
runner: ["browser", browserRunnerOptions],
|
||||
|
||||
tsConfigPath: "./tsconfig.test.json",
|
||||
tsConfigPath: path.resolve(PackageRoot, "tests", "tsconfig.test.json"),
|
||||
|
||||
specs: [path.resolve(PackageRoot, "tests", "specs", "**", "*.ts")],
|
||||
|
||||
specs: ["./src/**/*.test.ts"],
|
||||
exclude: [],
|
||||
|
||||
maxInstances,
|
||||
|
||||
@@ -2,5 +2,11 @@
|
||||
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/*.test.ts", "./tests"]
|
||||
"exclude": [
|
||||
// ---
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.comp.ts",
|
||||
"./**/*.stories.ts",
|
||||
"./tests"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+22
-1
@@ -14,12 +14,13 @@ declare module "module" {
|
||||
* const relativeDirname = dirname(fileURLToPath(import.meta.url));
|
||||
* ```
|
||||
*/
|
||||
|
||||
var __dirname: string;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "process" {
|
||||
import { Level } from "pino";
|
||||
|
||||
global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
@@ -30,6 +31,26 @@ declare module "process" {
|
||||
* @see {@link https://nodejs.org/en/learn/getting-started/nodejs-the-difference-between-development-and-production | The difference between development and production}
|
||||
*/
|
||||
readonly NODE_ENV?: "development" | "production";
|
||||
|
||||
/**
|
||||
* Whether or not we are running on a CI server.
|
||||
*/
|
||||
readonly CI?: string;
|
||||
|
||||
/**
|
||||
* The application log level.
|
||||
*/
|
||||
readonly AK_LOG_LEVEL?: Level;
|
||||
|
||||
/**
|
||||
* The base URL of web server to run the tests against.
|
||||
*
|
||||
* Typically this is `http://localhost:9000`.
|
||||
*
|
||||
* @format url
|
||||
*/
|
||||
readonly AK_TEST_RUNNER_PAGE_URL?: string;
|
||||
|
||||
/**
|
||||
* @todo Determine where this is used and if it is needed,
|
||||
* give it a better name.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/// <reference types="vitest/config" />
|
||||
|
||||
import { createBundleDefinitions } from "#bundler/utils/node";
|
||||
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
|
||||
|
||||
@@ -9,4 +11,41 @@ export default defineConfig({
|
||||
// ---
|
||||
inlineCSSPlugin(),
|
||||
],
|
||||
test: {
|
||||
dir: "./test",
|
||||
exclude: [
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/.{idea,git,cache,output,temp}/**",
|
||||
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*",
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
test: {
|
||||
include: ["./unit/**/*.{test,spec}.ts", "**/*.unit.{test,spec}.ts"],
|
||||
name: "unit",
|
||||
environment: "node",
|
||||
},
|
||||
},
|
||||
{
|
||||
test: {
|
||||
setupFiles: ["./test/lit/setup.js"],
|
||||
|
||||
include: ["./browser/**/*.{test,spec}.ts", "**/*.browser.{test,spec}.ts"],
|
||||
name: "browser",
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: "playwright",
|
||||
|
||||
instances: [
|
||||
{
|
||||
browser: "chromium",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
+34
-20
@@ -1,16 +1,23 @@
|
||||
import { addCommands } from "./commands.mjs";
|
||||
|
||||
/**
|
||||
* @file WebdriverIO configuration file for **integration tests**.
|
||||
*
|
||||
* @see https://webdriver.io/docs/configurationfile.html
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
|
||||
import { addCommands } from "./commands.mjs";
|
||||
|
||||
import { createBundleDefinitions } from "#bundler/utils/node";
|
||||
import { inlineCSSPlugin } from "#bundler/vite-plugin-lit-css/node";
|
||||
import { PackageRoot } from "#paths/node";
|
||||
|
||||
import { browser } from "@wdio/globals";
|
||||
|
||||
/// <reference types="@wdio/globals/types" />
|
||||
/// <reference types="./types/webdriver.js" />
|
||||
|
||||
const headless = !!process.env.CI;
|
||||
const headless = !process.env.HEADLESS || !!process.env.CI;
|
||||
const lemmeSee = !!process.env.WDIO_LEMME_SEE;
|
||||
|
||||
/**
|
||||
@@ -24,21 +31,18 @@ if (!process.env.WDIO_SKIP_CHROME) {
|
||||
*/
|
||||
const chromeBrowserConfig = {
|
||||
"browserName": "chrome",
|
||||
// "wdio:chromedriverOptions": {
|
||||
// binary: "./node_modules/.bin/chromedriver",
|
||||
// },
|
||||
"goog:chromeOptions": {
|
||||
args: ["disable-infobars", "window-size=1280,800"],
|
||||
args: ["disable-search-engine-choice-screen"],
|
||||
},
|
||||
};
|
||||
|
||||
if (headless) {
|
||||
chromeBrowserConfig["goog:chromeOptions"].args.push(
|
||||
"headless",
|
||||
"no-sandbox",
|
||||
"disable-gpu",
|
||||
"disable-setuid-sandbox",
|
||||
"disable-dev-shm-usage",
|
||||
"no-sandbox",
|
||||
"window-size=1280,672",
|
||||
"browser-test",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,17 +61,29 @@ if (process.env.WDIO_TEST_FIREFOX) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {WebdriverIO.BrowserRunnerOptions}
|
||||
*/
|
||||
const browserRunnerOptions = {
|
||||
viteConfig: {
|
||||
define: createBundleDefinitions(),
|
||||
plugins: [
|
||||
// ---
|
||||
inlineCSSPlugin(),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* @satisfies {WebdriverIO.Config}
|
||||
*/
|
||||
export const config = {
|
||||
runner: "local",
|
||||
tsConfigPath: "./tsconfig.json",
|
||||
runner: ["browser", browserRunnerOptions],
|
||||
|
||||
tsConfigPath: path.resolve(PackageRoot, "tsconfig.test.json"),
|
||||
|
||||
specs: [path.resolve(PackageRoot, "src", "**", "*.test.ts")],
|
||||
|
||||
specs: [
|
||||
// "./tests/specs/**/*.ts"
|
||||
"./tests/specs/new-application-by-wizard.ts",
|
||||
],
|
||||
exclude: [],
|
||||
maxInstances: 1,
|
||||
capabilities,
|
||||
@@ -84,13 +100,11 @@ export const config = {
|
||||
ui: "bdd",
|
||||
timeout: 60000,
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {WebdriverIO.Capabilities} capabilities
|
||||
* @param {string[]} specs
|
||||
* @param {WebdriverIO.Browser} browser
|
||||
* @returns {void}
|
||||
*/
|
||||
before(capabilities, specs, browser) {
|
||||
before(_capabilities, _specs, browser) {
|
||||
addCommands(browser);
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user