diff --git a/.github/workflows/ci-test.yaml b/.github/workflows/ci-test.yaml index 385120b02c..e8d7540457 100644 --- a/.github/workflows/ci-test.yaml +++ b/.github/workflows/ci-test.yaml @@ -238,6 +238,34 @@ jobs: # Cheap pre-flight before the multi-minute image build. run: tox -e rig -- validate + # 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. Install, lint and typecheck run + # before the image build, so a type error fails in seconds; the browser + # install stays after it. + - 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: Lint and typecheck frontend-v1 E2E + working-directory: tests/e2e/ui-v1 + # Playwright strips types without checking them, and neither the + # frontend lint workflow nor pre-commit covers tests/. The typecheck + # also holds the suite to the frontend registry types it imports. + run: bun run lint && bun run typecheck + - name: Set up env files run: ./run-platform.sh -e @@ -276,6 +304,24 @@ jobs: worker-unified.cache-from=type=gha,scope=worker-unified worker-unified.cache-to=type=gha,mode=max,scope=worker-unified + - 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/README.md b/tests/README.md index 22029f2358..f4856dda14 100644 --- a/tests/README.md +++ b/tests/README.md @@ -117,12 +117,13 @@ Optional knobs (see `groups.yaml` for examples): ### Frontend groups (`vitest` / `playwright`) -Two groups run Node suites rather than pytest: +Three groups run Node suites rather than pytest: | Group | Tier | Runner | What it covers | |---|---|---|---| | `frontend` | unit | `vitest` | Vitest + React Testing Library over jsdom — the antd→shadcn shims and the components on top of them. Workdir `frontend`. | | `ui` | e2e | `playwright` | Browser e2e against the real UI. Specs live in `tests/e2e/ui/`, beside the other e2e suites. | +| `frontend-v1-e2e` | e2e | `playwright` | Browser e2e for the v1 frontend (epic UN-4257). Specs and harness live in `tests/e2e/ui-v1/`; see its README. | ```bash python -m tests.rig run frontend # the whole vitest suite @@ -135,18 +136,29 @@ cd tests/e2e/ui && npx playwright install chromium `tests/e2e/ui` is self-contained — specs, `playwright.config.js` and its own `node_modules` — so it resolves without reaching into `frontend/`. -Both emit **real JUnit**, so `parse_junit` reports per-test counts exactly like +All three emit **real JUnit**, so `parse_junit` reports per-test counts exactly like a pytest group (unlike `hurl`, which gets one synthetic row). Vitest takes `--outputFile`; Playwright reads `PLAYWRIGHT_JUNIT_OUTPUT_NAME`, which the rig exports — that is why the config reads it. -Both are `optional`, deliberately. They shell out via `npx --no-install`, so a +`frontend` and `ui` are `optional`, deliberately. They shell out via `npx --no-install`, so a machine or CI image without `node_modules` (or without the browser binaries) **skips** instead of failing the build. The runner returns exit 5 — the rig's "nothing collected" convention — when `npx` is missing entirely. If a Node runner dies before its reporter flushes, the rig backfills a synthetic junit so the failure still shows in the summary rather than vanishing as a missing group. +`frontend-v1-e2e` is **not** optional: it gates the build. CI's e2e job +installs its `node_modules` and browser before the tier runs. To run it +locally, install them first: + +```bash +cd tests/e2e/ui-v1 && bun install --frozen-lockfile && bunx playwright install chromium +``` + +Without them, `npx --no-install` fails and the group is red; with no `npx` at +all it still exits 5 and reads as skipped, like the other Node groups. + **Auth.** The `ui` suite runs against both deployments. `UNSTRACT_AUTH_MODE` picks the flow, or it auto-detects: diff --git a/tests/compose/frontend-nginx.test.conf b/tests/compose/frontend-nginx.test.conf index b9e805e954..1a8c911fb4 100644 --- a/tests/compose/frontend-nginx.test.conf +++ b/tests/compose/frontend-nginx.test.conf @@ -59,9 +59,15 @@ http { # trailing slash so they behave like that file's PathPrefix() rules # (`/api/v1` itself matches, not just `/api/v1/...`). Keep the list # in step with those labels. + # + # `Host $http_host`, not `$host`: `$host` drops the port, so Django's + # CSRF same-origin check compares the browser's `Origin` + # (http://localhost:3000) with http://localhost and rejects every + # unsafe request the SPA makes, starting with the org handshake, which + # leaves the app on its "Please wait while we prepare your session" screen. location /api/v1 { proxy_pass $backend_upstream$request_uri; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -72,7 +78,7 @@ http { location /deployment { proxy_pass $backend_upstream$request_uri; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -82,7 +88,7 @@ http { location /public { proxy_pass $backend_upstream$request_uri; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; 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..fd6b627e4f --- /dev/null +++ b/tests/e2e/ui-v1/README.md @@ -0,0 +1,106 @@ +# 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 +# Once: the suite's own dependencies and a browser. The group gates the +# build, so without these the rig reports it red, not skipped. +cd tests/e2e/ui-v1 +bun install --frozen-lockfile +bunx playwright install chromium + +# Through the rig, as CI runs it (boots the platform, e2e-smoke first). +# The stack needs a frontend image built with VITE_V1_DEV_ROUTES=true, as +# ci-test.yaml builds it; a release image has no demo pages. +tox -e e2e + +# Standalone against a running stack +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, upper-cased with non-alphanumerics as `_` (`org-admin` reads `UNSTRACT_E2E_ORG_ADMIN_USER`); a test asking for a role without them skips, and fails under the rig, which seeds only the admin | +| `UNSTRACT_E2E_ORG_ID` | The organisation to select; the first one by default | +| `UNSTRACT_DISTRIBUTION` | `oss` (default), `cloud` or `onprem`: tests tagged for it run. Must match the image's `VITE_DISTRIBUTION` (unset builds as oss), which decides the pages it renders | +| `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: a +test tagged only for another distribution is not collected (nor are its +hooks), and one with no distribution tag (or a mistyped one, `@OSS`) fails. + +The stack under test is the E2E image: built with `VITE_V1_DEV_ROUTES=true` +(ci-test.yaml), which registers P0-T03's demo pages and honours the preview +override. A release image ignores both. + +```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` | Toggle pages only (Human Review, page 11, and its demo stand-in): old-version preference on and off, for `session.userId` | + +## Where the harness meets the frontend + +- `contract/registry.ts` reads P0-T03's registry data from the frontend + (`src/v1/app/registry/{types,entries,validate}.ts`, `src/v1/lib/oldVersion.ts`), + which import nothing Vite-specific. `REGISTRY` is what the E2E image renders + as v1: registered pages plus the demo pages. The typecheck in CI holds the + harness to those types. +- `contract/v1-ui.ts` holds the DOM markers: the v1 root + (`[data-ui-version="v1"]`, carrying `data-theme`), and the theme modes and + storage key from `src/v1/lib/theme.ts`. +- `specs/P0-T09.demo.spec.ts` proves the switch on P0-T03's demo pages + (`/:orgName/v1-demo`, `/:orgName/v1-demo-review`). Those pages load no data, + so the five-state helper is proven on `support/state-fixture.ts`. + +Still open: + +1. **State components** (P1): set `STATE_TEST_IDS` to the core skeleton, empty, + error and no-permission components, following the team's selector decision + for v1 tests. +2. **a11y level** (P8-T02): confirm or change the default `serious`. 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/distribution.ts b/tests/e2e/ui-v1/contract/distribution.ts new file mode 100644 index 0000000000..ff54b66d3f --- /dev/null +++ b/tests/e2e/ui-v1/contract/distribution.ts @@ -0,0 +1,22 @@ +/** + * The distribution this run checks, from UNSTRACT_DISTRIBUTION. It must match + * the image's VITE_DISTRIBUTION, which decides the pages it renders; nothing + * can check that from here. Read like the app's parseDistribution: unset or + * empty means oss. + */ + +import { + DISTRIBUTIONS, + type Distribution, +} from "../../../../frontend/src/v1/app/registry/types.ts"; + +export function currentDistribution(): Distribution { + const raw = process.env.UNSTRACT_DISTRIBUTION?.trim().toLowerCase() || "oss"; + const distribution = DISTRIBUTIONS.find((d) => d === raw); + if (!distribution) { + throw new Error( + `UNSTRACT_DISTRIBUTION=${raw} is not one of ${DISTRIBUTIONS.join(", ")}`, + ); + } + return distribution; +} diff --git a/tests/e2e/ui-v1/contract/registry.ts b/tests/e2e/ui-v1/contract/registry.ts new file mode 100644 index 0000000000..1ee25bf820 --- /dev/null +++ b/tests/e2e/ui-v1/contract/registry.ts @@ -0,0 +1,100 @@ +/** + * The ONE place the harness learns about the v1 route registry (P0-T03, + * UN-4271). The redirect, old-version and preview helpers read it only + * through this module. + * + * It imports the frontend's own registry data, so the harness and the app + * cannot drift. registry/types.ts promises that types.ts, entries.ts and + * lib/oldVersion.ts stay importable without Vite (no `import.meta.env`, no + * `@v1` alias, only relative imports). This also reads registry/validate.ts + * and registry/patterns.ts (which validate.ts imports) and, in v1-ui.ts, + * lib/theme.ts, which hold to the same today without promising it; if one + * stops, every run fails at load, not silently. registry/live.ts reads + * `import.meta.env` when it loads, and resolve.ts imports react-router, so + * this module rebuilds what live.ts computes for the E2E image instead. + */ + +import { + DEMO_PAGES, + PAGES, + REGISTERED_PAGES, +} from "../../../../frontend/src/v1/app/registry/entries.ts"; +import { + type Distribution, + type PageKey, + PREVIEW_STORAGE_KEY, + type RegistryEntry, +} from "../../../../frontend/src/v1/app/registry/types.ts"; +import { currentDistribution } from "./distribution.ts"; + +export { paramNames } from "../../../../frontend/src/v1/app/registry/patterns.ts"; +export type { + Distribution, + PageKey, + RegistryEntry, +} from "../../../../frontend/src/v1/app/registry/types.ts"; +export { + isPageKey, + TOGGLE_PAGES, +} from "../../../../frontend/src/v1/app/registry/validate.ts"; +export { + LAST_USER_STORAGE_KEY, + OLD_VERSION_ON, + oldVersionStorageKey, +} from "../../../../frontend/src/v1/lib/oldVersion.ts"; + +function isRegistered(entry: RegistryEntry): boolean { + return REGISTERED_PAGES.includes(entry.page); +} + +/** Whether `entry` can render as v1 in a build of `distribution`. */ +export function shipsIn( + entry: RegistryEntry, + distribution: Distribution, +): boolean { + return entry.distributions.includes(distribution); +} + +/** + * The pages the E2E image renders as v1 in this run's distribution, as + * live.ts computes them: registered pages plus the development-only demo + * pages (VITE_V1_DEV_ROUTES=true, set for the E2E image in ci-test.yaml), + * narrowed to the distribution the way resolve.ts's activeEntries does. + */ +export const REGISTRY: readonly RegistryEntry[] = [ + ...PAGES.filter(isRegistered), + ...DEMO_PAGES, +].filter((entry) => shipsIn(entry, currentDistribution())); + +/** + * Declared but not yet registered (D9) and shipped in this run's + * distribution: these render as v1 only through the preview override + * (`test.use({ v1Preview: [...] })`). + */ +export const UNREGISTERED_PAGES: readonly RegistryEntry[] = PAGES.filter( + (entry) => !isRegistered(entry) && shipsIn(entry, currentDistribution()), +); + +/** + * How a test asks the app to render pages before they are registered: + * P0-T03's override, a JSON array of page keys in localStorage, read once + * when the app loads, honoured only with VITE_V1_DEV_ROUTES=true. + */ +export interface PreviewMechanism { + kind: "localStorage"; + key: string; +} + +export const V1_PREVIEW: PreviewMechanism = { + kind: "localStorage", + key: PREVIEW_STORAGE_KEY, +}; + +/** Any page the frontend declares, in any distribution, demo pages included. */ +export function findEntry(page: PageKey): RegistryEntry { + const entry = [...PAGES, ...DEMO_PAGES].find((e) => e.page === page); + if (!entry) { + throw new Error(`page ${page} is not declared in the v1 route registry`); + } + 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..e9afd3a5db --- /dev/null +++ b/tests/e2e/ui-v1/contract/v1-ui.ts @@ -0,0 +1,36 @@ +/** + * DOM markers and storage keys the helpers rely on, in one place. The theme + * values come from the frontend's own module, so they cannot drift. + */ + +export { + THEME_MODES as THEMES, + THEME_STORAGE_KEY, + type ThemeMode as Theme, +} from "../../../../frontend/src/v1/lib/theme.ts"; + +/** The v1 tree root (ThemeProvider), which tells v1 apart from v0. */ +export const V1_ROOT_SELECTOR = '[data-ui-version="v1"]'; + +/** The v1 root carries the rendered theme in this attribute. */ +export const THEME_ATTRIBUTE = "data-theme"; + +/** + * Anything visible inside the SPA mount (frontend/index.html `#root`). "No v1 + * root" only means v0 rendered once this exists; before React mounts, the + * page has no v1 root either. Not `#root > *`: v0 puts a script and hidden + * wrappers there first. `:visible` is Playwright's pseudo-class. + */ +export const APP_MOUNTED_SELECTOR = "#root *:visible"; + +/** + * Test ids of the one skeleton, empty state, error and no-permission view. + * Still assumed: the core state components (P1) define the real ids, and the + * selector convention for v1 tests is pending a team decision. + */ +export const STATE_TEST_IDS = { + loading: "v1-state-loading", + empty: "v1-state-empty", + error: "v1-state-error", + noPermission: "v1-state-no-permission", +} as const; diff --git a/tests/e2e/ui-v1/fixtures/auth.ts b/tests/e2e/ui-v1/fixtures/auth.ts new file mode 100644 index 0000000000..380e8714ec --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/auth.ts @@ -0,0 +1,442 @@ +/** + * 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 start the login the + * way the Login button does, drive the 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. A failed sign-in names the step that failed and why, for + * every mode it tried. + */ + +import type { + APIRequestContext, + APIResponse, + BrowserContext, + Page, +} from "@playwright/test"; +import { + type AuthMode, + BACKEND_URL, + configuredAuthMode, + FRONTEND_URL, + ORG_ID, + RIG_MANAGED, +} from "./env.ts"; +import { settle } from "./settle.ts"; +import { test as base } from "./tags.ts"; + +/** + * A seeded user's role. `admin` is the stack's seeded user; any other role + * needs UNSTRACT_E2E__USER and UNSTRACT_E2E__PASSWORD (see + * `roleEnvKey`). A test asking for a role with no credentials skips with + * that reason, and fails under the rig, which seeds only the admin. + */ +export type Role = "admin" | (string & {}); + +interface Credentials { + username: string; + password: string; +} + +/** The `` in UNSTRACT_E2E__USER / _PASSWORD. */ +export function roleEnvKey(role: Role): string { + return role.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +} + +function credentialsFor(role: Role): Credentials | null { + const key = roleEnvKey(role); + 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 { + /** + * The role the test asked for. Not verified against the signed-in user: + * credentials set for a role sign in as whoever they belong to, so a role + * spec must check what it relies on. + */ + role: Role; + mode: AuthMode; + /** For API calls: `/api/v1/unstract//...`. */ + orgId: string; + /** For browser routes: the app's URLs carry the org's name, not its id. */ + orgName: string; + /** The signed-in user's id, as the v0 session holds it (per-user v1 preferences). */ + userId: 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; + /** The `:orgName` route param for this session's organisation. */ + orgName: string; + /** Opens an org-scoped route (`workflows` -> `//workflows`). */ + goto(route: string): ReturnType; +} + +export interface Organization { + id: string; + name: string; +} + +/** + * The organisation as the frontend sees it once it is set. Routes carry + * `organization.name` from the set response (useSessionValid), which need not + * equal the id (cloud: `org_…` id, slug name); the listed name is the fallback. + */ +export function routedOrganization( + listed: Organization, + setBody: unknown, +): Organization { + const name = (setBody as { organization?: { name?: unknown } } | null) + ?.organization?.name; + return { + id: listed.id, + name: typeof name === "string" && name ? name : listed.name, + }; +} + +/** + * The signed-in user's id from the set response's `user`, which is what the + * v0 session stores (useSessionValid) and hands the version switch. + */ +export function sessionUserId(setBody: unknown): string | null { + const id = (setBody as { user?: { id?: unknown } } | null)?.user?.id; + return typeof id === "string" || typeof id === "number" ? String(id) : null; +} + +interface SignedIn { + org: Organization; + userId: string; +} + +/** + * A sign-in step's outcome. A failure keeps the step and why, so a failed + * login says where it stopped, as tests/e2e/conftest.py does. + */ +type Failure = { ok: false; step: string; detail: string }; +type Attempt = { ok: true; value: T } | Failure; + +function failed(step: string, detail: string): Failure { + return { ok: false, step, detail }; +} + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** Resolves to the response, or to an Error when the request itself failed. */ +function settledResponse( + request: Promise, +): Promise { + return request.catch((err: unknown) => new Error(errorText(err))); +} + +/** Status and the start of the body, for a failure message. No request data. */ +async function describeResponse(res: APIResponse): Promise { + const body = await res.text().catch(() => ""); + return `${res.status()} ${body.slice(0, 200)}`.trim(); +} + +async function listOrgs( + context: BrowserContext, +): Promise> { + const step = "GET /api/v1/organization"; + const res = await settledResponse( + context.request.get(`${BACKEND_URL}/api/v1/organization`, { + failOnStatusCode: false, + }), + ); + if (res instanceof Error) { + return failed(step, res.message); + } + if (!res.ok()) { + return failed(step, await describeResponse(res)); + } + const body = (await res.json()) as { organizations?: Organization[] }; + const orgs = body.organizations ?? []; + return orgs.length + ? { ok: true, value: orgs } + : failed(step, "the user has no organisations"); +} + +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 listed = await listOrgs(context); + if (!listed.ok) { + return listed; + } + const orgs = listed.value; + const org = ORG_ID ? orgs.find((o) => o.id === ORG_ID) : orgs[0]; + if (!org) { + throw new Error( + `UNSTRACT_E2E_ORG_ID=${ORG_ID} is not one of this user's organisations: ${orgs.map((o) => o.id).join(", ")}`, + ); + } + const step = `POST /api/v1/organization/${org.id}/set`; + const res = await settledResponse( + context.request.post(`${BACKEND_URL}/api/v1/organization/${org.id}/set`, { + headers: await csrfHeaders(context), + failOnStatusCode: false, + }), + ); + if (res instanceof Error) { + return failed(step, res.message); + } + if (!res.ok()) { + return failed(step, await describeResponse(res)); + } + const body: unknown = await res.json().catch(() => null); + const userId = sessionUserId(body); + if (!userId) { + throw new Error(`${step} returned no user.id: ${JSON.stringify(body)}`); + } + return { ok: true, value: { org: routedOrganization(org, body), userId } }; +} + +async function loginOss( + context: BrowserContext, + creds: Credentials, +): Promise> { + const step = "POST /api/v1/login"; + const login = await settledResponse( + context.request.post(`${BACKEND_URL}/api/v1/login`, { + form: { username: creds.username, password: creds.password }, + maxRedirects: 0, + failOnStatusCode: false, + }), + ); + if (login instanceof Error) { + return failed(step, login.message); + } + // 200 is the login form re-rendering: bad credentials, or not an OSS stack. + if (login.status() !== 302) { + return failed(step, `expected 302, got ${login.status()}`); + } + return await selectOrg(context); +} + +/** Per IdP wait: the hosted form and the callback are a round trip to it. */ +const AUTH0_STEP_TIMEOUT_MS = 20_000; + +/** + * The session fixture's own budget. Its setup counts against the test + * timeout otherwise, and a slow failed login would surface as a generic + * timeout instead of the skip or the sign-in error below. + */ +const SESSION_TIMEOUT_MS = 120_000; + +/** The error `action` threw, or null when it completed. */ +function caught(action: Promise): Promise { + return action.then( + () => null, + (err: unknown) => errorText(err), + ); +} + +async function loginAuth0( + page: Page, + context: BrowserContext, + creds: Credentials, +): Promise> { + // `/` only renders the app's own login page. Its Login button navigates to + // /api/v1/login, and the backend builds the callback URL from + // the Referer, so go there the same way. + const appOrigin = new URL(FRONTEND_URL).origin; + const openError = await caught( + page.goto("/api/v1/login", { + referer: `${appOrigin}/`, + waitUntil: "domcontentloaded", + }), + ); + if (openError) { + return failed("open /api/v1/login", openError); + } + // Still on the app means the IdP had a session and skipped its form. + if (new URL(page.url()).origin !== appOrigin) { + // 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(); + const password = page + .locator('input[name="password"], input#password') + .first(); + const submit = page + .locator('button[type="submit"], button[name="action"]') + .first(); + const timeout = AUTH0_STEP_TIMEOUT_MS; + const steps: [string, () => Promise][] = [ + ["fill the IdP username", () => user.fill(creds.username, { timeout })], + [ + // Identifier-first tenants ask for the password on a second screen. + "continue to the IdP password screen", + async () => { + if (!(await password.isVisible())) { + await submit.click({ timeout }); + } + }, + ], + [ + "fill the IdP password", + () => password.fill(creds.password, { timeout }), + ], + ["submit the IdP form", () => submit.click({ timeout })], + [ + // Wrong credentials, MFA or consent all leave the browser on the IdP. + "return to the app from the IdP", + () => page.waitForURL((url) => url.origin === appOrigin, { timeout }), + ], + ]; + for (const [step, run] of steps) { + const error = await caught(run()); // NOSONAR: sign-in steps run in order + if (error) { + return failed(step, `${error} (at ${page.url()})`); + } + } + } + // The callback bounces through the app; the landing URL differs by tenant. + await settle(page); + return await selectOrg(context); +} + +function describeFailure(mode: AuthMode, failure: Failure): string { + return `${mode}: ${failure.step} failed: ${failure.detail}`; +} + +/** + * Signs in by the configured mode. In auto mode OSS is tried first and auth0 + * after; when both fail, both failures are reported, since an OSS stack also + * "fails" auth0 and the auth0 failure alone would point the wrong way. + */ +async function authenticate( + page: Page, + context: BrowserContext, + creds: Credentials, +): Promise<{ signedIn: SignedIn | null; mode: AuthMode; failures: string[] }> { + const mode = configuredAuthMode(); + const failures: string[] = []; + if (mode !== "auth0") { + const oss = await loginOss(context, creds); + if (oss.ok) { + return { signedIn: oss.value, mode: "oss", failures }; + } + failures.push(describeFailure("oss", oss)); + if (mode === "oss") { + return { signedIn: null, mode: "oss", failures }; + } + } + const auth0 = await loginAuth0(page, context, creds); + if (auth0.ok) { + return { signedIn: auth0.value, mode: "auth0", failures: [] }; + } + failures.push(describeFailure("auth0", auth0)); + return { signedIn: null, mode: "auth0", failures }; +} + +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); + if (!creds) { + const reason = `no credentials for role "${role}": set UNSTRACT_E2E_${roleEnvKey(role)}_USER and _PASSWORD`; + // The rig provisions only the admin user, so a role spec under it + // would skip on every run and still read green; a typo'd role too. + if (RIG_MANAGED) { + throw new Error(reason); + } + test.skip(true, reason); + return; + } + const { signedIn, mode, failures } = await authenticate( + page, + context, + creds, + ); + if (!signedIn) { + const reason = + `could not sign in as "${role}" against ${BACKEND_URL} (UNSTRACT_AUTH_MODE=${configuredAuthMode()}):\n ${failures.join("\n ")}\n` + + "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: signedIn.org.id, + orgName: signedIn.org.name, + userId: signedIn.userId, + api: context.request, + csrfHeaders: () => csrfHeaders(context), + }); + }, + { timeout: SESSION_TIMEOUT_MS }, + ], + + app: async ({ page, session }, use) => { + await use({ + orgId: session.orgId, + orgName: session.orgName, + async goto(route) { + const path = route.replace(/^\//, ""); + const response = await page.goto(`/${session.orgName}/${path}`, { + waitUntil: "domcontentloaded", + }); + // The SPA renders after hydration; wait for it, not a fixed sleep. + await settle(page); + 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..4b755a3145 --- /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, Organization, 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..0fd6cca484 --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/preview.ts @@ -0,0 +1,78 @@ +/** + * 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`): P0-T03's + * localStorage override, honoured only by a VITE_V1_DEV_ROUTES=true build + * (the E2E image). It is read once when the app loads, so it is written + * before the test's first navigation. + */ + +import type { Page } from "@playwright/test"; +import { currentDistribution } from "../contract/distribution.ts"; +import { + findEntry, + type PageKey, + type PreviewMechanism, + shipsIn, + V1_PREVIEW, +} from "../contract/registry.ts"; +import { test as seedTest } from "./seed.ts"; + +/** + * Refuses a page the app could not render here: one the frontend does not + * declare (in PAGES or DEMO_PAGES), or one this run's distribution does not + * ship, which the app's live registry drops even when previewed. + */ +export function assertPreviewable(pages: readonly PageKey[]): void { + const distribution = currentDistribution(); + for (const key of pages) { + if (!shipsIn(findEntry(key), distribution)) { + throw new Error( + `v1Preview: ${key} does not ship in ${distribution}; tag the spec for its distributions instead`, + ); + } + } +} + +function writeStorage([key, value]: [string, string]): void { + localStorage.setItem(key, value); +} + +/** The stored value for `pages`: the JSON array readPreviewPages parses. */ +export function previewValue(pages: readonly PageKey[]): string { + return JSON.stringify(pages); +} + +/** Applies the preview override for `pages` to this page's later loads. */ +export async function enableV1Preview( + page: Page, + pages: readonly PageKey[], + mechanism: PreviewMechanism = V1_PREVIEW, +): Promise { + const args: [string, string] = [mechanism.key, previewValue(pages)]; + 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, v1Preview }, use) => { + if (v1Preview.length) { + assertPreviewable(v1Preview); + await enableV1Preview(page, 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..7240437dfc --- /dev/null +++ b/tests/e2e/ui-v1/fixtures/seed.ts @@ -0,0 +1,118 @@ +/** + * 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-