web/table: fetch on first render when already visible (#22376)

* web/table: fetch on first render when already visible

Tables inside `<ak-modal>` rendered empty until the user clicked the
refresh button. The 2026.5 RC native-`<dialog>` 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>
This commit is contained in:
Teffen Ellis
2026-05-18 13:10:17 +02:00
committed by GitHub
parent 3836bdb52f
commit 1c82199852
27 changed files with 384 additions and 142 deletions
+1
View File
@@ -32,6 +32,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
playwright-report
playwright-traces
test-results
*.lcov
+8 -24
View File
@@ -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();
-6
View File
@@ -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;
+8
View File
@@ -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;
+11 -3
View File
@@ -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: {
+21 -33
View File
@@ -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<Endpoint, string> {
?autofocus=${!this.instance}
>
</ak-text-input>
<ak-form-element-horizontal required name="protocol">
${AKLabel(
{
slot: "label",
className: "pf-c-form__group-label",
htmlFor: "protocol",
required: true,
},
msg("Protocol"),
)}
<ak-radio
id="protocol"
required
.options=${[
{
label: msg("RDP"),
value: ProtocolEnum.Rdp,
},
{
label: msg("SSH"),
value: ProtocolEnum.Ssh,
},
{
label: msg("VNC"),
value: ProtocolEnum.Vnc,
},
]}
.value=${this.instance?.protocol}
>
</ak-radio>
</ak-form-element-horizontal>
<ak-radio-input
label=${msg("Protocol")}
name="protocol"
required
.options=${[
{
label: msg("RDP"),
value: ProtocolEnum.Rdp,
},
{
label: msg("SSH"),
value: ProtocolEnum.Ssh,
},
{
label: msg("VNC"),
value: ProtocolEnum.Vnc,
},
]}
.value=${this.instance?.protocol}
>
</ak-radio-input>
<ak-text-input
label=${msg("Host")}
name="host"
+24 -15
View File
@@ -1,6 +1,7 @@
import "#admin/common/ak-flow-search/ak-flow-search";
import "#admin/common/ak-crypto-certificate-search";
import "#admin/common/ak-flow-search/ak-branded-flow-search";
import "#components/ak-text-input";
import "#components/ak-switch-input";
import "#elements/CodeMirror";
import "#elements/ak-dual-select/ak-dual-select-dynamic-selected-provider";
@@ -16,6 +17,8 @@ import { DEFAULT_CONFIG } from "#common/api/config";
import { ModelForm } from "#elements/forms/ModelForm";
import { AKLabel } from "#components/ak-label";
import { FlowDesignationEnum, ProvidersApi, RACProvider } from "@goauthentik/api";
import YAML from "yaml";
@@ -54,23 +57,29 @@ export class RACProviderFormPage extends ModelForm<RACProvider, number> {
protected override renderForm(): TemplateResult {
return html`
<ak-form-element-horizontal label=${msg("Provider Name")} required name="name">
<input
type="text"
value="${ifDefined(this.instance?.name)}"
class="pf-c-form-control"
required
placeholder=${msg("Type a provider name...")}
spellcheck="false"
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization Flow")}
<ak-text-input
label=${msg("Provider Name")}
required
>
name="name"
value="${ifDefined(this.instance?.name)}"
placeholder=${msg("Type a provider name...")}
spellcheck="false"
?autofocus=${!this.instance}
></ak-text-input>
<ak-form-element-horizontal name="authorizationFlow" required>
${AKLabel(
{
className: "pf-c-form__group-label",
slot: "label",
htmlFor: "authorizationFlow",
required: true,
},
msg("Authorization Flow"),
)}
<ak-flow-search
id="authorizationFlow"
label=${msg("Authorization Flow")}
flowType=${FlowDesignationEnum.Authorization}
.currentFlow=${this.instance?.authorizationFlow}
required
+6 -2
View File
@@ -18,7 +18,7 @@ import { RoleForm } from "#admin/roles/ak-role-form";
import { RbacApi, Role } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { html, PropertyValues, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
@@ -83,7 +83,11 @@ export class RoleListPage extends TablePage<Role> {
row(item: Role): SlottedTemplateResult[] {
return [
html`<a href="#/identity/roles/${item.pk}">${item.name}</a>`,
html`<a
href="#/identity/roles/${item.pk}"
aria-label=${msg(str`View details of role "${item.name}"`)}
>${item.name}</a
>`,
html`<div class="ak-c-table__actions">${IconEditButton(RoleForm, item.pk)}</div>`,
];
}
+2 -7
View File
@@ -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<TypeCreate[]> => {
@@ -75,12 +75,7 @@ export class AKStageWizard extends CreateWizard {
return null;
}
return html`<ak-form-group
slot="pre-items"
label=${msg("Existing Stage")}
description=${msg("Bind an existing stage to this flow.")}
open
>
return html`<ak-form-group slot="pre-items" label=${msg("Existing Stage")} open>
<ak-radio
.options=${[
{
@@ -45,7 +45,7 @@ export class InvitationWizardEmailStep extends WizardPage {
@state()
availableTemplates: TypeCreate[] = [];
override formatNextLabel(): SlottedTemplateResult {
public override formatNextLabel(): SlottedTemplateResult {
return html`${msg("Send")}
<span class="pf-c-button__icon pf-m-end">
<i class="fas fa-paper-plane" aria-hidden="true"></i>
+1 -1
View File
@@ -79,7 +79,7 @@ export class ServiceAccountResultPage extends WizardPage<UserWizardState> {
this.host.cancelable = false;
};
public formatNextLabel(): SlottedTemplateResult | null {
public override formatNextLabel(): SlottedTemplateResult | null {
return ButtonKindLabelRecord.close();
}
@@ -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];
+6 -5
View File
@@ -357,6 +357,12 @@ export abstract class Table<T extends object, D = T>
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<T extends object, D = T>
}
}
protected override firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.#synchronizeRefreshSchedule();
}
//#endregion
protected async defaultEndpointConfig(): Promise<BaseTableListRequest> {
+50 -35
View File
@@ -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<this>): void {
protected override firstUpdated(changedProperties: PropertyValues<this>): 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<TypeCreate>): boolean | Promise<boolean> => {
this.selectedType = typeCreate;
detail: selectedType,
}: CustomEvent<TypeCreate | null>): boolean | Promise<boolean> => {
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`<ak-wizard-page-type-create
${ref(this.pageTypeCreateRef)}
slot="initial"
.types=${this.creationTypes}
layout=${this.layout}
group-label=${ifPresent(this.groupLabel)}
group-description=${ifPresent(this.groupDescription)}
headline=${this.verboseName
? msg(str`Choose ${this.verboseName} Type`)
: msg("Choose type")}
@ak-type-create-select=${this.typeSelectListener}
>
${this.renderCreateBefore()}
${guard([initialPageContent], () => {
if (!initialPageContent) {
return null;
}
>${this.renderHeading()}
<ak-wizard-page-type-create
${ref(this.pageTypeCreateRef)}
slot="initial"
.types=${this.creationTypes}
layout=${this.layout}
group-label=${ifPresent(this.groupLabel)}
group-description=${ifPresent(this.groupDescription)}
headline=${this.verboseName
? msg(str`Choose ${this.verboseName} Type`, {
id: "wizard.step.choose-type",
desc: "Label for the initial step in the creation wizard where the type of the entity being created is selected. The placeholder {entity} is replaced with the singular name of the entity, for example 'Choose User Type' or 'Choose Group Type'.",
})
: msg("Choose type", {
id: "wizard.step.choose-type.generic",
desc: "Generic label for the initial step in the creation wizard where the type of the entity being created is selected, used when no singular entity name is provided.",
})}
@ak-type-create-select=${this.typeSelectListener}
>
${this.renderCreateBefore()}
${guard([initialPageContent], () => {
if (!initialPageContent) {
return null;
}
return html`<div>
<p>${initialPageContent}</p>
</div>`;
})}
</ak-wizard-page-type-create>`,
)}
return html`<div>
<p>${initialPageContent}</p>
</div>`;
})}
</ak-wizard-page-type-create>
${this.renderForms()}
</ak-wizard>`;
}
@@ -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);
@@ -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<this>): 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}
/>
<div
aria-selected="${selected ? "true" : "false"}"
+1
View File
@@ -519,6 +519,7 @@ export class AKWizard<S = Record<string, unknown>> extends AKElement {
type="button"
?disabled=${disabled}
@click=${() => {
if (idx === activeStepIndex) return;
this.activeStepElement = stepEl;
}}
>
+6
View File
@@ -19,6 +19,12 @@
}
}
@media (not (prefers-contrast: more)) {
input {
outline: none;
}
}
/* #endregion */
/* #region Modifiers */
@@ -174,7 +174,6 @@ export class LibraryPage extends WithSession(AKElement) {
includeScore: true,
shouldSort: true,
ignoreFieldNorm: true,
useExtendedSearch: true,
threshold: 0.3,
});
@@ -18,7 +18,7 @@ test.describe("Session management", () => {
page.getByRole("heading", {
level: 1,
}),
).toHaveText("My applications", {
).toHaveText("Application Dashboard", {
timeout: 10_000,
});
});
@@ -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?" });
@@ -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();
+203
View File
@@ -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<string, Names>();
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
});
});