diff --git a/frontend/README.md b/frontend/README.md index 1d4ac7f129..382dd3bae7 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -283,6 +283,13 @@ E2E_LIVE_MODE=true npx playwright test The mock and seeded projects run in the **GitHub Actions** pull-request workflow. The live project is intended for a protected pipeline with an Entra identity or API keys. +For ordinary interactions with startup controls, call `waitForAppReady(page)` +from `e2e/_runtime.ts` after navigation or reload. A visible control can still +move as runtime readiness and configured defaults arrive. Tests that deliberately +interact before startup finishes should control those responses instead. +Before reloading to check a preference change, wait for its value in localStorage; +an empty Operation picker can mean its saved-ID lookup is still loading. + CI runs two isolated mock shards, one serial seeded job, and one isolated analytics job, with one worker per job. Mock and seeded jobs start their own Vite server and Python backend because mock specs still depend on backend auth diff --git a/frontend/e2e/_runtime.ts b/frontend/e2e/_runtime.ts index 6a7219e1f5..ad11201892 100644 --- a/frontend/e2e/_runtime.ts +++ b/frontend/e2e/_runtime.ts @@ -1,5 +1,15 @@ +import { expect, type Page } from '@playwright/test' + import type { RuntimeReadiness, RuntimeStatus } from '../src/types' +export async function waitForAppReady(page: Page): Promise { + await expect(page.getByRole('region', { name: 'Default Labels', exact: true })).toBeVisible() + await expect(page.getByText(/^PyRIT runtime:/)).toHaveCount(0) + await expect(page.getByText( + 'New attacks and scans are unavailable until default labels are loaded.', { exact: false }, + )).toHaveCount(0) +} + export const READY_RUNTIME: RuntimeReadiness = { ready: true, state: 'ready', diff --git a/frontend/e2e/operations-acceptance.spec.ts b/frontend/e2e/operations-acceptance.spec.ts index 25ca2dfcd0..258998d19b 100644 --- a/frontend/e2e/operations-acceptance.spec.ts +++ b/frontend/e2e/operations-acceptance.spec.ts @@ -2,6 +2,7 @@ import { test, expect, type APIRequestContext, type Page } from '@playwright/tes import type { AttackSummary, Finding, Operation, ScenarioRunListItem, ScenarioRunSummary } from '../src/types' import { compatibilityHeaders } from './_compatibility' +import { waitForAppReady } from './_runtime' const LEGACY_ATTACK_ID = '11111111-1111-4111-8111-111111111111' const PROPOSED_NAME = 'browser_proposed' @@ -88,13 +89,23 @@ for (const width of [1280, 390]) { )).toEqual({ id: operation.id }) await bar.getByRole('button', { name: 'Clear operation', exact: true }).click() + await expect.poll(() => page.evaluate(() => + JSON.parse(localStorage.getItem('pyrit.userPreferences.v1.local') ?? '{}').operation, + )).toBeNull() await page.reload() + await waitForAppReady(page) await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toHaveValue('') await page.evaluate(() => localStorage.removeItem('pyrit.userPreferences.v1.local')) await page.reload() + await waitForAppReady(page) + await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toHaveValue(PROPOSED_NAME) + await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toHaveAccessibleDescription('Not registered') await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() await page.getByRole('option', { name: PROPOSED_NAME, exact: true }).click() await expect(bar.getByText('Not registered', { exact: true })).toHaveCount(0) + await expect.poll(() => page.evaluate(() => + JSON.parse(localStorage.getItem('pyrit.userPreferences.v1.local') ?? '{}').operation, + )).toEqual({ id: operation.id }) const newName = `Restored ${width} ${Date.now()}` await page.evaluate((name: string) => { @@ -122,9 +133,13 @@ test('persists selected IDs for chat and scanner history, and clear survives rel test.setTimeout(90_000) const operation = await createOperation(request, `Activity ${Date.now()}`) await page.goto('/') + await waitForAppReady(page) const bar = page.getByRole('region', { name: 'Default Labels' }) await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() await page.getByRole('option', { name: operation.name, exact: true }).click() + await expect.poll(() => page.evaluate(() => + JSON.parse(localStorage.getItem('pyrit.userPreferences.v1.local') ?? '{}').operation, + )).toEqual({ id: operation.id }) const chat = await sendChat(page, request, 'Selected Operation chat.') expect(chat.operation_id).toBe(operation.id) @@ -159,7 +174,11 @@ test('persists selected IDs for chat and scanner history, and clear survives rel await expect(page.getByTestId(`scenario-history-row-${run.scenario_result_id}`)).toBeVisible() await bar.getByRole('button', { name: 'Clear operation', exact: true }).click() + await expect.poll(() => page.evaluate(() => + JSON.parse(localStorage.getItem('pyrit.userPreferences.v1.local') ?? '{}').operation, + )).toBeNull() await page.reload() + await waitForAppReady(page) await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toHaveValue('') const unassigned = await sendChat(page, request, 'Unassigned after reload.') expect(unassigned.operation_id).toBeNull() diff --git a/frontend/e2e/operations.spec.ts b/frontend/e2e/operations.spec.ts index a4656137d1..d12a1ecf16 100644 --- a/frontend/e2e/operations.spec.ts +++ b/frontend/e2e/operations.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test' import type { Finding, Operation } from '../src/types' +import { waitForAppReady } from './_runtime' for (const width of [1280, 390]) { test(`wraps long finding text in detail and evidence picker at ${width}px @seeded`, async ({ page, request }) => { @@ -178,6 +179,7 @@ test('selects saved operations inline and revisits ID-scoped attack history @see test.setTimeout(90_000) const name = `Engagement / α% & ${Date.now()}` await page.goto('/') + await waitForAppReady(page) const bar = page.getByRole('region', { name: 'Default Labels' }) await bar.getByRole('combobox', { name: 'Operation', exact: true }).click() await page.getByRole('option', { name: 'New operation…', exact: true }).click() @@ -191,6 +193,9 @@ test('selects saved operations inline and revisits ID-scoped attack history @see await expect(bar.getByRole('combobox', { name: 'Operation' })).toHaveValue(name) await expect(bar.getByRole('combobox', { name: 'Operation', exact: true })).toBeFocused() await expect(page).toHaveURL(/\/$/) + await expect.poll(() => page.evaluate(() => + JSON.parse(localStorage.getItem('pyrit.userPreferences.v1.local') ?? '{}').operation, + )).toEqual({ id: operation.id }) await page.reload() await expect(bar.getByRole('combobox', { name: 'Operation' })).toHaveValue(name)