From 1c821998525e29de19740db46c7e852118373aaa Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Mon, 18 May 2026 13:10:17 +0200 Subject: [PATCH] web/table: fetch on first render when already visible (#22376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * web/table: fetch on first render when already visible Tables inside `` rendered empty until the user clicked the refresh button. The 2026.5 RC native-`` migration taught `AKModal.updated()` to force `visible = true` on its slotted child, but `Table.firstUpdated()` was delegating to `#synchronizeRefreshSchedule()`, which only flushes a *previously deferred* refresh. With visibility forced on before the first update cycle, no deferred refresh was ever queued, so the synchronizer no-op'd and nothing fetched. Switch the first-update hook to call `fetch()` directly. `fetch()` already handles both states correctly: if the table is visible it issues the request immediately, and if it isn't it queues the deferred refresh that the synchronizer flushes when visibility flips on. Beyond the modal case this also covers any future caller that mounts a Table already-visible. Reproduced and verified against the user-library RAC endpoint launcher (the surface from the beta report). Added a Playwright e2e (`rac-launch-modal.test.ts`) that seeds a RAC provider + two endpoints via the API, opens the launcher, and asserts the endpoint rows appear without a manual refresh — fails on `main`, passes with this change. A 2026.5 backport will follow as a separate PR. Co-Authored-By: Agent (authentik-m-triage-rac-proper-shared-lilac) <279763771+playpen-agent@users.noreply.github.com> * web/test: silence cspell on AK_TEST_BOOTSTRAP_TOKEN fallback `changeme` in the playpen-specific default for `AK_TEST_BOOTSTRAP_TOKEN` trips the spellcheck lint job. Add an inline `cspell:ignore` directive so the fallback can stay (CI sets the env var so the default is only used locally inside playpen sandboxes). * Flesh out RAC test coverage. * Use simple search for applications list. * Add order. * Ignore playwright result. * Remove unused. * Tidy for test. * Fix test selectors. * Fix overlap. * Defer to connected callback. * Use consistent Patternfly input outline. * Clean up labels. * Only trigger navigation on non-current entries. * Ensure that selected type is retained. --------- Co-authored-by: Agent (authentik-m-triage-rac-proper-shared-lilac) <279763771+playpen-agent@users.noreply.github.com> --- web/.gitignore | 1 + web/e2e/fixtures/FormFixture.ts | 32 +-- web/e2e/fixtures/NavigatorFixture.ts | 6 - web/eslint.config.mjs | 8 + web/playwright.config.js | 14 +- web/src/admin/providers/rac/EndpointForm.ts | 54 ++--- .../admin/providers/rac/RACProviderForm.ts | 39 ++-- web/src/admin/roles/ak-role-list.ts | 8 +- web/src/admin/stages/ak-stage-wizard.ts | 9 +- .../wizard/InvitationWizardEmailStep.ts | 2 +- web/src/admin/users/ak-user-wizard.ts | 2 +- web/src/elements/messages/MessageContainer.ts | 2 +- web/src/elements/table/Table.ts | 11 +- web/src/elements/wizard/CreateWizard.ts | 85 +++++--- .../elements/wizard/TypeCreateWizardPage.ts | 16 +- web/src/elements/wizard/Wizard.ts | 1 + web/src/styles/authentik/base/common.css | 6 + web/src/user/LibraryPage/ak-library-impl.ts | 1 - .../{session.test.ts => 100-session.test.ts} | 2 +- ....test.ts => 101-session-lifecycle.test.ts} | 19 +- .../{modals.test.ts => 200-modals.test.ts} | 0 .../{users.test.ts => 300-users.test.ts} | 5 +- .../{groups.test.ts => 400-groups.test.ts} | 0 .../{roles.test.ts => 500-roles.test.ts} | 0 ...roviders.test.ts => 600-providers.test.ts} | 0 ...tions.test.ts => 700-applications.test.ts} | 0 web/test/browser/800-rac.test.ts | 203 ++++++++++++++++++ 27 files changed, 384 insertions(+), 142 deletions(-) rename web/test/browser/{session.test.ts => 100-session.test.ts} (95%) rename web/test/browser/{session-lifecycle.test.ts => 101-session-lifecycle.test.ts} (86%) rename web/test/browser/{modals.test.ts => 200-modals.test.ts} (100%) rename web/test/browser/{users.test.ts => 300-users.test.ts} (98%) rename web/test/browser/{groups.test.ts => 400-groups.test.ts} (100%) rename web/test/browser/{roles.test.ts => 500-roles.test.ts} (100%) rename web/test/browser/{providers.test.ts => 600-providers.test.ts} (100%) rename web/test/browser/{applications.test.ts => 700-applications.test.ts} (100%) create mode 100644 web/test/browser/800-rac.test.ts diff --git a/web/.gitignore b/web/.gitignore index 1d7ebbe353..c10c9b24b6 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -32,6 +32,7 @@ lib-cov # Coverage directory used by tools like istanbul coverage playwright-report +playwright-traces test-results *.lcov diff --git a/web/e2e/fixtures/FormFixture.ts b/web/e2e/fixtures/FormFixture.ts index 983f966527..cce2d00d98 100644 --- a/web/e2e/fixtures/FormFixture.ts +++ b/web/e2e/fixtures/FormFixture.ts @@ -22,31 +22,15 @@ export class FormFixture extends PageFixture { fieldName: string | RegExp, context: LocatorContext = this.page, ) => { - const control = context - .getByLabel(fieldName, { exact: true }) - .filter({ - hasNot: context.getByRole("presentation"), - }) - .and(context.locator(":not(button)")) - .or( - context.getByRole("textbox", { - name: fieldName, - }), - ) - .or( - context.getByRole("spinbutton", { - name: fieldName, - }), - ); + const textbox = context.getByRole("textbox", { name: fieldName }); + const searchbox = context.getByRole("searchbox", { name: fieldName }); + const spinbutton = context.getByRole("spinbutton", { name: fieldName }); + // Comboboxes (e.g. the Query Language input) wrap an inner textbox. + const comboboxTextbox = context + .getByRole("combobox", { name: fieldName }) + .getByRole("textbox"); - const role = await control.getAttribute("role"); - - if (role === "combobox") { - // Comboboxes, such as our Query Language input need additional handling... - const textbox = control.getByRole("textbox"); - - return textbox; - } + const control = textbox.or(searchbox).or(spinbutton).or(comboboxTextbox); await expect(control, `Field (${fieldName}) should be visible`).toBeVisible(); diff --git a/web/e2e/fixtures/NavigatorFixture.ts b/web/e2e/fixtures/NavigatorFixture.ts index 645cd71f04..bb315a5ef8 100644 --- a/web/e2e/fixtures/NavigatorFixture.ts +++ b/web/e2e/fixtures/NavigatorFixture.ts @@ -2,12 +2,6 @@ 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; diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index cf5f41155a..9c3da7cbba 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -56,6 +56,14 @@ const eslintConfig = defineConfig( }, files: ["**/*.d.ts"], }, + { + rules: { + // Playwright first parameter must be a destructure pattern, + // even when not referencing any fixtures. + "no-empty-pattern": "off", + }, + files: ["**/*.spec.ts", "**/*.test.ts"], + }, ); export default eslintConfig; diff --git a/web/playwright.config.js b/web/playwright.config.js index 863b1e6eaa..ef9ad8dca9 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -23,10 +23,16 @@ export default defineConfig({ testDir: "./test/browser", fullyParallel: true, forbidOnly: CI, - retries: CI ? 2 : 0, - workers: CI ? 1 : undefined, + retries: CI ? 1 : 0, + workers: "50%", + maxFailures: CI ? 5 : 2, reporter: CI - ? "github" + ? [ + // --- + ["github"], + ["html", { open: "never", outputFolder: "playwright-report" }], + ["json", { outputFile: "playwright-report/results.json" }], + ] : [ // --- ["list", { printSteps: true }], @@ -36,6 +42,8 @@ export default defineConfig({ testIdAttribute: "data-test-id", baseURL, trace: "on-first-retry", + screenshot: "only-on-failure", + video: CI ? "retain-on-failure" : "off", colorScheme: "dark", launchOptions: { logger: { diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts index 1ebef00b5c..2015ae0b71 100644 --- a/web/src/admin/providers/rac/EndpointForm.ts +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -13,8 +13,6 @@ import { DEFAULT_CONFIG } from "#common/api/config"; import { ModelForm } from "#elements/forms/ModelForm"; import { SlottedTemplateResult } from "#elements/types"; -import { AKLabel } from "#components/ak-label"; - import { Endpoint, EndpointAuthModeEnum, ProtocolEnum, RacApi } from "@goauthentik/api"; import YAML from "yaml"; @@ -73,38 +71,28 @@ export class EndpointForm extends ModelForm { ?autofocus=${!this.instance} > - - ${AKLabel( - { - slot: "label", - className: "pf-c-form__group-label", - htmlFor: "protocol", - required: true, - }, - msg("Protocol"), - )} - - - + + { protected override renderForm(): TemplateResult { return html` - - - - - + name="name" + value="${ifDefined(this.instance?.name)}" + placeholder=${msg("Type a provider name...")} + spellcheck="false" + ?autofocus=${!this.instance} + > + + + ${AKLabel( + { + className: "pf-c-form__group-label", + slot: "label", + htmlFor: "authorizationFlow", + required: true, + }, + msg("Authorization Flow"), + )} { row(item: Role): SlottedTemplateResult[] { return [ - html`${item.name}`, + html`${item.name}`, html`
${IconEditButton(RoleForm, item.pk)}
`, ]; } diff --git a/web/src/admin/stages/ak-stage-wizard.ts b/web/src/admin/stages/ak-stage-wizard.ts index 21e31367ab..eec408cf11 100644 --- a/web/src/admin/stages/ak-stage-wizard.ts +++ b/web/src/admin/stages/ak-stage-wizard.ts @@ -40,7 +40,7 @@ export class AKStageWizard extends CreateWizard { public override layout = TypeCreateWizardPageLayouts.list; - public override groupLabel = msg("Bind New Stage"); + public override groupLabel = msg("New Stage"); public override groupDescription = msg("Select the type of stage you want to create."); protected apiEndpoint = async (requestInit?: RequestInit): Promise => { @@ -75,12 +75,7 @@ export class AKStageWizard extends CreateWizard { return null; } - return html` + return html` diff --git a/web/src/admin/users/ak-user-wizard.ts b/web/src/admin/users/ak-user-wizard.ts index dabd054d8d..cb4d691ea3 100644 --- a/web/src/admin/users/ak-user-wizard.ts +++ b/web/src/admin/users/ak-user-wizard.ts @@ -79,7 +79,7 @@ export class ServiceAccountResultPage extends WizardPage { this.host.cancelable = false; }; - public formatNextLabel(): SlottedTemplateResult | null { + public override formatNextLabel(): SlottedTemplateResult | null { return ButtonKindLabelRecord.close(); } diff --git a/web/src/elements/messages/MessageContainer.ts b/web/src/elements/messages/MessageContainer.ts index ae29674ef4..06ef03d7b1 100644 --- a/web/src/elements/messages/MessageContainer.ts +++ b/web/src/elements/messages/MessageContainer.ts @@ -110,7 +110,7 @@ export class MessageContainer extends AKElement { public messages: APIMessage[] = []; @property({ type: String, reflect: true, useDefault: true }) - public alignment: MessageContainerAlignment = "bottom-right"; + public alignment: MessageContainerAlignment = "bottom-left"; static styles: CSSResult[] = [PFAlertGroup, Styles]; diff --git a/web/src/elements/table/Table.ts b/web/src/elements/table/Table.ts index 8bd660a46a..4e5e4666d1 100644 --- a/web/src/elements/table/Table.ts +++ b/web/src/elements/table/Table.ts @@ -357,6 +357,12 @@ export abstract class Table if (this.searchEnabled) { this.search = getURLParam(this.#searchParam, ""); } + + // Use `fetch()` rather than `#synchronizeRefreshSchedule()` here: the + // latter only flushes a *previously deferred* refresh and would no-op + // when a parent (e.g. `AKModal`) has already forced `visible = true` + // before the first update cycle, leaving the table empty on open. + this.fetch(); } public override disconnectedCallback(): void { @@ -403,11 +409,6 @@ export abstract class Table } } - protected override firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.#synchronizeRefreshSchedule(); - } - //#endregion protected async defaultEndpointConfig(): Promise { diff --git a/web/src/elements/wizard/CreateWizard.ts b/web/src/elements/wizard/CreateWizard.ts index cdb4da2c22..ec82137e57 100644 --- a/web/src/elements/wizard/CreateWizard.ts +++ b/web/src/elements/wizard/CreateWizard.ts @@ -33,7 +33,6 @@ import { html, PropertyValues } from "lit"; import { guard } from "lit-html/directives/guard.js"; import { createRef, ref } from "lit-html/directives/ref.js"; import { property } from "lit/decorators.js"; -import { keyed } from "lit/directives/keyed.js"; export class CreateWizard extends AKElement implements TransclusionChildElement { /** @@ -141,6 +140,10 @@ export class CreateWizard extends AKElement implements TransclusionChildElement return this.wizardRef.value || null; } + public get pageTypeCreate(): TypeCreateWizardPage | null { + return this.pageTypeCreateRef.value || null; + } + /** * An optional description to show on the initial page of the wizard, * used to explain the different types or provide general information about the creation process. @@ -177,7 +180,7 @@ export class CreateWizard extends AKElement implements TransclusionChildElement }); } - public override firstUpdated(changedProperties: PropertyValues): void { + protected override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); this.refresh(); @@ -241,18 +244,25 @@ export class CreateWizard extends AKElement implements TransclusionChildElement * responsible for updating the wizard's steps and validity. */ protected typeSelectListener = ({ - detail: typeCreate, - }: CustomEvent): boolean | Promise => { - this.selectedType = typeCreate; + detail: selectedType, + }: CustomEvent): boolean | Promise => { + this.selectedType = selectedType; const { wizard } = this; if (!wizard) return false; + const nextSteps = [...this.initialSteps]; + + if (!selectedType) { + wizard.steps = nextSteps; + wizard.valid = false; + return false; + } + const currentSteps = wizard.steps.slice(); - const selectedSteps = this.selectSteps(typeCreate, currentSteps); - const nextSteps = [...this.initialSteps]; + const selectedSteps = this.selectSteps(selectedType, currentSteps); const idx = nextSteps.indexOf("initial") + 1; @@ -305,34 +315,36 @@ export class CreateWizard extends AKElement implements TransclusionChildElement part="main" .initialSteps=${this.initialSteps} .finalHandler=${this.finalHandler} - > - ${this.renderHeading()} - ${keyed( - this.wizard?.activeStep, - html` - ${this.renderCreateBefore()} - ${guard([initialPageContent], () => { - if (!initialPageContent) { - return null; - } + >${this.renderHeading()} + + ${this.renderCreateBefore()} + ${guard([initialPageContent], () => { + if (!initialPageContent) { + return null; + } - return html`
-

${initialPageContent}

-
`; - })} -
`, - )} + return html`
+

${initialPageContent}

+
`; + })} +
${this.renderForms()} `; } @@ -350,7 +362,10 @@ export class CreateWizard extends AKElement implements TransclusionChildElement const slotName = formatTypeCreateStepID(type); const entityLabel = selectedType?.name ?? this.verboseName ?? msg("Entity"); - const label = msg(str`${entityLabel} Details`); + const label = msg(str`${entityLabel} Details`, { + id: "wizard.step.details", + desc: `Label for the step in the creation wizard where the details of the entity being created are filled in. The placeholder {entity} is replaced with the name of the entity type, for example 'User Details' or 'Group Details'.`, + }); const content = StrictUnsafe(type.component, props); diff --git a/web/src/elements/wizard/TypeCreateWizardPage.ts b/web/src/elements/wizard/TypeCreateWizardPage.ts index 113640ffbd..d6382f17d2 100644 --- a/web/src/elements/wizard/TypeCreateWizardPage.ts +++ b/web/src/elements/wizard/TypeCreateWizardPage.ts @@ -10,7 +10,7 @@ import { WizardPage } from "#elements/wizard/WizardPage"; import { TypeCreate } from "@goauthentik/api"; import { msg, str } from "@lit/localize"; -import { css, CSSResult, html } from "lit"; +import { css, CSSResult, html, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import { classMap } from "lit/directives/class-map.js"; import { guard } from "lit/directives/guard.js"; @@ -89,6 +89,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { //#endregion + //#region Lifecycle + public reset = () => { super.reset(); @@ -100,7 +102,13 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { this.host.valid = !!this.selectedType; }; - #selectDispatch = (type: TypeCreate) => { + protected override updated(changedProperties: PropertyValues): void { + if (changedProperties.has("selectedType")) { + this.#selectDispatch(this.selectedType); + } + } + + #selectDispatch = (type: TypeCreate | null) => { this.dispatchEvent( new CustomEvent("ak-type-create-select", { detail: type, @@ -110,6 +118,8 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { ); }; + //#endregion + //#region Rendering //#region Grid layout @@ -217,9 +227,9 @@ export class TypeCreateWizardPage extends WithLicenseSummary(WizardPage) { aria-describedby=${`${inputID}-description`} @change=${() => { this.selectedType = type; - this.#selectDispatch(type); }} ?disabled=${disabled} + .checked=${selected} />
> extends AKElement { type="button" ?disabled=${disabled} @click=${() => { + if (idx === activeStepIndex) return; this.activeStepElement = stepEl; }} > diff --git a/web/src/styles/authentik/base/common.css b/web/src/styles/authentik/base/common.css index 6cd0f381f4..30afcb38aa 100644 --- a/web/src/styles/authentik/base/common.css +++ b/web/src/styles/authentik/base/common.css @@ -19,6 +19,12 @@ } } +@media (not (prefers-contrast: more)) { + input { + outline: none; + } +} + /* #endregion */ /* #region Modifiers */ diff --git a/web/src/user/LibraryPage/ak-library-impl.ts b/web/src/user/LibraryPage/ak-library-impl.ts index ce41d3b940..c326c986d1 100644 --- a/web/src/user/LibraryPage/ak-library-impl.ts +++ b/web/src/user/LibraryPage/ak-library-impl.ts @@ -174,7 +174,6 @@ export class LibraryPage extends WithSession(AKElement) { includeScore: true, shouldSort: true, ignoreFieldNorm: true, - useExtendedSearch: true, threshold: 0.3, }); diff --git a/web/test/browser/session.test.ts b/web/test/browser/100-session.test.ts similarity index 95% rename from web/test/browser/session.test.ts rename to web/test/browser/100-session.test.ts index 1a939f6754..6986219e4a 100644 --- a/web/test/browser/session.test.ts +++ b/web/test/browser/100-session.test.ts @@ -18,7 +18,7 @@ test.describe("Session management", () => { page.getByRole("heading", { level: 1, }), - ).toHaveText("My applications", { + ).toHaveText("Application Dashboard", { timeout: 10_000, }); }); diff --git a/web/test/browser/session-lifecycle.test.ts b/web/test/browser/101-session-lifecycle.test.ts similarity index 86% rename from web/test/browser/session-lifecycle.test.ts rename to web/test/browser/101-session-lifecycle.test.ts index 0aff0f0b24..dad19ddf34 100644 --- a/web/test/browser/session-lifecycle.test.ts +++ b/web/test/browser/101-session-lifecycle.test.ts @@ -17,8 +17,6 @@ test.describe("Session Lifecycle", () => { test.beforeAll( 'Ensure "Enable Remember me on this device" is on for the default identification stage', async ({ browser }, { title: testName }) => { - if (Date.now()) return; - const context = await browser.newContext(); const page = await context.newPage(); const navigator = new NavigatorFixture(page, testName); @@ -99,6 +97,23 @@ test.describe("Session Lifecycle", () => { await signOutLink.click(); await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F"); + await session.$identificationStage.waitFor({ state: "visible" }); + + const passwordEmbedded = await session.$passwordField.isVisible(); + + if (passwordEmbedded) { + // Password is embedded in the identification stage, so the Not-you UI never renders. + // Remember-me's only observable effect is the pre-filled username field. + await expect( + session.$usernameField, + "Username pre-filled from remember-me", + ).toHaveValue(GOOD_USERNAME); + + return; + } + + await session.$submitButton.click(); + await session.$passwordStage.waitFor({ state: "visible" }); const notYouLink = page.getByRole("link", { name: "Not you?" }); diff --git a/web/test/browser/modals.test.ts b/web/test/browser/200-modals.test.ts similarity index 100% rename from web/test/browser/modals.test.ts rename to web/test/browser/200-modals.test.ts diff --git a/web/test/browser/users.test.ts b/web/test/browser/300-users.test.ts similarity index 98% rename from web/test/browser/users.test.ts rename to web/test/browser/300-users.test.ts index acecc9fc1c..5469f79e2b 100644 --- a/web/test/browser/users.test.ts +++ b/web/test/browser/300-users.test.ts @@ -85,6 +85,7 @@ test.describe("Users", () => { const { click } = pointer; const dialog = page.getByRole("dialog", { name: "New User Wizard" }); + const nextButton = dialog.getByTestId("wizard-navigation-next"); await expect(dialog, "Dialog is initially closed").toBeHidden(); @@ -101,11 +102,11 @@ test.describe("Users", () => { [fill, /^Username/, username, dialog], ); - await dialog.getByRole("button", { name: "Next" }).click(); + await nextButton.click(); await expect(dialog, "Credentials page is visible").toBeVisible(); - await dialog.getByRole("button", { name: "Finish" }).click(); + await nextButton.click(); await expect(dialog, "Dialog closes after creating service account").toBeHidden(); diff --git a/web/test/browser/groups.test.ts b/web/test/browser/400-groups.test.ts similarity index 100% rename from web/test/browser/groups.test.ts rename to web/test/browser/400-groups.test.ts diff --git a/web/test/browser/roles.test.ts b/web/test/browser/500-roles.test.ts similarity index 100% rename from web/test/browser/roles.test.ts rename to web/test/browser/500-roles.test.ts diff --git a/web/test/browser/providers.test.ts b/web/test/browser/600-providers.test.ts similarity index 100% rename from web/test/browser/providers.test.ts rename to web/test/browser/600-providers.test.ts diff --git a/web/test/browser/applications.test.ts b/web/test/browser/700-applications.test.ts similarity index 100% rename from web/test/browser/applications.test.ts rename to web/test/browser/700-applications.test.ts diff --git a/web/test/browser/800-rac.test.ts b/web/test/browser/800-rac.test.ts new file mode 100644 index 0000000000..e288a93c48 --- /dev/null +++ b/web/test/browser/800-rac.test.ts @@ -0,0 +1,203 @@ +import { expect, test } from "#e2e"; +import { randomName } from "#e2e/utils/generators"; + +import { IDGenerator } from "@goauthentik/core/id"; +import { series } from "@goauthentik/core/promises"; + +interface Names { + providerName: string; + appName: string; + endpointA: string; + endpointB: string; +} + +test.describe("RAC", () => { + const fixtures = new Map(); + + test.beforeEach("Seed entity names", async ({}, { testId }) => { + const seed = IDGenerator.randomID(6); + const base = randomName(seed); + + fixtures.set(testId, { + providerName: `${base} RAC (${seed})`, + appName: `${base} RAC App (${seed})`, + endpointA: `rac-vnc-a-${seed}`, + endpointB: `rac-vnc-b-${seed}`, + }); + }); + + test("Configure provider, add endpoints, attach to application, and launch from user library", async ({ + session, + navigator, + form, + pointer, + page, + }, testInfo) => { + const { providerName, appName, endpointA, endpointB } = fixtures.get(testInfo.testId)!; + const { fill, search, selectSearchValue, setRadio } = form; + const { click } = pointer; + + await test.step("Authenticate", async () => { + await session.login({ to: "/if/admin/#/core/providers" }); + }); + + //#region Create RAC provider via the wizard + + const providerDialog = page.getByRole("dialog", { name: "New Provider Wizard" }); + + await test.step("Create RAC provider", async () => { + await expect(providerDialog, "Provider wizard is initially closed").toBeHidden(); + + await click("New Provider", "button"); + + await expect(providerDialog, "Provider wizard opens").toBeVisible(); + + await series( + [click, "RAC Provider", "option"], + [fill, "Provider Name", providerName], + [ + selectSearchValue, + "Authorization Flow", + /default-provider-authorization-explicit-consent/, + ], + [click, "Create", "button", providerDialog], + ); + + await expect(providerDialog, "Provider wizard closes after creation").toBeHidden(); + }); + + const $providerRow = await test.step("Find provider in table", () => search(providerName)); + + await expect($providerRow, "Provider row is visible").toBeVisible(); + + //#endregion + + //#region Add two endpoints from the provider detail page + + await test.step("Open provider detail page", async () => { + await $providerRow.getByRole("link", { name: providerName }).click(); + + await expect( + page.getByRole("heading", { name: providerName }), + "Provider detail page renders", + ).toBeVisible(); + }); + + const endpointDialog = page.getByRole("dialog", { name: /New RAC Endpoint/i }); + const endpointList = page.locator("ak-rac-endpoint-list"); + + for (const endpointName of [endpointA, endpointB]) { + await test.step(`Create endpoint ${endpointName}`, async () => { + await expect(endpointDialog, "Endpoint dialog is initially closed").toBeHidden(); + + await endpointList + .getByRole("button", { name: "New RAC Endpoint" }) + .first() + .click(); + + await expect(endpointDialog, "Endpoint dialog opens").toBeVisible(); + + await series( + [fill, "Endpoint Name", endpointName, endpointDialog], + [setRadio, "Protocol", "VNC", endpointDialog], + [fill, "Host", "localhost:5900", endpointDialog], + [click, "Create RAC Endpoint", "button", endpointDialog], + ); + + await expect(endpointDialog, "Endpoint dialog closes after creation").toBeHidden(); + + await expect( + endpointList.getByRole("cell", { name: endpointName }), + `Endpoint ${endpointName} appears in the sub-table`, + ).toBeVisible(); + }); + } + + //#endregion + + //#region Attach the provider to an application + + await test.step("Navigate to applications", async () => { + await navigator.navigate("/if/admin/#/core/applications"); + }); + + const appDialog = page.getByRole("dialog", { name: "New Application" }); + + await test.step("Create application with the RAC provider", async () => { + await expect(appDialog, "Application dialog is initially closed").toBeHidden(); + + await click("New Application options", "button"); + await click("With Existing Provider...", "menuitem"); + + await expect(appDialog, "Application dialog opens").toBeVisible(); + + await series( + [fill, /^Application Name/, appName, appDialog], + [selectSearchValue, "Provider", providerName, appDialog], + ); + + await appDialog.getByRole("button", { name: "Create Application" }).click(); + + await expect(appDialog, "Application dialog closes after creation").toBeHidden(); + }); + + await test.step("Verify application in admin table", async () => { + const $appRow = await search(appName); + + await expect($appRow, "Application is visible in the admin table").toBeVisible(); + }); + + //#endregion + + //#region User library: launch the application + + await test.step("Navigate to the user library", async () => { + await navigator.navigate("/if/user/"); + }); + + const librarySearch = page.getByPlaceholder("Search for an application by name..."); + + await expect(librarySearch, "Library search input is ready").toBeVisible({ + timeout: 15_000, + }); + + await test.step("Filter library to the new application", async () => { + await librarySearch.fill(appName); + }); + + // Newly created apps can take several seconds to surface in the + // user-facing application list, so allow extra time for the card to + // appear once the search filter is applied. + const launchButton = page.getByRole("button", { name: `Open "${appName}"` }); + + await expect( + launchButton, + "Application launch button appears in the filtered library", + ).toBeVisible({ timeout: 15_000 }); + + await test.step("Open the endpoint launcher", async () => { + await launchButton.click(); + }); + + const launchDialog = page.getByRole("dialog", { name: /Launch Endpoint/i }); + + await expect(launchDialog, "Launch Endpoint dialog opens").toBeVisible(); + + // Both endpoints must render on first open — no manual refresh, no + // re-navigation. Two endpoints are required because a single endpoint + // auto-launches and closes the modal. + await test.step("Endpoint list populates on first open", async () => { + await expect( + launchDialog.getByRole("cell", { name: endpointA }), + `Endpoint ${endpointA} is visible in the launcher`, + ).toBeVisible({ timeout: 5_000 }); + + await expect( + launchDialog.getByRole("cell", { name: endpointB }), + `Endpoint ${endpointB} is visible in the launcher`, + ).toBeVisible(); + }); + + //#endregion + }); +});