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 + }); +});