From 3eabc3d555e1aba7c6ae1344009e0a884a89f39b Mon Sep 17 00:00:00 2001 From: Ghost Jake <89829542+Deepak-Kesavan@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:04:18 +0530 Subject: [PATCH 01/17] UN-4345 [FEAT] Frontend v1 E2E harness and the frontend-v1-e2e rig group Adds tests/e2e/ui-v1: a self-contained Playwright suite (own config, lock, Biome, strict TS) with login/org, seed, v1Preview and distribution tag fixtures, and five-state, accessibility, theme-matrix, redirect and old-version helpers. P0-T03 dependencies go through contract/registry.ts (stubbed; built ahead of P0-T03). Adds the frontend-v1-e2e rig group and the CI install steps for it. tests/e2e/ui and the ui group are untouched. --- .github/workflows/ci-test.yaml | 37 +++ tests/e2e/ui-v1/.gitignore | 4 + tests/e2e/ui-v1/README.md | 105 ++++++++ tests/e2e/ui-v1/biome.json | 28 ++ tests/e2e/ui-v1/bun.lock | 92 +++++++ tests/e2e/ui-v1/contract/registry.ts | 90 +++++++ tests/e2e/ui-v1/contract/v1-ui.ts | 29 +++ tests/e2e/ui-v1/fixtures/auth.ts | 243 ++++++++++++++++++ tests/e2e/ui-v1/fixtures/env.ts | 29 +++ tests/e2e/ui-v1/fixtures/index.ts | 14 + tests/e2e/ui-v1/fixtures/preview.ts | 67 +++++ tests/e2e/ui-v1/fixtures/seed.ts | 103 ++++++++ tests/e2e/ui-v1/fixtures/tags.ts | 47 ++++ tests/e2e/ui-v1/helpers/a11y.ts | 97 +++++++ tests/e2e/ui-v1/helpers/five-states.ts | 185 +++++++++++++ tests/e2e/ui-v1/helpers/index.ts | 20 ++ tests/e2e/ui-v1/helpers/old-version.ts | 96 +++++++ tests/e2e/ui-v1/helpers/redirect.ts | 42 +++ tests/e2e/ui-v1/helpers/routes.ts | 38 +++ tests/e2e/ui-v1/helpers/themes.ts | 68 +++++ tests/e2e/ui-v1/journeys/README.md | 4 + tests/e2e/ui-v1/package.json | 18 ++ tests/e2e/ui-v1/playwright.config.ts | 56 ++++ tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts | 78 ++++++ tests/e2e/ui-v1/specs/harness/a11y.spec.ts | 48 ++++ .../ui-v1/specs/harness/five-states.spec.ts | 65 +++++ tests/e2e/ui-v1/specs/harness/preview.spec.ts | 38 +++ .../specs/harness/registry-routes.spec.ts | 89 +++++++ tests/e2e/ui-v1/specs/harness/themes.spec.ts | 45 ++++ tests/e2e/ui-v1/support/demo-page.ts | 109 ++++++++ tests/e2e/ui-v1/tsconfig.json | 18 ++ tests/groups.yaml | 18 ++ 32 files changed, 2020 insertions(+) create mode 100644 tests/e2e/ui-v1/.gitignore create mode 100644 tests/e2e/ui-v1/README.md create mode 100644 tests/e2e/ui-v1/biome.json create mode 100644 tests/e2e/ui-v1/bun.lock create mode 100644 tests/e2e/ui-v1/contract/registry.ts create mode 100644 tests/e2e/ui-v1/contract/v1-ui.ts create mode 100644 tests/e2e/ui-v1/fixtures/auth.ts create mode 100644 tests/e2e/ui-v1/fixtures/env.ts create mode 100644 tests/e2e/ui-v1/fixtures/index.ts create mode 100644 tests/e2e/ui-v1/fixtures/preview.ts create mode 100644 tests/e2e/ui-v1/fixtures/seed.ts create mode 100644 tests/e2e/ui-v1/fixtures/tags.ts create mode 100644 tests/e2e/ui-v1/helpers/a11y.ts create mode 100644 tests/e2e/ui-v1/helpers/five-states.ts create mode 100644 tests/e2e/ui-v1/helpers/index.ts create mode 100644 tests/e2e/ui-v1/helpers/old-version.ts create mode 100644 tests/e2e/ui-v1/helpers/redirect.ts create mode 100644 tests/e2e/ui-v1/helpers/routes.ts create mode 100644 tests/e2e/ui-v1/helpers/themes.ts create mode 100644 tests/e2e/ui-v1/journeys/README.md create mode 100644 tests/e2e/ui-v1/package.json create mode 100644 tests/e2e/ui-v1/playwright.config.ts create mode 100644 tests/e2e/ui-v1/specs/P0-T09.demo.spec.ts create mode 100644 tests/e2e/ui-v1/specs/harness/a11y.spec.ts create mode 100644 tests/e2e/ui-v1/specs/harness/five-states.spec.ts create mode 100644 tests/e2e/ui-v1/specs/harness/preview.spec.ts create mode 100644 tests/e2e/ui-v1/specs/harness/registry-routes.spec.ts create mode 100644 tests/e2e/ui-v1/specs/harness/themes.spec.ts create mode 100644 tests/e2e/ui-v1/support/demo-page.ts create mode 100644 tests/e2e/ui-v1/tsconfig.json diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index 66239e7b32..3f87f2745e 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -239,6 +239,43 @@ jobs: worker-unified.cache-from=type=gha,scope=worker-unified worker-unified.cache-to=type=gha,mode=max,scope=worker-unified + # Frontend v1 E2E (`frontend-v1-e2e`) runs with `npx --no-install`, so its + # own node_modules and a browser must already be here. Scoped to + # tests/e2e/ui-v1 only; the v0 `ui` group's setup is deliberately left + # as it is. The leg runs the whole e2e tier, so the group is always + # selected; the caches keep that cheap. + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: Cache frontend-v1 E2E dependencies + uses: actions/cache@v5 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-ui-v1-${{ hashFiles('tests/e2e/ui-v1/bun.lock') }} + + - name: Install frontend-v1 E2E dependencies + working-directory: tests/e2e/ui-v1 + # No dependency lifecycle scripts: browsers are installed below. + run: bun install --frozen-lockfile --ignore-scripts + + - name: Cache Playwright browsers + id: ui-v1-browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + # The lock pins Playwright, which pins the browser build. + key: ${{ runner.os }}-playwright-ui-v1-${{ hashFiles('tests/e2e/ui-v1/bun.lock') }} + + - name: Install Playwright browser + working-directory: tests/e2e/ui-v1 + # The OS libraries are not cacheable; on a cache hit install only them. + run: | + if [ "${{ steps.ui-v1-browsers.outputs.cache-hit }}" = "true" ]; then + npx --no-install playwright install-deps chromium + else + npx --no-install playwright install --with-deps --only-shell chromium + fi + - name: Run e2e tier (rig boots the platform via compose) env: UNSTRACT_E2E_RUNTIME: compose diff --git a/tests/e2e/ui-v1/.gitignore b/tests/e2e/ui-v1/.gitignore new file mode 100644 index 0000000000..42f2da1191 --- /dev/null +++ b/tests/e2e/ui-v1/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +reports/ +test-results/ +playwright-report/ diff --git a/tests/e2e/ui-v1/README.md b/tests/e2e/ui-v1/README.md new file mode 100644 index 0000000000..39e60089a9 --- /dev/null +++ b/tests/e2e/ui-v1/README.md @@ -0,0 +1,105 @@ +# Frontend v1 E2E (`frontend-v1-e2e`) + +Playwright suite for the v1 frontend (epic UN-4257, task P0-T09). It is kept +apart from the v0 suite in `tests/e2e/ui/`: own config, fixtures, lock and +rig group, so v1 results never mix with v0's (D10, constraint 12). + +## Run + +```bash +# Through the rig (boots the platform, then runs e2e-smoke first) +tox -e groups -- frontend-v1-e2e + +# Standalone against a running stack +cd tests/e2e/ui-v1 +bun install --frozen-lockfile +bunx playwright install chromium +UNSTRACT_FRONTEND_URL=http://localhost:3000 UNSTRACT_BACKEND_URL=http://localhost:8000 bunx playwright test + +bun run lint && bun run typecheck +``` + +| Env | Meaning | +| --- | --- | +| `UNSTRACT_FRONTEND_URL`, `UNSTRACT_BACKEND_URL` | Stack under test (the rig exports both) | +| `UNSTRACT_AUTH_MODE` | `oss` or `auth0`; detected automatically otherwise | +| `UNSTRACT_ADMIN_USER` / `_PASSWORD` | The `admin` role (defaults to the seeded `unstract` user) | +| `UNSTRACT_E2E__USER` / `_PASSWORD` | Any other role; a test asking for a role without them skips | +| `UNSTRACT_E2E_ORG_ID` | The organisation to select; the first one by default | +| `UNSTRACT_DISTRIBUTION` | `oss` (default), `cloud` or `onprem`: which tagged tests run | +| `UNSTRACT_E2E_A11Y_IMPACT` | Lowest axe impact that fails a scan; default `serious` | + +## Writing a page spec + +One spec per page task, named by task id: `specs/P4-T01.spec.ts`. The P8-T01 +shared journeys go in `journeys/`. Every test carries a distribution tag; +untagged tests never run. + +```ts +import { DIST, expect, test } from "../fixtures/index.ts"; +import { expectAccessible, expectFiveStates, forEachTheme } from "../helpers/index.ts"; + +test.describe("P4-T01 API Deployments", { tag: DIST.both }, () => { + // Non-final task of a multi-task page: render v1 before registration (D9). + test.use({ v1Preview: ["UN06"] }); + + test("five states", async ({ page, app }) => { + await expectFiveStates(page, { + open: () => app.goto("api"), + api: "**/api/v1/unstract/*/api/deployment/**", + rest: (p) => p.getByRole("table"), + emptyBody: [], + }); + }); + + test("accessible in every theme", async ({ page, app, seed }) => { + await seed.create("workflow/", { workflow_name: seed.name("wf") }); + await forEachTheme(page, async (theme) => { + await app.goto("api"); + await expectAccessible(page, { label: theme }); + }); + }); +}); +``` + +| Fixture or helper | Does | +| --- | --- | +| `session`, `app`, `role` | Signs in as a seeded user of a role, selects the org; `app.goto(route)` opens org-scoped routes | +| `seed` | Creates rows through the API and deletes them after the test | +| `v1Preview` | Renders an unregistered page's v1 routes (non-final page tasks) | +| `DIST` / `TAG` | Marks a test as OSS, cloud, on-prem or several | +| `expectFiveStates` | Drives rest, loading, empty, error and no-permission by intercepting the screen's API | +| `expectAccessible` | axe scan, WCAG 2.0/2.1 A+AA, fails at or above the impact level | +| `forEachTheme` | Repeats a check in Light, Dark and Unstract | +| `expectV0RedirectsToV1` | A v0 URL lands on the mapped v1 route | +| `expectOldVersionRoundTrip` | Human Review (page 11) only: old-version preference on and off | + +## Built ahead of P0-T03 + +P0-T09 is blocked by P0-T03 (version switch and route registry), which had not +landed when this harness was written. Every P0-T03 dependency goes through one +adapter, `contract/registry.ts`, and every assumed DOM marker sits in +`contract/v1-ui.ts`. To switch over when the blocking tasks land: + +1. **Registry** (P0-T03): in `contract/registry.ts`, replace the stub block + with a re-export of the frontend registry and set `REGISTRY_SOURCE` to + `"frontend"`. The redirect and old-version self-tests then run on their + own. The registry module has to be importable outside Vite (plain data, + relative imports, no `import.meta.env`, no aliases or asset imports), or + emitted as JSON. +2. **Old-version preference key** (P0-T03): set `OLD_VERSION_PREFERENCE` to + the real storage key. +3. **v1 preview of unregistered pages** (P0-T03): set `V1_PREVIEW` to the + dev/test-only override, and the key and route in + `specs/harness/preview.spec.ts`. +4. **Demo route** (P0-T03): point part 2 of `specs/P0-T09.demo.spec.ts` at the + real development-only demo route, and delete `support/demo-page.ts`. +5. **v1 root marker** (P0-T01/P0-T03): set `V1_ROOT_SELECTOR` in + `contract/v1-ui.ts` to the v1 tree root. +6. **State components** (P1): set `STATE_TEST_IDS` to the core skeleton, empty, + error and no-permission components. +7. **Themes** (P0-T04): swap `defaultSetTheme` in `helpers/themes.ts` for the + real theme mechanism. +8. **a11y level** (P8-T02): confirm or change the default `serious`. +9. **Gate**: drop `optional: true` from `frontend-v1-e2e` in + `tests/groups.yaml` once CI shows the group green. diff --git a/tests/e2e/ui-v1/biome.json b/tests/e2e/ui-v1/biome.json new file mode 100644 index 0000000000..38fa6045ea --- /dev/null +++ b/tests/e2e/ui-v1/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", + "root": true, + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "**", + "!node_modules", + "!reports", + "!test-results", + "!playwright-report" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 80 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { "noExplicitAny": "error" }, + "style": { "useImportType": "error" } + } + } +} diff --git a/tests/e2e/ui-v1/bun.lock b/tests/e2e/ui-v1/bun.lock new file mode 100644 index 0000000000..1e9dccc89e --- /dev/null +++ b/tests/e2e/ui-v1/bun.lock @@ -0,0 +1,92 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "@unstract/ui-v1-e2e", + "devDependencies": { + "@axe-core/playwright": "4.13.0", + "@biomejs/biome": "2.3.13", + "@playwright/test": "1.62.1", + "@types/node": "22.20.3", + "typescript": "7.0.2", + }, + }, + }, + "packages": { + "@axe-core/playwright": ["@axe-core/playwright@4.13.0", "", { "dependencies": { "axe-core": "~4.13.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg=="], + + "@biomejs/biome": ["@biomejs/biome@2.3.13", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.13", "@biomejs/cli-darwin-x64": "2.3.13", "@biomejs/cli-linux-arm64": "2.3.13", "@biomejs/cli-linux-arm64-musl": "2.3.13", "@biomejs/cli-linux-x64": "2.3.13", "@biomejs/cli-linux-x64-musl": "2.3.13", "@biomejs/cli-win32-arm64": "2.3.13", "@biomejs/cli-win32-x64": "2.3.13" }, "bin": { "biome": "bin/biome" } }, "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.13", "", { "os": "linux", "cpu": "x64" }, "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.13", "", { "os": "win32", "cpu": "x64" }, "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ=="], + + "@playwright/test": ["@playwright/test@1.62.1", "", { "dependencies": { "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" } }, "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ=="], + + "@types/node": ["@types/node@22.20.3", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "axe-core": ["axe-core@4.13.0", "", {}, "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + + "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + } +} diff --git a/tests/e2e/ui-v1/contract/registry.ts b/tests/e2e/ui-v1/contract/registry.ts new file mode 100644 index 0000000000..96a308514d --- /dev/null +++ b/tests/e2e/ui-v1/contract/registry.ts @@ -0,0 +1,90 @@ +/** + * The ONE place the harness learns about the v1 route registry (P0-T03). + * + * The redirect and old-version helpers read routes only through this module, + * so wiring in the real registry is a change to this file alone. + * + * Built ahead of P0-T03: the types mirror its published contract table, and + * `REGISTRY` is empty until the registry exists. When it lands, replace the + * stub block below with a re-export of the frontend module, then set + * `REGISTRY_SOURCE` to "frontend". That import only works if the registry + * module is pure data with plain relative imports: Playwright transpiles TS + * itself and applies neither Vite (`import.meta.env`, asset imports) nor the + * frontend tsconfig path aliases. If it cannot be, have P0-T03 emit JSON and + * read that here instead. + */ + +export type Distribution = "oss" | "cloud" | "onprem"; + +/** UN01 to UN20 (Document Studio), LW01 to LW07 (LLMWhisperer). */ +export type PageKey = `UN${string}` | `LW${string}`; + +/** A React Router pattern, e.g. `/:orgName/workflows/:id`. */ +export type RoutePattern = string; + +export interface RegistryEntry { + page: PageKey; + /** v1 route patterns, including v1-only sub-routes (tabs, stages). */ + v1Routes: readonly RoutePattern[]; + /** v0 route patterns this page takes over. */ + replaces: readonly RoutePattern[]; + /** v1 pattern -> v0 pattern; read only by Human Review's old-version mode. */ + toV0: Readonly>; + /** v0 pattern -> v1 pattern it redirects to. */ + toV1: Readonly>; + /** Honours the old-version preference. True for UN11 only (D14). */ + toggle: boolean; + distributions: readonly Distribution[]; + /** The go-live task that registers the page, e.g. "P4-T01". */ + registeredBy: string; +} + +export type RegistrySource = "stub" | "frontend"; + +// ── stub: replace this block when P0-T03 lands ─────────────────────────────── +export const REGISTRY_SOURCE: RegistrySource = "stub"; +export const REGISTRY: readonly RegistryEntry[] = []; +/** + * Where the Human Review old-version preference lives. P0-T03 says per user, + * per browser, no backend, so browser storage; the key itself is a + * placeholder until P0-T03 names it. + */ +export const OLD_VERSION_PREFERENCE = { + storage: "localStorage", + key: "unstract.v1.humanReview.oldVersion", + on: "true", +} as const; +/** + * How a test asks the app to render a page's v1 routes BEFORE it is + * registered. Under D9 only a page's final task registers it, so every + * earlier task's spec needs this. P0-T03 has not defined it yet; `null` makes + * every spec that asks for a preview skip. Expected shape once it exists: a + * dev/test-only storage key or cookie holding the page keys to preview, + * honoured only in non-production builds. + */ +export const V1_PREVIEW: PreviewMechanism | null = null; +// ── end stub ──────────────────────────────────────────────────────────────── + +export type PreviewMechanism = + | { kind: "localStorage" | "sessionStorage"; key: string } + | { kind: "cookie"; name: string }; + +export const PREVIEW_PENDING_REASON = + "P0-T03 has no dev/test override to render an unregistered v1 page: contract/registry.ts V1_PREVIEW=null"; + +export const REGISTRY_PENDING_REASON = + "P0-T03 route registry not landed: contract/registry.ts REGISTRY_SOURCE=stub"; + +export function registryAvailable(): boolean { + return REGISTRY_SOURCE !== "stub"; +} + +export function findEntry(page: PageKey): RegistryEntry { + const entry = REGISTRY.find((e) => e.page === page); + if (!entry) { + throw new Error( + `page ${page} is not in the route registry (source=${REGISTRY_SOURCE})`, + ); + } + return entry; +} diff --git a/tests/e2e/ui-v1/contract/v1-ui.ts b/tests/e2e/ui-v1/contract/v1-ui.ts new file mode 100644 index 0000000000..ddfba0b8ca --- /dev/null +++ b/tests/e2e/ui-v1/contract/v1-ui.ts @@ -0,0 +1,29 @@ +/** + * DOM markers the helpers assert on, in one place. + * + * ASSUMED, not yet published: the v1 tree root, its theme hook and the core + * state components do not exist yet (P0-T01, P0-T04, P1). These are the + * conventions the harness expects; when those tasks land, change the values + * here (or have those tasks adopt them) and every helper follows. + */ + +/** Attribute on the v1 tree root that tells v1 apart from v0. */ +export const V1_ROOT_SELECTOR = '[data-ui-version="v1"]'; + +/** Test ids of the one skeleton, empty state, error and no-permission view. */ +export const STATE_TEST_IDS = { + loading: "v1-state-loading", + empty: "v1-state-empty", + error: "v1-state-error", + noPermission: "v1-state-no-permission", +} as const; + +export const THEMES = ["light", "dark", "unstract"] as const; +export type Theme = (typeof THEMES)[number]; + +/** + * Default theme hook (P0-T04 owns the real one): a `data-theme` attribute plus + * a same-named class on , which covers both the attribute and the + * Tailwind class strategy. + */ +export const THEME_ATTRIBUTE = "data-theme"; diff --git a/tests/e2e/ui-v1/fixtures/auth.ts b/tests/e2e/ui-v1/fixtures/auth.ts new file mode 100644 index 0000000000..271a3ac143 --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/auth.ts @@ -0,0 +1,243 @@ +/** + * Login and organisation selection for the v1 E2E suite. + * + * Written fresh for v1 rather than imported from tests/e2e/ui (v1 and v0 stay + * separate in tests too), but the handshake is the same one + * tests/e2e/conftest.py::authed_session performs, so a change to that flow + * breaks every suite together instead of silently skipping this one. + * + * oss Django mock-login: form POST to /api/v1/login (302 + session), then + * GET /organization (csrftoken) and POST /organization//set. + * auth0 Cloud: the IdP owns the credential exchange, so drive its hosted + * form in the browser and let the app complete its own callback. + * + * UNSTRACT_AUTH_MODE pins the mode; otherwise OSS is tried first and auth0 is + * the fallback. + */ + +import { + type APIRequestContext, + type BrowserContext, + test as base, + type Page, +} from "@playwright/test"; +import { + type AuthMode, + BACKEND_URL, + configuredAuthMode, + ORG_ID, + RIG_MANAGED, +} from "./env.ts"; + +/** + * A seeded user's role. `admin` is the stack's seeded user; any other role + * needs UNSTRACT_E2E__USER and UNSTRACT_E2E__PASSWORD, and a test + * asking for a role with no credentials skips with that reason. + */ +export type Role = "admin" | (string & {}); + +interface Credentials { + username: string; + password: string; +} + +function credentialsFor(role: Role): Credentials | null { + const key = role.toUpperCase().replace(/[^A-Z0-9]/g, "_"); + const username = process.env[`UNSTRACT_E2E_${key}_USER`]; + const password = process.env[`UNSTRACT_E2E_${key}_PASSWORD`]; + if (username && password) { + return { username, password }; + } + if (role !== "admin") { + return null; + } + // Same fallbacks as the `ui` suite, so one env file drives both. + return { + username: + process.env.UNSTRACT_ADMIN_USER ?? + process.env.UNSTRACT_AUTH0_USER ?? + "unstract", + password: + process.env.UNSTRACT_ADMIN_PASSWORD ?? + process.env.UNSTRACT_AUTH0_PASSWORD ?? + "unstract", + }; +} + +export interface Session { + role: Role; + mode: AuthMode; + orgId: string; + /** Shares cookies with the browser context, so API and UI are one user. */ + api: APIRequestContext; + /** Headers a mutating API call needs (Django CSRF). */ + csrfHeaders(): Promise>; +} + +export interface App { + orgId: string; + /** Opens an org-scoped route (`workflows` -> `//workflows`). */ + goto(route: string): ReturnType; +} + +interface Organization { + id: string; +} + +async function listOrgIds(context: BrowserContext): Promise { + const res = await context.request + .get(`${BACKEND_URL}/api/v1/organization`, { failOnStatusCode: false }) + .catch(() => null); + if (!res?.ok()) { + return null; + } + const body = (await res.json()) as { organizations?: Organization[] }; + return (body.organizations ?? []).map((o) => o.id); +} + +async function csrfHeaders( + context: BrowserContext, +): Promise> { + const token = (await context.cookies()).find( + (c) => c.name === "csrftoken", + )?.value; + return token ? { "X-CSRFToken": token } : {}; +} + +/** Picks UNSTRACT_E2E_ORG_ID when the user belongs to it, else the first. */ +async function selectOrg(context: BrowserContext): Promise { + const orgIds = await listOrgIds(context); + if (!orgIds?.length) { + return null; + } + if (ORG_ID && !orgIds.includes(ORG_ID)) { + throw new Error( + `UNSTRACT_E2E_ORG_ID=${ORG_ID} is not one of this user's organisations: ${orgIds.join(", ")}`, + ); + } + const orgId = ORG_ID ?? orgIds[0] ?? null; + if (!orgId) { + return null; + } + const res = await context.request.post( + `${BACKEND_URL}/api/v1/organization/${orgId}/set`, + { headers: await csrfHeaders(context), failOnStatusCode: false }, + ); + return res.ok() ? orgId : null; +} + +async function loginOss( + context: BrowserContext, + creds: Credentials, +): Promise { + const login = await context.request + .post(`${BACKEND_URL}/api/v1/login`, { + form: { username: creds.username, password: creds.password }, + maxRedirects: 0, + failOnStatusCode: false, + }) + .catch(() => null); + // 200 is the login form re-rendering: bad credentials, or not an OSS stack. + if (login?.status() !== 302) { + return null; + } + return await selectOrg(context); +} + +async function loginAuth0( + page: Page, + context: BrowserContext, + creds: Credentials, +): Promise { + await page.goto("/", { waitUntil: "domcontentloaded" }).catch(() => null); + // Field names are stable across Auth0's Universal Login and classic Lock. + const user = page + .locator('input[name="username"], input[name="email"], input#username') + .first(); + if ((await user.count()) > 0) { + await user.fill(creds.username); + await page + .locator('input[name="password"], input#password') + .first() + .fill(creds.password); + await page + .locator('button[type="submit"], button[name="action"]') + .first() + .click(); + // The callback bounces through the app; the landing URL differs by tenant. + await page.waitForLoadState("networkidle").catch(() => null); + } + return await selectOrg(context); +} + +async function authenticate( + page: Page, + context: BrowserContext, + creds: Credentials, +): Promise<{ orgId: string | null; mode: AuthMode }> { + const mode = configuredAuthMode(); + if (mode !== "auth0") { + const orgId = await loginOss(context, creds); + if (orgId || mode === "oss") { + return { orgId, mode: "oss" }; + } + } + return { orgId: await loginAuth0(page, context, creds), mode: "auth0" }; +} + +interface AuthFixtures { + /** Which seeded user signs in: `test.use({ role: "viewer" })`. */ + role: Role; + session: Session; + app: App; +} + +export const test = base.extend({ + role: ["admin", { option: true }], + + session: async ({ page, context, role }, use) => { + const creds = credentialsFor(role); + test.skip( + !creds, + `no credentials for role "${role}": set UNSTRACT_E2E_${role.toUpperCase()}_USER and _PASSWORD`, + ); + if (!creds) { + return; + } + const { orgId, mode } = await authenticate(page, context, creds); + if (!orgId) { + const reason = + `could not sign in as "${role}" against ${BACKEND_URL} (mode=${mode}). ` + + "Set UNSTRACT_AUTH_MODE=oss|auth0 plus credentials, or let the rig " + + "boot a stack (`python -m tests.rig run frontend-v1-e2e`)."; + // Under the rig the stack is known to be up, so a failed login is a bug. + if (RIG_MANAGED) { + throw new Error(reason); + } + test.skip(true, reason); + return; + } + await use({ + role, + mode, + orgId, + api: context.request, + csrfHeaders: () => csrfHeaders(context), + }); + }, + + app: async ({ page, session }, use) => { + await use({ + orgId: session.orgId, + async goto(route) { + const path = route.replace(/^\//, ""); + const response = await page.goto(`/${session.orgId}/${path}`, { + waitUntil: "domcontentloaded", + }); + // The SPA renders after hydration; wait for it, not a fixed sleep. + await page.waitForLoadState("networkidle").catch(() => null); + return response; + }, + }); + }, +}); diff --git a/tests/e2e/ui-v1/fixtures/env.ts b/tests/e2e/ui-v1/fixtures/env.ts new file mode 100644 index 0000000000..0950bdf918 --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/env.ts @@ -0,0 +1,29 @@ +/** + * Environment the suite reads. The rig exports the URLs when it boots the + * platform (`python -m tests.rig run frontend-v1-e2e`); the fallbacks match the + * ports compose publishes. + */ + +export const FRONTEND_URL = + process.env.UNSTRACT_FRONTEND_URL ?? "http://localhost:3000"; + +export const BACKEND_URL = ( + process.env.UNSTRACT_BACKEND_URL ?? "http://localhost:8000" +).replace(/\/$/, ""); + +/** + * Set by tests/rig/cli.py for every `requires_platform` group. When present + * the rig already proved the stack answers, so an unreachable app or a failed + * login is a real failure, not a reason to skip. + */ +export const RIG_MANAGED = Boolean(process.env.UNSTRACT_RIG_SESSION_ID); + +export type AuthMode = "oss" | "auth0"; + +export function configuredAuthMode(): AuthMode | "auto" { + const explicit = process.env.UNSTRACT_AUTH_MODE?.toLowerCase(); + return explicit === "oss" || explicit === "auth0" ? explicit : "auto"; +} + +/** Pins the organisation to select; the first one the user has otherwise. */ +export const ORG_ID = process.env.UNSTRACT_E2E_ORG_ID || undefined; diff --git a/tests/e2e/ui-v1/fixtures/index.ts b/tests/e2e/ui-v1/fixtures/index.ts new file mode 100644 index 0000000000..d7c09df0ec --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/index.ts @@ -0,0 +1,14 @@ +/** + * Entry point for every v1 spec: + * + * import { DIST, expect, test } from "../fixtures/index.ts"; + * + * `test` carries the login (`session`, `app`, `role`), `seed` and + * `v1Preview` fixtures. + */ + +export { expect } from "@playwright/test"; +export type { App, Role, Session } from "./auth.ts"; +export { enableV1Preview, test } from "./preview.ts"; +export type { CreateOptions, Seed } from "./seed.ts"; +export { currentDistribution, DIST, TAG } from "./tags.ts"; diff --git a/tests/e2e/ui-v1/fixtures/preview.ts b/tests/e2e/ui-v1/fixtures/preview.ts new file mode 100644 index 0000000000..e70a34b714 --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/preview.ts @@ -0,0 +1,67 @@ +/** + * Renders a page's v1 routes before the page is registered (D9: only a + * page's final task registers it, so earlier tasks' specs need this). + * + * test.use({ v1Preview: ["UN13"] }); + * + * The mechanism comes from contract/registry.ts (`V1_PREVIEW`). Until P0-T03 + * provides one, a test that asks for a preview skips with the reason, so a + * non-final page task can write its spec now and it turns on by itself. + */ + +import type { BrowserContext, Page } from "@playwright/test"; +import { + type PageKey, + PREVIEW_PENDING_REASON, + type PreviewMechanism, + V1_PREVIEW, +} from "../contract/registry.ts"; +import { FRONTEND_URL } from "./env.ts"; +import { test as seedTest } from "./seed.ts"; + +function writeStorage([storage, key, value]: [string, string, string]): void { + const store = storage === "sessionStorage" ? sessionStorage : localStorage; + store.setItem(key, value); +} + +/** Applies the preview override for `pages` to this page's later loads. */ +export async function enableV1Preview( + page: Page, + context: BrowserContext, + pages: readonly PageKey[], + mechanism: PreviewMechanism | null = V1_PREVIEW, +): Promise { + if (!mechanism) { + throw new Error(PREVIEW_PENDING_REASON); + } + const value = JSON.stringify(pages); + if (mechanism.kind === "cookie") { + await context.addCookies([ + { name: mechanism.name, value, url: FRONTEND_URL }, + ]); + return; + } + const args: [string, string, string] = [mechanism.kind, mechanism.key, value]; + await page.addInitScript(writeStorage, args); +} + +interface PreviewFixtures { + /** Page keys to render as v1 before registration. */ + v1Preview: readonly PageKey[]; + /** Applies `v1Preview` before the test body; never used directly. */ + applyV1Preview: undefined; +} + +export const test = seedTest.extend({ + v1Preview: [[], { option: true }], + applyV1Preview: [ + async ({ page, context, v1Preview }, use) => { + if (v1Preview.length) { + test.skip(!V1_PREVIEW, PREVIEW_PENDING_REASON); + await enableV1Preview(page, context, v1Preview); + } + await use(undefined); + }, + { auto: true }, + ], +}); diff --git a/tests/e2e/ui-v1/fixtures/seed.ts b/tests/e2e/ui-v1/fixtures/seed.ts new file mode 100644 index 0000000000..e0bdc86f04 --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/seed.ts @@ -0,0 +1,103 @@ +/** + * Test data through the API, removed after the test. + * + * const wf = await seed.create<{ id: string }>("workflow/", { + * workflow_name: seed.name("wf"), + * }); + * + * Paths are relative to the org-scoped API root + * (`/api/v1/unstract//`). Everything created is deleted in reverse + * order when the test ends, pass or fail. A delete that fails fails the test: + * the suite shares one org, so leaked rows would leak into other specs. + */ + +import { randomUUID } from "node:crypto"; +import { test as authTest } from "./auth.ts"; +import { BACKEND_URL } from "./env.ts"; + +export interface CreateOptions { + /** Field holding the new row's id. Default `id`. */ + idField?: string; + /** Delete path for the created row. Default `//`. */ + deletePath?: (created: T) => string; +} + +export interface Seed { + /** `${BACKEND}/api/v1/unstract/`, no trailing slash. */ + prefix: string; + /** A unique, recognisable name for seeded rows: `e2e-v1-