diff --git a/.depot/workflows/deploy.yml b/.depot/workflows/deploy.yml index c1fde45..b829e80 100644 --- a/.depot/workflows/deploy.yml +++ b/.depot/workflows/deploy.yml @@ -1,9 +1,10 @@ name: Deploy # DOCS AT MAIN: every main push that changes what the Worker ships deploys `preview` -# (docs.iterate-dev-preview.workers.dev) and then, once that answers, `prd` (docs.iterate.workers.dev, -# which the iterate project's `docs` slug and docs.iterate.com route to), both from envs.ts. A red run -# pages. +# (docs.iterate-dev-preview.workers.dev), runs the browser specs against it (test/playwright/docs, on +# main on dev's platform, installing this commit's @iterate-com/docs from pkg.pr.new), and then, once +# they pass, deploys `prd` (docs.iterate.workers.dev, which the iterate project's `docs` slug and +# docs.iterate.com route to), both from envs.ts. A red run pages. permissions: contents: read @@ -40,10 +41,41 @@ jobs: DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} run: pnpm --dir packages/docs-app run-script deploy --env preview - # only after preview answered: a deploy that fails there never reaches production + specs: + name: Browser specs against preview + needs: preview + # six browsers at once (test/playwright.config.ts `workers`) + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.sha }} + - name: Setup + uses: ./.depot/actions/setup + - name: Browser + run: pnpm --dir test exec playwright install --only-shell chromium + - name: Specs + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + PUBLISHED_PACKAGE_COMMIT: ${{ github.sha }} + WORKER_BASE_URL: https://os.iterate-dev-preview.workers.dev + DOCS_BASE_URL: https://docs.iterate-dev-preview.workers.dev + run: doppler run --project os --config preview -- pnpm spec + - name: Upload the report + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: test-results/ + retention-days: 14 + + # only after preview passed its specs: a deploy that fails there never reaches production prd: name: Deploy prd - needs: preview + needs: specs runs-on: depot-ubuntu-24.04 timeout-minutes: 20 steps: @@ -62,6 +94,7 @@ jobs: page: needs: - preview + - specs - prd if: always() && github.ref == 'refs/heads/main' && contains(needs.*.result, 'failure') runs-on: depot-ubuntu-24.04 diff --git a/.depot/workflows/pull-request-closed.yml b/.depot/workflows/pull-request-closed.yml new file mode 100644 index 0000000..ee4a50e --- /dev/null +++ b/.depot/workflows/pull-request-closed.yml @@ -0,0 +1,38 @@ +name: Pull request closed + +# A CLOSED PULL REQUEST'S DOCS WORKER, `docs-pr` (specs.yml deployed it), deleted. Its own +# workflow, so no push to an open pull request carries it as a skipped job. Depot registers the +# `closed` trigger once this file is on the default branch. By hand: +# pnpm --dir packages/docs-app delete-pull-request --pr +permissions: + contents: read + +on: + pull_request: + types: + - closed + +concurrency: + # waits for the pull request's specs run to settle instead of deleting its Worker from under it + group: specs-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + delete: + name: Delete the pull request's Docs + runs-on: depot-ubuntu-24.04 + timeout-minutes: 10 + steps: + # main's code, not the pull request's: a head whose install broke still gets its Worker + # deleted, and none of its code runs with the Doppler token + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + - name: Setup + uses: ./.depot/actions/setup + - name: Delete + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: pnpm --dir packages/docs-app delete-pull-request --pr ${{ github.event.pull_request.number }} diff --git a/.depot/workflows/specs.yml b/.depot/workflows/specs.yml new file mode 100644 index 0000000..8da4c56 --- /dev/null +++ b/.depot/workflows/specs.yml @@ -0,0 +1,54 @@ +name: Specs + +# A PULL REQUEST'S DOCS UNDER ITS BROWSER SPECS (test/playwright/docs): its head deployed as a Worker +# of its own on the dev/preview account (`docs-pr`, envs.ts `pullRequestEnv`), served through +# fixture projects on main on dev's platform (os.iterate-dev-preview.workers.dev), each installing +# the head's @iterate-com/docs from pkg.pr.new (.github/workflows/pkg-pr-new.yml, which the specs +# wait for). The Worker is deleted when the pull request closes (pull-request-closed.yml). Main's +# specs run in deploy.yml, against `preview`, before prd. +permissions: + contents: read + +on: + pull_request: + +concurrency: + group: specs-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + specs: + name: Browser specs + # six browsers at once (test/playwright.config.ts `workers`) + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 30 + env: + # the head pkg.pr.new published, not the merge commit a pull request's run checks out + PUBLISHED_PACKAGE_COMMIT: ${{ github.event.pull_request.head.sha }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - name: Setup + uses: ./.depot/actions/setup + - name: Browser + run: pnpm --dir test exec playwright install --only-shell chromium + - name: Deploy the pull request's Docs + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + run: pnpm --dir packages/docs-app run-script deploy --env pr${{ github.event.pull_request.number }} + - name: Specs + env: + DOPPLER_TOKEN: ${{ secrets.DOPPLER_TOKEN }} + WORKER_BASE_URL: https://os.iterate-dev-preview.workers.dev + DOCS_BASE_URL: https://docs-pr${{ github.event.pull_request.number }}.iterate-dev-preview.workers.dev + run: doppler run --project os --config preview -- pnpm spec + - name: Upload the report + if: ${{ failure() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: test-results/ + retention-days: 14 diff --git a/.gitignore b/.gitignore index 68d7900..b6b61b6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,9 @@ dist __pycache__/ +# what a browser spec run leaves (test/playwright.config.ts) +test-results/ + # ad-hoc ignored files/paths *ignoreme* *.log diff --git a/AGENTS.md b/AGENTS.md index 69f7ece..8a46ce2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,9 @@ [README.md](README.md) first, then the package's own README. `packages/docs/AGENTS.md` is for agents working on a project's docs through the platform, not on this repo. -- Before pushing: `pnpm typecheck && pnpm lint && pnpm format && pnpm test`. +- Before pushing: `pnpm typecheck && pnpm lint && pnpm format && pnpm test`. The browser specs + (`pnpm spec`, `test/playwright/docs`) need a deployed Docs: `test/playwright.config.ts` says how. +- `test/helpers/` is iterate/private's spec harness, copied: keep it close to the original. - Never hand-edit `packages/docs-app/src/routeTree.gen.ts`: `pnpm --dir packages/docs-app routes:generate`. - `envs.ts` owns the deployments; Doppler project `_shared` holds the deploy credentials. Main deploys itself (`.depot/workflows/deploy.yml`). Workers are never deleted. diff --git a/README.md b/README.md index 366f83f..65ef46c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ pnpm typecheck pnpm lint pnpm format pnpm test +pnpm spec # the browser specs (test/playwright.config.ts says against what) ``` ## Releases and deploys @@ -22,9 +23,11 @@ pnpm test - **npm**: every main commit whose packed package changed publishes `-main.-` under the `main` and `latest` dist-tags (`.github/workflows/npm-publish.yml`, `scripts/ci/npm-publish.ts`). -- **The app**: main deploys `preview` (docs.iterate-dev-preview.workers.dev), then `prd` - (docs.iterate.workers.dev), both in `envs.ts` (`.depot/workflows/deploy.yml`). A red run pages - #error-pulse. +- **The app**: main deploys `preview` (docs.iterate-dev-preview.workers.dev), runs the browser + specs against it, then deploys `prd` (docs.iterate.workers.dev), all in `envs.ts` + (`.depot/workflows/deploy.yml`). A red run pages #error-pulse. +- **A pull request** gets its own Worker, `docs-pr`, and the browser specs against it, on main on + dev's platform (`.depot/workflows/specs.yml`); it's deleted when the pull request closes. CI runs on [Depot](https://depot.dev) (`.depot/workflows/`); its one secret is the org's `DOPPLER_TOKEN`. pkg.pr.new and npm run on GitHub Actions, which their GitHub App and trusted diff --git a/envs.ts b/envs.ts index f442968..6d6e14a 100644 --- a/envs.ts +++ b/envs.ts @@ -60,11 +60,27 @@ export const docsEnvs = { }, } satisfies Record; +/** A PULL REQUEST'S OWN DOCS, `pr`: `docs-pr` on the dev/preview account, linked as `preview` + * is, which its CI deploys and runs the browser specs against (.depot/workflows/specs.yml) and + * deletes once it closes (.depot/workflows/pull-request-closed.yml). It installs the pull + * request's own @iterate-com/docs build (vite.config.ts `pkgPrNewRef`). Undefined for any other + * name. */ +export function pullRequestEnv(name: string): DocsEnv | undefined { + const number = /^pr(\d+)$/.exec(name)?.[1]; + if (!number) return undefined; + const workerName = `docs-pr${number}`; + return { + ...docsEnvs.preview, + workerName, + baseUrl: `https://${workerName}.iterate-dev-preview.workers.dev`, + }; +} + export function getEnv(name: string): DocsEnv { - const env = (docsEnvs as Record)[name]; + const env = (docsEnvs as Record)[name] || pullRequestEnv(name); if (!env) throw new Error( - `unknown env ${JSON.stringify(name)}; known envs: ${Object.keys(docsEnvs).join(", ")}`, + `unknown env ${JSON.stringify(name)}; known envs: ${Object.keys(docsEnvs).join(", ")}, pr`, ); return env; } diff --git a/package.json b/package.json index 78b2ba7..c7c4b5a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "lint": "oxlint . --deny-warnings --report-unused-disable-directives-severity error", "format": "oxfmt", "format:check": "oxfmt --check", - "test": "pnpm -r --parallel test && vitest run --root scripts" + "test": "pnpm -r --parallel test && vitest run --root scripts", + "spec": "pnpm --dir test exec playwright test" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/docs-app/README.md b/packages/docs-app/README.md index 9705dcf..939812d 100644 --- a/packages/docs-app/README.md +++ b/packages/docs-app/README.md @@ -69,6 +69,14 @@ Under subdomains the tunnel's host is an origin of its own, and plain `pnpm dev` dev signs in against prd's platform; a gitignored `.dev.vars` names another (`ITERATE_APP__URLS__OS=http://localhost:8788`). -Deploy: main deploys `preview`, then `prd` (`.depot/workflows/deploy.yml`). By hand, +The browser specs are the root's `test/playwright/docs`: CI runs them against each pull request's +own Worker, and against `preview` before main deploys prd. By hand, against any Docs Worker: + +```sh +doppler run --project os --config preview -- env WORKER_BASE_URL=https://os.iterate-dev-preview.workers.dev \ + DOCS_BASE_URL=https://docs-pr12.iterate-dev-preview.workers.dev pnpm spec +``` + +Deploy: main deploys `preview`, then `prd` once the specs pass (`.depot/workflows/deploy.yml`). By hand, `pnpm --dir packages/docs-app run deploy --env prd`. Docs has no secrets, so it deploys with the Cloudflare token in Doppler's `_shared` project ([scripts/app.ts](scripts/app.ts)). diff --git a/packages/docs-app/package.json b/packages/docs-app/package.json index 45a1884..64def04 100644 --- a/packages/docs-app/package.json +++ b/packages/docs-app/package.json @@ -11,7 +11,8 @@ "typecheck": "pnpm routes:check && tsc --noEmit", "deploy": "node scripts/app.ts deploy", "routes:generate": "node scripts/app.ts generate-route-tree", - "routes:check": "node scripts/app.ts generate-route-tree --check" + "routes:check": "node scripts/app.ts generate-route-tree --check", + "delete-pull-request": "node scripts/app.ts delete-pull-request" }, "dependencies": { "@atomic-editor/editor": "0.6.2", diff --git a/packages/docs-app/scripts/app.ts b/packages/docs-app/scripts/app.ts index 8919b90..d93a874 100644 --- a/packages/docs-app/scripts/app.ts +++ b/packages/docs-app/scripts/app.ts @@ -2,6 +2,9 @@ * The Docs app's command line, behind its package scripts: * * deploy --env vite build for that env → wrangler deploy → /healthz smoke + * delete-pull-request --pr + * delete a closed pull request's own Worker, `docs-pr` (envs.ts + * `pullRequestEnv`); no other Worker is ever deleted * generate-route-tree regenerate src/routeTree.gen.ts outside `vite dev`/`vite build`; * `--check` fails (and restores the file) when the checked-in tree is stale * @@ -16,7 +19,7 @@ import { fileURLToPath } from "node:url"; import { Generator, getConfig } from "@tanstack/router-generator"; import { createCli, t } from "trpc-cli"; import { z } from "zod"; -import { getEnv } from "../../../envs.ts"; +import { getEnv, pullRequestEnv } from "../../../envs.ts"; const root = fileURLToPath(new URL("..", import.meta.url)); @@ -39,10 +42,7 @@ async function deploy(options: { env: string }) { console.log( `Deploying Docs to ${options.env} (worker ${env.workerName}, account ${env.cloudflareAccountId})`, ); - const token = execFileSync("doppler", ["secrets", "get", "CLOUDFLARE_API_TOKEN", "--plain"], { - encoding: "utf8", - env: { ...process.env, DOPPLER_PROJECT: "_shared", DOPPLER_CONFIG: env.dopplerConfig }, - }).trim(); + const token = cloudflareToken(env.dopplerConfig); rmSync(path.join(root, "dist"), { recursive: true, force: true }); run("pnpm", ["exec", "vite", "build"], { CLOUDFLARE_ENV: options.env }); const credentials = { @@ -73,6 +73,37 @@ async function deploy(options: { env: string }) { console.log(`✅ ${options.env} deployed and serving at ${env.baseUrl}`); } +/** Deletes pull request `pr`'s own Worker, which its CI deployed (envs.ts `pullRequestEnv`). One + * already gone (a pull request whose CI never deployed) is no error. `force=true` because the + * Worker owns a Durable Object class (BrowserSession), and Cloudflare refuses to delete such a + * script without it; the class's storage goes with the Worker, as it should here. */ +async function deletePullRequest(options: { pr: number }) { + const env = pullRequestEnv(`pr${options.pr}`)!; + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${env.cloudflareAccountId}/workers/scripts/${env.workerName}?force=true`, + { + method: "DELETE", + headers: { authorization: `Bearer ${cloudflareToken(env.dopplerConfig)}` }, + signal: AbortSignal.timeout(30_000), + }, + ); + if (response.status === 404) return console.log(`${env.workerName} is already gone`); + if (!response.ok) + throw new Error( + `deleting ${env.workerName} answered ${response.status}: ${await response.text()}`, + ); + console.log(`deleted ${env.workerName}`); +} + +/** The Cloudflare token every iterate app deploys with, Doppler `_shared`'s, for one of its configs. + * Doppler's CLI authenticates with DOPPLER_TOKEN in CI and the developer's login elsewhere. */ +function cloudflareToken(dopplerConfig: string) { + return execFileSync("doppler", ["secrets", "get", "CLOUDFLARE_API_TOKEN", "--plain"], { + encoding: "utf8", + env: { ...process.env, DOPPLER_PROJECT: "_shared", DOPPLER_CONFIG: dopplerConfig }, + }).trim(); +} + /** Probes `url` until it answers 200: a new version takes a few seconds to reach every colo. */ async function smoke(url: string) { for (let attempt = 1; attempt <= 18; attempt++) { @@ -167,6 +198,9 @@ if (import.meta.main) deploy: t.procedure .input(z.object({ env: z.string().describe("Target environment name from envs.ts") })) .handler(({ input }) => deploy(input)), + deletePullRequest: t.procedure + .input(z.object({ pr: z.number().int().positive().describe("The pull request's number") })) + .handler(({ input }) => deletePullRequest(input)), generateRouteTree: t.procedure .input( z.object({ diff --git a/packages/docs-app/vite.config.ts b/packages/docs-app/vite.config.ts index c477ff4..53e0406 100644 --- a/packages/docs-app/vite.config.ts +++ b/packages/docs-app/vite.config.ts @@ -7,7 +7,7 @@ import { COMPATIBILITY_DATE } from "iterate/compatibility-date"; import { defineConfig } from "vite"; import type { z } from "zod"; import type { StartAppConfig } from "./src/app/start-app-config.ts"; -import { DENY_ZONES, docsEnvs, getEnv } from "../../envs.ts"; +import { DENY_ZONES, docsEnvs, getEnv, pullRequestEnv } from "../../envs.ts"; import { routeTreeStyle } from "./scripts/app.ts"; export default defineConfig({ @@ -52,7 +52,7 @@ function workerConfig(envName: string | undefined) { urls: { ...linked.urls, docs: linked.baseUrl }, denyZones: DENY_ZONES, ...(env?.posthogProjectKey && { posthogProjectKey: env.posthogProjectKey }), - pkgPrNewRef: "main", + pkgPrNewRef: envName && pullRequestEnv(envName) ? pullRequestCommit() : "main", } satisfies z.input; return { name: env?.workerName || "docs", @@ -89,3 +89,14 @@ function workerConfig(envName: string | undefined) { ...(env && { account_id: env.cloudflareAccountId, workers_dev: true }), }; } + +/** The commit whose @iterate-com/docs a pull request's Docs installs (`PUBLISHED_PACKAGE_COMMIT`, + * its head, which pkg.pr.new publishes; .depot/workflows/specs.yml sets it). */ +function pullRequestCommit() { + const commit = process.env.PUBLISHED_PACKAGE_COMMIT; + if (!commit || !/^[0-9a-f]{40}$/.test(commit)) + throw new Error( + `a pull request's Docs installs its head's build: PUBLISHED_PACKAGE_COMMIT is ${JSON.stringify(commit)}, not a commit's 40 hex digits`, + ); + return commit; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 289da29..47c4feb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,36 @@ importers: specifier: 4.157.0 version: 4.157.0(@cloudflare/workers-types@5.20261104.1)(@types/node@24.19.2) + test: + devDependencies: + '@iterate-com/docs': + specifier: workspace:* + version: link:../packages/docs + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 + '@types/node': + specifier: 'catalog:' + version: 24.19.2 + capnweb: + specifier: npm:@iterate-com/capnweb@0.13.0 + version: '@iterate-com/capnweb@0.13.0' + iterate: + specifier: 'catalog:' + version: 0.4.1-main.20261105T063432Z-a81a857(react@19.3.1) + middlewright: + specifier: https://pkg.pr.new/middlewright@20eacef + version: https://pkg.pr.new/middlewright@20eacef(@playwright/test@1.63.0) + playwright: + specifier: 1.63.0 + version: 1.63.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 + zod: + specifier: 'catalog:' + version: 4.6.5(patch_hash=5a57d5a4f788c6fe9e15d60e59511c1f901b7fc1d638126eb2b02cafc9e79a86) + packages: '@atomic-editor/editor@0.6.2': @@ -1480,6 +1510,11 @@ packages: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -2605,6 +2640,10 @@ packages: electron-to-chromium@1.5.475: resolution: {integrity: sha512-S9tceHJbIsHH8VRwh9x+8lJO50SYwTdwVGdEjdz2FERyP59j1N6rdXOSklIusrhxt9GG/WN4V/kRgmDHl9MM5w==} + emittery@1.2.1: + resolution: {integrity: sha512-sFz64DCRjirhwHLxofFqxYQm6DCp6o0Ix7jwKQvuCHPn4GMRZNuBZyLPu9Ccmk/QSCAMZt6FOUqA8JZCQvA9fw==} + engines: {node: '>=14.16'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -3167,6 +3206,13 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + middlewright@https://pkg.pr.new/middlewright@20eacef: + resolution: {tarball: https://pkg.pr.new/middlewright@20eacef} + version: 0.1.0 + engines: {node: '>=20'} + peerDependencies: + '@playwright/test': '>=1.49.0' + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} @@ -3387,6 +3433,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss-selector-parser@7.1.7: resolution: {integrity: sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==} engines: {node: '>=4'} @@ -5231,6 +5287,10 @@ snapshots: - '@shikijs/themes' - shiki + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -6188,6 +6248,8 @@ snapshots: electron-to-chromium@1.5.475: {} + emittery@1.2.1: {} + emoji-regex@10.6.0: {} empathic@2.1.0: {} @@ -6702,6 +6764,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + middlewright@https://pkg.pr.new/middlewright@20eacef(@playwright/test@1.63.0): + dependencies: + '@playwright/test': 1.63.0 + dedent: 1.7.2 + emittery: 1.2.1 + transitivePeerDependencies: + - babel-plugin-macros + mime-db@1.54.0: {} mime-types@3.0.2: @@ -6926,6 +6996,12 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss-selector-parser@7.1.7: dependencies: cssesc: 3.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 682f4f4..9caf38a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,6 @@ packages: - packages/* + - test # Every dependency both packages use is pinned here once and written `catalog:` in each package.json, # as in iterate/private, whose catalog these versions are copied from; a dependency only one package # uses keeps its version in that package.json. diff --git a/test/helpers/auth-config.ts b/test/helpers/auth-config.ts new file mode 100644 index 0000000..52be466 --- /dev/null +++ b/test/helpers/auth-config.ts @@ -0,0 +1,39 @@ +// Copied from iterate/private's test/helpers/auth-config.ts at a541c9d, with Docs' specs. +// Unchanged. + +import type { IngressRouting } from "iterate/project-ingress"; +import { z } from "zod"; + +export const OsPlaywrightAuthEnv = z.object({ + /** OS admin bearer (`adminBearer`) used to create fixture projects through /api. */ + ITERATE_ADMIN_BEARER: z.string().min(1), + /** The sign-in page's password step (`login.methods.password`), how fixtures sign a person in. */ + LOGIN_PASSWORD: z.string().min(1), + /** How the deployment addresses a project: `.`, a path, or not at all. */ + PROJECT_INGRESS_ROUTING: z.string().refine((value) => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } + }, "must be the deployment's ingress routing as a JSON string"), + /** The MCP resource the OAuth specs request tokens for. */ + MCP_BASE_URL: z.url(), + /** The key the deployment seals project secrets under, which a backup's sealed cells open with. */ + SECRETS_KEY: z.string().min(1), +}); + +// Suite setup supplies these values before Playwright creates its workers. +// Fixtures only read prepared configuration; they never fetch secrets. +export function readOsPlaywrightAuthConfig() { + const env = OsPlaywrightAuthEnv.parse(process.env); + return { + adminBearer: env.ITERATE_ADMIN_BEARER, + loginPassword: env.LOGIN_PASSWORD, + // the deployment's own `urls.ingressRouting`, as playwright/global-setup.ts read it (deployed-target.ts) + ingressRouting: JSON.parse(env.PROJECT_INGRESS_ROUTING) as IngressRouting, + mcpBaseUrl: env.MCP_BASE_URL, + secretsKey: env.SECRETS_KEY, + }; +} diff --git a/test/helpers/fake-ai.ts b/test/helpers/fake-ai.ts new file mode 100644 index 0000000..9adf832 --- /dev/null +++ b/test/helpers/fake-ai.ts @@ -0,0 +1,285 @@ +// Copied from iterate/private's test/helpers/fake-ai.ts at a541c9d, with Docs' specs. +// Unchanged. + +// helpers/fake-ai.ts — the one fake `itx.ai`: Cloudflare's Workers AI binding played by the test, +// what a row lends in its place (`provide("itx.ai", new FakeAi())`) so a run never pays for a model +// (docs/testing.md#real-model-rows). It answers a call only with a reply the test provided for it +// (`provideReply`), so a test reads in the order its model is asked. +// +// Typed scripts follow `ItxScriptBuilder` (iterate/iterate, apps/os/e2e/test-support/ +// itx-script-builder.ts): the function's own source, wrapped so its `values` arrive as JSON, and +// refused when the test's transform left a helper in it. +import { RpcTarget } from "capnweb"; +import type { IterateContextApi } from "iterate/api"; +import { parseCodemodeResponse } from "iterate/agents/codemode-format"; + +/** One `run(model, inputs, options)` as the fake received it. `messages` is typed because chat rows + * read it; the Responses API's `input`, or a bare `prompt`, arrive instead, through the index. */ +export type FakeAiCall = { + model: string; + inputs: { messages: { role: string; content: string }[]; [field: string]: any }; + options?: unknown; +}; + +/** What a provided reply is matched against: the call's newest input, as JSON (`newestInput`). A + * RegExp is one; so is what `thenReply` matches a script's output with. `toString` names it in a + * timeout's error. */ +type CallMatch = { test(newestInput: string): boolean; toString(): string }; + +/** The `itx` a script gets: the platform's API, and whatever the project added (`itx.agents`, a + * test's own `itx.go`), untyped. */ +type ScriptItx = IterateContextApi & { [name: string]: any }; + +/** An answer in code, as a model gives one: `status` (what the agent shows while it runs), prose + * beside it, and `script`, which runs in the agent's sandbox. The script sees nothing of the test + * but `values`, which reach it as JSON (unicorn-js/isolated-functions holds it to that). What it + * returns, `Result`, is what a function given to `thenReply` gets. */ +export type ScriptReply, Result> = { + status: string; + prose?: string; + values?: Values; + script: (itx: ScriptItx, values: Values) => Promise; +}; + +/** An answer in itself: text, which Workers AI answers as `{ response }` and an `openai/…` partner + * model as the Responses API's stream (a `` block in it is the model's script, for rows + * about malformed answers); a script; the stream's own frames (`sseResponse`); or an Error, thrown. */ +type PlainReply = string | ScriptReply | unknown[] | Error; + +/** An answer: a plain one, or a function of the call returning what the binding would (an object, + * a `Response`). */ +export type FakeAiReply = PlainReply | ((call: FakeAiCall) => unknown); + +/** The binding, recording every call. A test provides each reply just after what asks for it (the + * person's words), so it reads in the order things happen. A call takes the first provided reply + * that matches it; with none, it waits `replyTimeoutMs` (a second) for one and then throws, naming + * what it was asked. A test that wants the model busy provides a stream held on a promise, not no + * reply. */ +export class FakeAi extends RpcTarget { + readonly calls: FakeAiCall[] = []; + readonly #replyTimeoutMs: number; + readonly #provided: ProvidedReply[] = []; + /** Settles when a reply is provided, so the calls waiting look again. */ + #replyProvided = Promise.withResolvers(); + constructor({ replyTimeoutMs = 1000 }: { replyTimeoutMs?: number } = {}) { + super(); + this.#replyTimeoutMs = replyTimeoutMs; + } + + /** Answer the next call whose newest input matches: the person's words, an interrupt, or a + * script's output, never the instructions and history before them. `thenReply` on what it + * returns answers the call that follows its script. */ + provideReply, Result>( + match: CallMatch, + reply: ScriptReply, + ): ProvidedReply; + /** A reply whose script, if it has one, is text: what that returns is `any` to `thenReply`. */ + provideReply(match: CallMatch, reply: FakeAiReply): ProvidedReply; + provideReply(match: CallMatch, reply: FakeAiReply) { + return this.#provide(match, () => reply); + } + + /** Queue a reply, given by `replyFor` once a call has taken it. */ + #provide(match: CallMatch, replyFor: (call: FakeAiCall) => FakeAiReply): ProvidedReply { + const provided = new ProvidedReply(match, replyFor, (next, nextFor) => + this.#provide(next, nextFor), + ); + this.#provided.push(provided); + this.#replyProvided.resolve(); + this.#replyProvided = Promise.withResolvers(); + return provided; + } + + async run(model: string, inputs: FakeAiCall["inputs"], options?: unknown) { + const call = { model, inputs, options }; + this.calls.push(call); + const callId = `call_${String(this.calls.length)}`; + const provided = await this.#take(call); + provided.callId = callId; + const reply = provided.replyFor(call); + if (reply instanceof Error) throw reply; + if (Array.isArray(reply)) return sseResponse(reply); + const answer = + typeof reply === "string" + ? { response: reply } + : typeof reply === "function" + ? await reply(call) + : { response: codemodeText(reply) }; + const text = (answer as { response?: unknown } | null)?.response; + return model.startsWith("openai/") && typeof text === "string" + ? responsesAnswer(text, callId) + : answer; + } + + async #take(call: FakeAiCall): Promise> { + const newest = newestInput(call); + const deadline = Date.now() + this.#replyTimeoutMs; + for (;;) { + const index = this.#provided.findIndex(({ match }) => match.test(newest)); + if (index >= 0) return this.#provided.splice(index, 1)[0]!; + const left = deadline - Date.now(); + if (left <= 0) + throw new Error( + `FakeAi: no reply provided within ${String(this.#replyTimeoutMs)} ms for a call whose newest input is ${newest}. Provided and not taken: ${this.#provided.map(({ match }) => String(match)).join(", ") || "none"}`, + ); + let timer: ReturnType | undefined; + await Promise.race([ + this.#replyProvided.promise, + new Promise((resolve) => (timer = setTimeout(resolve, left))), + ]); + clearTimeout(timer); + } + } +} + +/** A reply the test provided, and the call id its script got once a call took it. `Result` is what + * its script returns. */ +class ProvidedReply { + readonly match: CallMatch; + /** The reply, given the call that took it (a `thenReply` function reads the script's result off + * it). */ + readonly replyFor: (call: FakeAiCall) => FakeAiReply; + callId: string | undefined; + readonly #provide: ( + match: CallMatch, + replyFor: (call: FakeAiCall) => FakeAiReply, + ) => ProvidedReply; + constructor( + match: CallMatch, + replyFor: (call: FakeAiCall) => FakeAiReply, + provide: (match: CallMatch, replyFor: (call: FakeAiCall) => FakeAiReply) => ProvidedReply, + ) { + this.match = match; + this.replyFor = replyFor; + this.#provide = provide; + } + + /** Answer the call that shows the model what this reply's script returned: with a reply, or with + * a function of what it returned (typed as the script's return) that gives one. A script that + * failed returned nothing: the function is skipped and the answer is the script's error, so a + * row's check on the answer names it. */ + thenReply, NextResult>( + reply: ScriptReply, + ): ProvidedReply; + thenReply(reply: PlainReply | ((result: Result) => PlainReply)): ProvidedReply; + thenReply(reply: PlainReply | ((result: Result) => PlainReply)) { + return this.#provide(this.#output, (call) => + typeof reply === "function" ? replyToResult(call, reply) : reply, + ); + } + + /** Matches the call whose newest input is this reply's script output, by the call id it got. */ + readonly #output: CallMatch = { + test: (newest) => { + const item = newest ? JSON.parse(newest) : null; + return item?.type === "function_call_output" && item.call_id === this.callId; + }, + toString: () => `the output of the script answering ${String(this.match)}`, + }; +} + +/** A call's newest input, as JSON: the last item of the Responses API's `input` (or the last chat + * message) that isn't the platform's own developer or system message, such as the time the agent + * made the request; or the bare prompt; or nothing, for a call with no inputs at all. */ +function newestInput(call: FakeAiCall) { + const { input, messages, prompt } = call.inputs || {}; + const items: any[] = Array.isArray(input) ? input : messages || []; + return ( + JSON.stringify( + items.findLast((item) => item.role !== "developer" && item.role !== "system") || + input || + prompt, + ) || "" + ); +} + +/** What a `thenReply` function replies for `call`, given what the script returned: read off how the + * agent showed it to the model (core/lib/src/agents/result-render.ts), a string from its plain + * fence, anything else from its JSON fence. A script that failed is answered with its error. */ +function replyToResult(call: FakeAiCall, reply: (result: any) => PlainReply): PlainReply { + const output = String(JSON.parse(newestInput(call)).output); + if (output.startsWith("Your script failed")) + return `FakeAi: the script failed, so there is no result to reply to.\n${output}`; + const fenced = /^Your script returned(?: \([^)]*\))?:\n```(json)?\n([^]*?)\n```/.exec(output); + if (!fenced) throw new Error(`FakeAi: no result to read in the script's output:\n${output}`); + return reply(fenced[1] ? JSON.parse(fenced[2]!) : fenced[2]); +} + +/** What a test's transform can leave in a function's source that the agent's sandbox doesn't + * have: Vite's import bindings, Oxc's and esbuild's spellings of `using` and async helpers. */ +const TRANSFORM_LEFTOVERS = [ + /(?` block, the script being + * the test's own function called with its values. */ +function codemodeText({ status, prose, values, script }: ScriptReply) { + const source = script.toString(); + if (!/^async\s*\([^]*?\)\s*=>\s*\{[^]*\}$/.test(source)) + throw new Error( + `FakeAi: a script is an async arrow function with a block body, taking (itx) or (itx, values). Got:\n${source}`, + ); + const leftover = TRANSFORM_LEFTOVERS.find((marker) => marker.test(source)); + if (leftover) + throw new Error( + `FakeAi: the script holds ${String(leftover)}, a helper the test's transform added, which the agent's sandbox doesn't have. Got:\n${source}`, + ); + const code = `async (itx) => {\n return await (${source})(itx, ${JSON.stringify(values || {})});\n}`; + return `${prose ? `${prose}\n` : ""}\n${code}\n`; +} + +/** A streamed answer: each frame one Server-Sent Event's `data:` line, in order. A promise among the + * frames holds the stream until it settles; one that never settles never ends it. */ +function sseStream(frames: unknown[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + for (const frame of frames) { + if (frame instanceof Promise) await frame; + else controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`)); + } + controller.close(); + }, + }); +} + +/** The model's text as the agent loop's model answers it through the Responses API: the prose as a + * message, and a `` block as a call of the one tool, `run({ status, script })` + * (core/lib/src/agents/render.ts `RUN_TOOL`), with `callId`. */ +function responsesAnswer(text: string, callId: string) { + const parsed = parseCodemodeResponse(text); + const prose = parsed.kind === "script" || parsed.kind === "none" ? parsed.prose?.trim() : text; + const output = [ + ...(prose ? [messageItem(prose)] : []), + ...(parsed.kind === "script" + ? [ + { + type: "function_call", + call_id: callId, + name: "run", + arguments: JSON.stringify({ status: parsed.status || "", script: parsed.code }), + }, + ] + : []), + ]; + return sseResponse([ + ...(prose ? [{ type: "response.output_text.delta", delta: prose }] : []), + { + type: "response.completed", + response: { output, usage: { input_tokens: 10, output_tokens: 5 } }, + }, + ]); +} + +/** The Responses API's output item for prose, as `response.completed` lists it: the agent loop reads + * its answer from the items (core/lib/src/agents/processor.ts `#stream`), the deltas only stream. */ +export function messageItem(text: string) { + return { type: "message", role: "assistant", content: [{ type: "output_text", text }] }; +} + +/** `sseStream(frames)` as a partner model's (the Responses API's) HTTP answer. */ +export function sseResponse(frames: unknown[]) { + return new Response(sseStream(frames), { headers: { "content-type": "text/event-stream" } }); +} diff --git a/test/helpers/fixture-slug.ts b/test/helpers/fixture-slug.ts new file mode 100644 index 0000000..727d676 --- /dev/null +++ b/test/helpers/fixture-slug.ts @@ -0,0 +1,19 @@ +// Copied from iterate/private's packages/shared/src/test-support/fixture-slug.ts at a541c9d, unchanged. + +/** + * The naming convention for disposable test fixtures (projects, orgs) minted + * by the Playwright suite's fixtures (test/helpers/forged-session.ts): + * `--`. + * + * Project slugs become DNS labels (`.localhost`, a project host), so + * the result is lowercased, double dashes are collapsed, and callers that + * take arbitrary prefixes should pass `maxPrefixLength` to stay under label + * length limits. The timestamp keeps leaked fixtures attributable/sortable: + * there is no `projects.remove` on itx yet, so disposal is a no-op and stages + * accumulate fixtures until reset. + */ +export function uniqueFixtureSlug(prefix: string, opts?: { maxPrefixLength?: number }): string { + return `${prefix.slice(0, opts?.maxPrefixLength)}-${Date.now().toString(36)}-${crypto.randomUUID().slice(0, 8)}` + .toLowerCase() + .replace(/-{2,}/g, "-"); +} diff --git a/test/helpers/forged-session.ts b/test/helpers/forged-session.ts new file mode 100644 index 0000000..1ba6cb8 --- /dev/null +++ b/test/helpers/forged-session.ts @@ -0,0 +1,142 @@ +// Copied from iterate/private's test/helpers/forged-session.ts at a541c9d, with Docs' specs. +// Only the fixture-slug import changed. + +import { expect, test, type Page } from "@playwright/test"; +import { uniqueFixtureSlug } from "./fixture-slug.ts"; +import type { VideoModePageExtension } from "middlewright"; +import type { OperatorSession } from "./operator.ts"; +import { readOsPlaywrightAuthConfig } from "./auth-config.ts"; +import { workerBaseUrl } from "./worker-base-url.ts"; + +/** + * A browser signed in as a fresh person who owns a fresh project, without driving the sign-in page + * or the consent flow — for specs whose subject is something else. `itx` is the project's root + * context as the operator, for seeding state the spec does not drive through the UI. With `app`, + * that person is also signed in to the client app and on its page for the project (`signInToApp`). + * Dispose with `await using fixture = await helpers.createFixture(...)`. + */ +export async function createProjectFixture( + slugPrefix: string, + input: { + page: Page; + operator: OperatorSession; + /** A client app (any URL on its origin) to sign in to and open on the project's page. */ + app?: string; + /** The preset the project starts from, by its label in the dash: Default, the dash's own + * choice, when omitted. */ + preset?: string; + }, +) { + const slug = uniqueFixtureSlug(slugPrefix); + const email = `forged-${slug}@example.com`; + await mintIterateSession({ email, page: input.page }); + const project = await createOwnedProject({ + operator: input.operator, + email, + slug, + preset: input.preset || "Default", + }); + if (input.app) await signInToApp({ page: input.page, app: input.app, project }); + + return { + project, + /** The fixture's person, for a second project of theirs (`operator.authenticate({ email })`). */ + email, + itx: input.operator.authenticate().projects.get(project.id), + [Symbol.asyncDispose]() { + // A fixture's project is left behind (a spec that deletes one does so itself): a preview's + // state goes with the preview, and a failed spec's project stays to be read. + return Promise.resolve(); + }, + }; +} + +/** + * The fixture's person signed in to a client app and on its page for `project`: the app's own + * sign-in route (`/.auth/login`), which the issuer answers with consent because the person is signed + * in there already, then Authorize. A demo video (VIDEO_MODE=1) starts on the app's page, not on + * consent. Signing in to an app, and ending its session, is itself the subject of + * test/playwright/notes/sessions.spec.ts, and consent of test/playwright/os/auth.spec.ts. + */ +async function signInToApp(input: { page: Page; app: string; project: { slug: string } }) { + const { page, project } = input; + const origin = new URL(input.app).origin; + const next = `/projects/${project.slug}`; + await test.step("sign in to the app", async () => { + await page.goto(`${origin}/.auth/login?${new URLSearchParams({ next })}`); + // noWaitAfter: Authorize posts and the issuer hands the browser back to the app; the wait + // below covers that navigation (the spinner-waiter counts one in flight as loading) + await page.getByRole("button", { name: "Authorize", exact: true }).click({ noWaitAfter: true }); + // every client app's shell (packages/ui app-shell.tsx) names the active project in its switcher; + // below 768 px (use-mobile.ts) the switcher is in the sidebar sheet, so there the header's + // sidebar trigger says the shell is up, and the landed path below names the project + const phone = (page.viewportSize()?.width ?? 0) < 768; + await ( + phone + ? page.getByRole("button", { name: "Toggle sidebar" }) + : page.getByRole("button", { name: "Switch project" }).filter({ hasText: project.slug }) + ).waitFor(); + }); + const landed = new URL(page.url()); + expect({ origin: landed.origin, pathname: landed.pathname }).toEqual({ origin, pathname: next }); + // the harness leaves this file's clicks unhighlighted (test.ts `skipStackFrames`): the renderer + // would pull the start back to a highlighted consent click + (page as Page & Partial).videoMode?.setStartTime(); +} + +/** A browser signed in as a fresh person with no project yet — the consent page's onboarding + * step, without driving the sign-in page. */ +export function createSessionFixture(slugPrefix: string, input: { page: Page }) { + return mintIterateSession({ + email: `forged-${uniqueFixtureSlug(slugPrefix)}@example.com`, + page: input.page, + }); +} + +/** + * The fixture's browser session. OS has no token to forge — a browser session is an OAuth grant + * the issuer makes on sign-in — so this posts the deployment's test password the way the sign-in + * page does (`login.password`), and the issuer's session cookie lands in the page's browser + * context. The sign-in page itself is the subject of test/playwright/os/issuer-pages.spec.ts. + */ +export async function mintIterateSession(input: { email: string; page: Page }) { + const { loginPassword } = readOsPlaywrightAuthConfig(); + return test.step("sign in with the deployment's test password", async () => { + // the OS platform, whichever app host the spec's project targets + const login = await input.page.request.post(`${workerBaseUrl}/login`, { + headers: { Origin: workerBaseUrl }, + form: { email: input.email, password: loginPassword, next: "/" }, + maxRedirects: 0, + // A page.request call inherits the tight actionTimeout, but this is fixture setup over HTTP + // (the sign-in makes an OAuth grant). timeout: no loading UI exists for the spinner-waiter + timeout: 15_000, + }); + // A sign-in the platform failed answers 303 back to the sign-in page, its error in the query + // (core/os src/issuer-session.ts): the location is the failure's own words. + if (login.status() !== 302) + throw new Error( + `sign-in answered ${login.status()} to ${login.headers().location ?? "no location"}: ${await login.text()}`, + ); + }); +} + +async function createOwnedProject(input: { + operator: OperatorSession; + email: string; + slug: string; + preset: string; +}) { + // create() answers once the project's creation is requested, before its saga lands + // `project/created`: an app's page may open on a project still being created. + return test.step("create project fixture over /api", async () => { + // Created as that person, so it lands in an organization they own; its minted id is how a + // project is addressed — the slug only labels its hosts. With the Default preset (the agents + // app) it is what the dash creates for a person who picks no template. + const projects = input.operator.authenticate({ email: input.email }).projects; + const preset = (await projects.templates()).find(({ label }) => label === input.preset); + const { projectId } = await projects + .create({ project: input.slug, configRepoTemplate: preset?.reference }) + .whoami(); + return { id: projectId, slug: input.slug }; + }); +} diff --git a/test/helpers/operator.ts b/test/helpers/operator.ts new file mode 100644 index 0000000..b194d13 --- /dev/null +++ b/test/helpers/operator.ts @@ -0,0 +1,29 @@ +// Copied from iterate/private's test/helpers/operator.ts at a541c9d, with Docs' specs. +// Unchanged. + +import { newWebSocketRpcSession } from "capnweb"; +import type { IterateApi } from "iterate/api"; +import { readOsPlaywrightAuthConfig } from "./auth-config.ts"; +import { workerBaseUrl } from "./worker-base-url.ts"; + +/** + * The operator's capnweb session on the OS platform under test: `/api` over a WebSocket, opened + * bare and authenticated in-band with the deployment's admin bearer (`adminBearer`, which + * playwright/global-setup.ts reads out of ITERATE), the way test/helpers/client.ts opens every e2e session. + * Specs seed state through it instead of the UI where the state is not their subject. Dispose it. + */ +export function openOperatorSession() { + const { adminBearer: secret } = readOsPlaywrightAuthConfig(); + const url = new URL("/api", workerBaseUrl); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + const api = newWebSocketRpcSession(url.href); + return { + /** Every project (`{ actor: "admin" }`); with `as`, that person, found or created, with every + * scope: how a fixture makes a project the person owns (core/os/src/session.ts). */ + authenticate: (as?: { email: string }) => + api.authenticate({ type: "admin-secret", secret, as }), + [Symbol.dispose]: () => api[Symbol.dispose](), + }; +} + +export type OperatorSession = ReturnType; diff --git a/test/helpers/proxied-app-route.ts b/test/helpers/proxied-app-route.ts new file mode 100644 index 0000000..eb9b3f5 --- /dev/null +++ b/test/helpers/proxied-app-route.ts @@ -0,0 +1,31 @@ +// Copied from iterate/private's scripts/os/preview-config.ts `proxiedAppRoute` at a541c9d, with +// Docs' specs. +import type { FetchRouteInput } from "iterate/api"; + +/** THE FETCH ROUTE a project serves a proxied app with: the routing slug of the app's name, members + * only, to a loaded worker that fetches through to `appUrl`, the app's Worker, as the prd iterate + * project's `docs` route does. The project's config worker forwards a member's request to it, and + * answers anyone else the sign-in challenge. */ +export function proxiedAppRoute(app: string, appUrl: string) { + const { protocol, host } = new URL(appUrl); + const worker = [ + "export default {", + " fetch(request) {", + " const url = new URL(request.url);", + ` url.protocol = ${JSON.stringify(protocol)};`, + ` url.host = ${JSON.stringify(host)};`, + ' return fetch(new Request(url, new Request(request, { redirect: "manual" })));', + " },", + "};", + "", + ].join("\n"); + return { + requestMatcher: { routingSlug: app }, + target: [ + "itx", + "workers", + ["get", { source: { "package.json": '{"main":"worker.js"}', "worker.js": worker } }], + ], + authRequirement: { visitors: "project-members" }, + } satisfies FetchRouteInput; +} diff --git a/test/helpers/screenshot.ts b/test/helpers/screenshot.ts new file mode 100644 index 0000000..6b55541 --- /dev/null +++ b/test/helpers/screenshot.ts @@ -0,0 +1,57 @@ +// Copied from iterate/private's test/helpers/screenshot.ts at a541c9d, with Docs' specs. +// Only the slugify import changed. + +import { slugify } from "./slugify.ts"; +import type { TestInfo } from "@playwright/test"; +import type { Plugin } from "middlewright"; + +const ENVIRONMENT_VARIABLE = "PLAYWRIGHT_SCREENSHOT"; +const occurrencesByTest = new WeakMap>(); + +export const screenshot = (): Plugin => { + const matchers = parseMatchers(process.env[ENVIRONMENT_VARIABLE] || ""); + + if (matchers.length === 0 || process.env.PWDEBUG) return { name: "screenshot" }; + + return { + name: "screenshot", + middleware: async (context, next) => { + const locatorDescription = context.locator.toString(); + const matches = matchers.some((matcher) => matcher.test(locatorDescription)); + const result = await next(); + + if (matches) { + const occurrences = occurrencesByTest.get(context.testInfo) || new Map(); + occurrencesByTest.set(context.testInfo, occurrences); + const locatorSlug = slugify(locatorDescription, { maxLength: 160 }); + const occurrence = (occurrences.get(locatorSlug) || 0) + 1; + occurrences.set(locatorSlug, occurrence); + const screenshotSlug = occurrence === 1 ? locatorSlug : `${locatorSlug}-${occurrence}`; + const path = context.testInfo.outputPath(`${screenshotSlug}.png`); + // A capture after a click that starts a navigation (`noWaitAfter`) waits for the new page + // to paint, and page.screenshot defaults to the tight actionTimeout. + // timeout: the capture is the harness's, not a UI wait the spinner-waiter could extend + await context.page.screenshot({ fullPage: true, path, timeout: 15_000 }); + await context.testInfo.attach(screenshotSlug, { contentType: "image/png", path }); + } + + return result; + }, + }; +}; + +const parseMatchers = (value: string) => + value + .split(";") + .map((pattern) => pattern.trim()) + .filter(Boolean) + .map((pattern, index) => { + try { + return new RegExp(pattern); + } catch (error) { + throw new Error( + `Invalid ${ENVIRONMENT_VARIABLE} regex at position ${index + 1}: ${pattern}`, + { cause: error }, + ); + } + }); diff --git a/test/helpers/slugify.ts b/test/helpers/slugify.ts new file mode 100644 index 0000000..4d9555a --- /dev/null +++ b/test/helpers/slugify.ts @@ -0,0 +1,23 @@ +// Copied from iterate/private's packages/shared/src/slugify.ts at a541c9d, unchanged. + +export interface SlugifyOptions { + fallback?: string; + maxLength?: number; +} + +export function slugify(input: string, options: SlugifyOptions = {}) { + const fallback = options.fallback || "unnamed"; + const normalized = input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, ""); + const resolved = normalized || fallback; + + if (options.maxLength == null) { + return resolved; + } + + return resolved.slice(0, options.maxLength).replace(/-+$/g, "") || fallback; +} diff --git a/test/helpers/test.ts b/test/helpers/test.ts new file mode 100644 index 0000000..6744142 --- /dev/null +++ b/test/helpers/test.ts @@ -0,0 +1,177 @@ +// Copied from iterate/private's test/helpers/test.ts at a541c9d, with Docs' specs. +// Only the `appOrigin` app list changed: Docs is the one app here. + +import { test as base, expect, type Page, type TestInfo as _TestInfo } from "@playwright/test"; +import { + addPlugins, + hydrationWaiter, + spinnerWaiter, + uiErrorReporter, + videoMode, +} from "middlewright"; +import { + createProjectFixture, + createSessionFixture, + mintIterateSession, +} from "./forged-session.ts"; +import { openOperatorSession, type OperatorSession } from "./operator.ts"; +import { screenshot } from "./screenshot.ts"; + +const addPagePlugins = (page: Page, testInfo: _TestInfo) => { + return addPlugins({ + page, + testInfo, + plugins: [ + hydrationWaiter({ timeout: 30_000 }), + uiErrorReporter(), + // A page that has not hydrated is loading: its controls do nothing until React owns it + // (`data-hydrated`, core/os __root.tsx and packages/ui document.tsx). hydrationWaiter looks + // only when an action starts, so a page the action lands on by navigating (a client app's + // redirect to consent) counts as a spinner until it hydrates. + spinnerWaiter({ + spinnerTimeout: 30_000, + spinnerSelectors: [...spinnerWaiter.defaults.spinnerSelectors, '[data-hydrated="false"]'], + }), + screenshot(), + process.env.VIDEO_MODE === "1" && + videoMode({ + // the harness's own actions, and the fixture's sign-in to an app, are not the demo + skipStackFrames: ["helpers/test.ts", "helpers/forged-session.ts"], + deadAirThreshold: 300, + finalHold: 1, + highlight: { mode: "pointer", duration: 1000 }, + }), + ], + boxedStackPrefixes: (defaults) => [...defaults, import.meta.dirname], + }); +}; + +export const test = base.extend<{ + /** The operator's session on the platform under test (the admin bearer): every project. For + * state no fixture makes; a fixture's own project is `fixture.itx`. */ + operator: ReturnType; + helpers: { + /** A fresh person with a fresh project, signed in without driving the UI; `itx` is that + * project as the operator. With `app` (a client app's URL, usually the project's `baseURL`), + * also signed in to that app and on its page for the project; uncaught page errors then fail + * the spec. `preset` names the template it starts from by its dash label (Default if none). */ + createFixture: ( + slugPrefix: string, + options?: { app?: string; preset?: string }, + ) => Promise>>; + /** A browser signed in as a fresh person with no project, without driving the sign-in page. */ + createSession: (slugPrefix: string) => ReturnType; + /** Someone else in the fixture's organization (`-`), signed in, in a + * browser of their own: `page` has the primary page's plugins, and its uncaught errors fail + * the spec. Disposing it closes their browser. */ + createMember: ( + fixture: { email: string }, + name: string, + ) => Promise< + { email: string; page: Awaited> } & AsyncDisposable + >; + /** The origin of a client app deployed against the platform under test, from its + * `_BASE_URL`. Locally a missing app skips the spec; in CI it fails, because the + * preview's e2e job always sets the variable. */ + appOrigin: (app: "docs") => string; + }; + page: Awaited>; +}>({ + // oxlint-disable-next-line no-empty-pattern -- Playwright reads a fixture's dependencies from this pattern; the operator's session has none + operator: async ({}, use) => { + using session = openOperatorSession(); + await use(session.authenticate()); + }, + helpers: async ({ page, browser }, use, testInfo) => { + // A client app's uncaught errors fail its spec (docs/browser-testing.md: fail on page and + // hydration errors), counted from before the fixture's sign-in to the end of the test. + const appPageErrors: string[] = []; + // opened by the first createFixture, so a spec that makes none opens no socket + let operatorSession: OperatorSession | undefined; + try { + await use({ + createFixture: (slugPrefix, options) => + base.step("create project fixture", () => { + if (options?.app) page.on("pageerror", (error) => appPageErrors.push(error.message)); + operatorSession ||= openOperatorSession(); + return createProjectFixture(slugPrefix, { + page, + operator: operatorSession, + ...options, + }); + }), + createSession: (slugPrefix) => + base.step("create signed-in session", () => createSessionFixture(slugPrefix, { page })), + createMember: (fixture, name) => + base.step("create another member", async () => { + const email = `${name}-${fixture.email}`; + operatorSession ||= openOperatorSession(); + await operatorSession.authenticate().users.create({ email }); + const owner = operatorSession.authenticate({ email: fixture.email }); + const [org] = await owner.organizations.list(); + await owner.organizations.addMember(org!.id, { userId: email, role: "member" }); + const context = await browser.newContext(); + const memberPage = await addPagePlugins(await context.newPage(), testInfo); + memberPage.on("pageerror", (error) => appPageErrors.push(`${email}: ${error.message}`)); + await mintIterateSession({ email, page: memberPage }); + return { + email, + page: memberPage, + async [Symbol.asyncDispose]() { + await memberPage[Symbol.asyncDispose](); + await context.close(); + }, + }; + }), + appOrigin: (app) => { + const variable = `${app.toUpperCase()}_BASE_URL`; + const name = `${app[0]!.toUpperCase()}${app.slice(1)}`; + const url = process.env[variable]; + base.skip( + !process.env.CI && !url, + `The ${name} specs need the ${name} app deployed against the platform under test`, + ); + if (!url) throw new Error(`${variable}: the preview's ${name} app is not set`); + return new URL(url).origin; + }, + }); + } finally { + operatorSession?.[Symbol.dispose](); + } + expect(appPageErrors, "uncaught errors on the app's pages").toEqual([]); + }, + page: async ({ page: basePage }, use, testInfo) => { + // A spec that opens a second tab does so via `context.newPage()`, which + // returns a RAW page without our middlewright plugins — so its `waitFor()`s + // fall back to the config's deliberately-tight actionTimeout, far too + // short to establish a second live stream subscription (the spinner-waiter + // extends waits while a "connecting…" spinner shows; a raw page has no such + // safety net). Give every extra page the same plugins as the primary. + // `basePage` already exists here (Playwright's built-in `page` fixture + // created it via `context.newPage()` before this fixture ran), so patching + // `newPage` now only wraps pages the spec opens LATER — the primary page is + // never double-wrapped. + const context = basePage.context(); + const rawNewPage = context.newPage.bind(context); + const extraPageDisposers: Array<() => Promise> = []; + context.newPage = async () => { + const extraPage = await addPagePlugins(await rawNewPage(), testInfo); + extraPageDisposers.push(() => extraPage[Symbol.asyncDispose]()); + return extraPage; + }; + await using page = await addPagePlugins(basePage, testInfo); + try { + await use(page); + } finally { + // The spec usually closes its extra pages itself; dispose the plugin + // wrappers too (lifecycle cleanup only — a no-op with video off), + // tolerating an already-closed page. + for (const dispose of extraPageDisposers) await dispose().catch(() => {}); + } + }, +}); + +export declare namespace test { + type Page = Awaited>; + type TestInfo = _TestInfo; +} diff --git a/test/helpers/worker-base-url.ts b/test/helpers/worker-base-url.ts new file mode 100644 index 0000000..490b2e1 --- /dev/null +++ b/test/helpers/worker-base-url.ts @@ -0,0 +1,14 @@ +// Copied from iterate/private's test/helpers/worker-base-url.ts at a541c9d, with Docs' specs. +// Unchanged. + +/** The port of the local OS worker Playwright starts when `WORKER_BASE_URL` is unset. */ +export const localWorkerPort = Number(process.env.WORKER_PORT || 8788); + +/** + * The OS under test, as for the vitest e2e suite: `WORKER_BASE_URL`'s origin, else the local + * worker. The config (the `os` projects' baseURL), suite setup and every test worker read it here, + * so each process derives the same origin from the same environment. + */ +export const workerBaseUrl = new URL( + process.env.WORKER_BASE_URL || `http://localhost:${localWorkerPort}`, +).origin; diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..8c940c1 --- /dev/null +++ b/test/package.json @@ -0,0 +1,19 @@ +{ + "name": "@iterate-com/docs-specs", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@iterate-com/docs": "workspace:*", + "@playwright/test": "1.63.0", + "@types/node": "catalog:", + "capnweb": "npm:@iterate-com/capnweb@0.13.0", + "iterate": "catalog:", + "middlewright": "https://pkg.pr.new/middlewright@20eacef", + "playwright": "1.63.0", + "typescript": "catalog:", + "zod": "catalog:" + } +} diff --git a/test/playwright.config.ts b/test/playwright.config.ts new file mode 100644 index 0000000..10de2e4 --- /dev/null +++ b/test/playwright.config.ts @@ -0,0 +1,77 @@ +// Docs' browser specs: iterate/private's test/playwright.config.ts at a541c9d, cut down to its `docs` +// and `docs-phone` projects, with the timeouts and the one CI retry of its e2e policy +// (packages/shared/src/test-support/e2e-policy there) written out. +// +// doppler run --project os --config preview -- env \ +// WORKER_BASE_URL=https://os.iterate-dev-preview.workers.dev \ +// DOCS_BASE_URL=https://docs-pr12.iterate-dev-preview.workers.dev pnpm spec +// +// WORKER_BASE_URL is the platform whose projects serve Docs (unset: a local one, iterate's +// `pnpm dev`); DOCS_BASE_URL the Docs Worker under test. Locally, an unset DOCS_BASE_URL skips the +// specs; in CI it fails them. +import path from "node:path"; +import { defineConfig, devices } from "@playwright/test"; +import { workerBaseUrl } from "./helpers/worker-base-url.ts"; + +// Playwright resolves this file's relative paths against test/. What a run leaves goes into +// test-results/ at the root. +const repoRoot = path.resolve(import.meta.dirname, ".."); +const docsBaseUrl = process.env.DOCS_BASE_URL?.replace(/\/+$/, ""); +const videoMode = process.env.VIDEO_MODE === "1"; +const desktopWebUse = { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 900 }, +}; +// VIDEO_MODE=1 records every project at its own viewport size. +const recordedAtViewport = ( + use: Use, +) => (videoMode ? { ...use, video: { mode: "on" as const, size: use.viewport } } : use); + +export default defineConfig({ + testDir: ".", + globalSetup: "./playwright/global-setup.ts", + testMatch: "**/*.spec.ts", + // Each spec provisions its own fixture project, so every test runs beside the others. + fullyParallel: true, + forbidOnly: !!process.env.CI, + // One retry in CI, the only retry layer: a burst that defeats it fails the run on purpose. + retries: process.env.CI ? 1 : 0, + workers: 6, + outputDir: path.join(repoRoot, "test-results/playwright-output"), + reporter: [ + ["list"], + [ + "html", + { outputFolder: path.join(repoRoot, "test-results/playwright-report"), open: "never" }, + ], + ], + timeout: 240_000, + expect: { timeout: 15_000 }, + use: { + // Tight on purpose: the middlewright spinner-waiter extends it only while the app visibly + // reports progress. + actionTimeout: 1_000, + baseURL: workerBaseUrl, + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: videoMode ? "on" : "retain-on-failure", + }, + projects: [ + { + name: "docs", + testDir: "playwright/docs", + use: recordedAtViewport({ ...desktopWebUse, baseURL: docsBaseUrl }), + }, + // a doc opened to comment on once more at a phone's width, with touch: where it is often read + { + name: "docs-phone", + testDir: "playwright/docs", + testMatch: ["**/comment-mode.spec.ts"], + use: recordedAtViewport({ + ...devices["Pixel 7"], + viewport: { width: 390, height: 844 }, + baseURL: docsBaseUrl, + }), + }, + ], +}); diff --git a/test/playwright/docs/comment-mode.spec.ts b/test/playwright/docs/comment-mode.spec.ts new file mode 100644 index 0000000..9f30a9b --- /dev/null +++ b/test/playwright/docs/comment-mode.spec.ts @@ -0,0 +1,135 @@ +// A doc opened to comment on (`?mode=comment`), and a thread routed to an agent: what each does is +// in packages/docs-app/src/editor/doc-session.ts (`commentOnly`, `#followRoutes`), and where +// Comment sits on a phone in src/components/doc-editor.tsx. The agent's model is +// helpers/fake-ai.ts, so the spec decides what it answers. The docs-phone project runs these again +// at a phone's width. +import { expect } from "@playwright/test"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { FakeAi } from "../../helpers/fake-ai.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("a doc opened to comment on takes comments and no typing, and shows edits made elsewhere", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs"); + const docUrl = projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path: `/projects/${fixture.project.slug}/config/digest.md`, + })!; + docUrl.searchParams.set("mode", "comment"); + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + await config.writeFile("digest.md", "# Friday\n\nPay the water bill.\n\nPost the IRS form.\n"); + + await page.goto(docUrl.href); + if (ingressRouting?.type === "subdomains") await consent(page, docUrl.host); + const editor = page.getByRole("textbox", { name: "digest.md", exact: true }); + const comment = page.getByRole("button", { name: "Comment", exact: true }); + // live on the doc's processor, and still taking no typing + await comment.and(page.getByRole("button", { disabled: false })).waitFor(); + await editor.and(page.locator('[contenteditable="false"][aria-readonly="true"]')).waitFor(); + + // Misha selects a line, as a long press does on a phone, and comments on it + await editor.getByText("Post the IRS form.").selectText(); + await page.getByText("Comment on the selected text").waitFor(); + await comment.click(); + await page.getByRole("textbox", { name: "Comment", exact: true }).fill("Posted it yesterday"); + await page.getByRole("textbox", { name: "Comment", exact: true }).press("ControlOrMeta+Enter"); + const onLine = page.getByRole("article", { name: "Comment on “Post the IRS form.”" }); + await onLine.filter({ hasText: "Posted it yesterday" }).waitFor(); + + // An agent's commit reaches the open doc, as it does a doc opened to edit + await config.writeFile( + "digest.md", + "# Friday\n\nPay the water bill by Monday.\n\nPost the IRS form.\n", + ); + // timeout: a commit made elsewhere, so the spinner-waiter has nothing to extend by + await editor.getByText("Pay the water bill by Monday.").waitFor({ timeout: 10_000 }); + await onLine.filter({ hasText: "Posted it yesterday" }).waitFor(); + + // Edit opens the same doc to edit + await page.getByRole("link", { name: "Edit", exact: true }).click(); + await editor.and(page.locator('[contenteditable="true"]')).waitFor(); +}); + +test("a comment handed to an agent shows the agent thinking until it answers", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs-thinking"); + const docUrl = projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path: `/projects/${fixture.project.slug}/config/digest.md`, + })!; + docUrl.searchParams.set("mode", "comment"); + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + await config.writeFile("digest.md", "# Friday\n\nPay the water bill.\n"); + // The default template's init case installs the agents app, so the project's root has + // `itx.agents`, which the fixture's type does not name. + const itx: any = fixture.itx; + // The agent that answers comments, its model the spec's + const assistant = itx.cd("/agents/assistant"); + const ai = new FakeAi(); + await assistant.provide("itx.ai", ai); + await itx.agents.create("/agents/assistant"); + await assistant.append({ + type: "events.iterate.com/agent/configured", + payload: { config: { llm: { model: "gpt-6-luna" } } }, + }); + + await page.goto(docUrl.href); + if (ingressRouting?.type === "subdomains") await consent(page, docUrl.host); + const editor = page.getByRole("textbox", { name: "digest.md", exact: true }); + const comment = page.getByRole("button", { name: "Comment", exact: true }); + // live on the doc's processor + await comment.and(page.getByRole("button", { disabled: false })).waitFor(); + await editor.getByText("Pay the water bill.").selectText(); + await page.getByText("Comment on the selected text").waitFor(); + await comment.click(); + await page.getByRole("textbox", { name: "Comment", exact: true }).fill("Did this get paid?"); + await page.getByRole("textbox", { name: "Comment", exact: true }).press("ControlOrMeta+Enter"); + const onLine = page.getByRole("article", { name: "Comment on “Pay the water bill.”" }); + await onLine.filter({ hasText: "Did this get paid?" }).waitFor(); + + // What the project's own code does with a new comment: hands its thread to the agent that + // answers it, and passes the agent the words + const doc = itx.cd("/docs/config/digest.md"); + const { state } = await doc.invoke("itx.facets.get('doc').liveSnapshot()"); + const thread = state.threads[0].id; + await doc.append({ + type: "docs/comment-routed", + payload: { thread, to: "/agents/assistant" }, + }); + await itx.agents.get("/agents/assistant").message("Did this get paid?"); + // The agent answers with a script that replies in the thread and carries on until the spec says + // go ahead, then, shown the script's output, ends its turn + ai.provideReply(/Did this get paid/, { + status: "Replying", + values: { thread }, + script: async (itx, { thread }) => { + await itx.cd("/docs/config/digest.md").append({ + type: "docs/comment-replied", + payload: { thread, comment: "answer", body: "Paid it this morning." }, + }); + await itx.cd("/agents/assistant").waitForEvent({ type: "spec/go-ahead", afterOffset: 0 }); + }, + }).thenReply("Replied in the thread."); + + // timeout: the agent starts its turn on its own, so the spinner-waiter has nothing to extend by + await onLine.getByRole("status").filter({ hasText: "Thinking…" }).waitFor({ timeout: 10_000 }); + + // The reply is in and the script still runs: the agent is still thinking + await onLine.filter({ hasText: "Paid it this morning." }).waitFor(); + await onLine.getByRole("status").filter({ hasText: "Thinking…" }).waitFor(); + await assistant.append({ type: "spec/go-ahead" }); + await expect.poll(() => onLine.getByText("Thinking…").count()).toBe(0); +}); diff --git a/test/playwright/docs/comments.spec.ts b/test/playwright/docs/comments.spec.ts new file mode 100644 index 0000000..acff62e --- /dev/null +++ b/test/playwright/docs/comments.spec.ts @@ -0,0 +1,83 @@ +// Comments on a doc: events on the doc's own context, never text in the file. A comment on a typo +// reaches another member live, their reply reaches its author, an agent's commit fixing the typo +// leaves the comment on the corrected word, an agent replies as packages/docs/AGENTS.md says and +// the page names it, and any member may resolve it. +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("two people comment on a typo, an agent fixes it and the comment stays on the word, and one of them resolves it", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs"); + const docUrl = projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path: `/projects/${fixture.project.slug}/config/plan.md`, + })!; + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + await config.writeFile("plan.md", "# Offsite\n\nHotle is booked for three nights.\n"); + + await page.goto(docUrl.href); + if (ingressRouting?.type === "subdomains") await consent(page, docUrl.host); + const editor = page.getByRole("textbox", { name: "plan.md", exact: true }); + await editor.and(page.locator('[contenteditable="true"]')).waitFor(); + + // Misha selects the typo and comments on it + await editor.getByText("Hotle is booked").click(); + await editor.press("Home"); + for (let i = 0; i < "Hotle".length; i++) await editor.press("Shift+ArrowRight"); + await page.getByRole("button", { name: "Comment", exact: true }).click(); + await page.getByRole("textbox", { name: "Comment", exact: true }).fill("Which hotel?"); + await page.getByRole("textbox", { name: "Comment", exact: true }).press("ControlOrMeta+Enter"); + const onTypo = page.getByRole("article", { name: "Comment on “Hotle”" }); + await onTypo.filter({ hasText: "Which hotel?" }).waitFor(); + + // Jonas opens the doc: Misha's comment is there, and his reply reaches Misha live + await using jonas = await helpers.createMember(fixture, "jonas"); + await jonas.page.goto(docUrl.href); + if (ingressRouting?.type === "subdomains") await consent(jonas.page, docUrl.host); + const jonasOnTypo = jonas.page.getByRole("article", { name: "Comment on “Hotle”" }); + await jonasOnTypo.filter({ hasText: fixture.email.split("@")[0]! }).click(); + await jonasOnTypo.getByRole("textbox", { name: "Reply" }).fill("The one by the station."); + await jonasOnTypo.getByRole("button", { name: "Reply" }).click(); + // timeout: another person's comment, so the spinner-waiter has nothing to extend by + await onTypo.filter({ hasText: "The one by the station." }).waitFor({ timeout: 10_000 }); + + // An agent fixes the typo in a commit: the text under the comment changed, and the doc's + // processor moves the comment's quote to the corrected word + await config.writeFile("plan.md", "# Offsite\n\nHotel is booked for three nights.\n"); + const onFix = page.getByRole("article", { name: "Comment on “Hotel”" }); + // timeout: a commit made elsewhere, so the spinner-waiter has nothing to extend by + await onFix.filter({ hasText: "The one by the station." }).waitFor({ timeout: 10_000 }); + await jonas.page.getByRole("article", { name: "Comment on “Hotel”" }).waitFor(); + + // An agent working for Misha replies, reading the thread and appending the reply as the agent + // guide says; the page shows who it was + const doc = fixture.itx.cd("/docs/config/plan.md"); + const { state }: any = await doc.invoke("itx.facets.get('doc').liveSnapshot()"); + await doc.append({ + type: "docs/comment-replied", + payload: { + thread: state.threads[0].id, + comment: crypto.randomUUID(), + body: "Booked at the one by the station.", + via: "Claude Code", + }, + }); + // timeout: an agent's reply, so the spinner-waiter has nothing to extend by + await onFix.filter({ hasText: "· Claude Code" }).waitFor({ timeout: 10_000 }); + + // Jonas resolves it: anyone can, and it folds away for Misha too + await jonas.page + .getByRole("article", { name: "Comment on “Hotel”" }) + .getByRole("button", { name: "Resolve" }) + .click(); + // timeout: another person's resolve, so the spinner-waiter has nothing to extend by + await page.getByText("Resolved (1)").waitFor({ timeout: 10_000 }); +}); diff --git a/test/playwright/docs/docs.spec.ts b/test/playwright/docs/docs.spec.ts new file mode 100644 index 0000000..3bfdb4c --- /dev/null +++ b/test/playwright/docs/docs.spec.ts @@ -0,0 +1,106 @@ +// Docs, served on a project's `docs` routing slug like Notes: a new doc +// in the project's config repo, written with the editor's shortcuts and co-edited live by two +// people through the doc's processor (@iterate-com/docs); a commit an agent makes reaches both open +// editors; and when both have left, the processor commits it all at once, the first to type as the +// author and the other as co-author. +import { expect } from "@playwright/test"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("two people write a doc together: shortcuts, each other's typing live, an agent's commit live, and one commit when they leave", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs"); + const proxied = (path: string) => + projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path, + })!; + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + await page.goto(proxied("/projects").href); + if (ingressRouting?.type === "subdomains") await consent(page, proxied("/").host); + + // a project's docs open on its config repo + await page.getByRole("textbox", { name: "New doc title", exact: true }).fill("Lisbon offsite"); + await page.getByRole("button", { name: "New doc", exact: true }).click(); + const editor = page.getByRole("textbox", { name: "lisbon-offsite.md", exact: true }); + // read-only on the loaded text until the doc's processor has synced ("Opening…") + await editor.and(page.locator('[contenteditable="true"]')).waitFor(); + const config = fixture.itx.repos.get("/repos/config"); + const saved = () => config.readFile("lisbon-offsite.md"); + await expect.poll(saved).toBe("# Lisbon offsite\n"); + + // Cmd/Ctrl-B bolds; the app shell's sidebar, which toggles on the same keys, stays as it was. + // Tab nests a list item and the editor keeps focus. + const sidebarState = await page + .locator('[data-slot="sidebar"][data-state]') + .getAttribute("data-state"); + await editor.press("ControlOrMeta+End"); + await editor.press("Enter"); + await editor.pressSequentially("Tuesday"); + await editor.press("Shift+Home"); + await editor.press("ControlOrMeta+b"); + await editor.press("End"); + await editor.pressSequentially(" 13 October"); + await editor.press("Enter"); + await editor.press("Enter"); + await editor.pressSequentially("- Retro"); + await editor.press("Enter"); + await editor.pressSequentially("One slide each"); + await editor.press("Tab"); + await editor.and(page.locator(":focus")).waitFor(); + await page.locator(`[data-slot="sidebar"][data-state="${sidebarState}"]`).waitFor(); + + // Jonas, a member of the same organization, opens the doc in his own browser: he gets Misha's + // unsaved text, each sees the other is here, and what he types reaches Misha without a reload. + await using jonas = await helpers.createMember(fixture, "jonas"); + await jonas.page.goto(proxied(`/projects/${fixture.project.slug}/config/lisbon-offsite.md`).href); + if (ingressRouting?.type === "subdomains") await consent(jonas.page, proxied("/").host); + const jonasEditor = jonas.page.getByRole("textbox", { name: "lisbon-offsite.md", exact: true }); + await jonasEditor.and(jonas.page.locator('[contenteditable="true"]')).waitFor(); + await jonasEditor.filter({ hasText: "One slide each" }).waitFor(); + await page.getByLabel("Also here").filter({ hasText: jonas.email }).waitFor(); + await jonas.page.getByLabel("Also here").filter({ hasText: fixture.email }).waitFor(); + + await jonasEditor.press("ControlOrMeta+End"); + await jonasEditor.press("Enter"); + await jonasEditor.press("Shift+Tab"); + await jonasEditor.pressSequentially("Book the boat"); + // timeout: another person's typing, so the spinner-waiter has nothing to extend by + await editor.filter({ hasText: "Book the boat" }).waitFor({ timeout: 10_000 }); + + // An agent commits to the doc: the root's docs processor tells the doc's, which merges the + // commit into the live text and sends it to both editors. + await config.writeFile( + "lisbon-offsite.md", + (await saved())!.replace("# Lisbon offsite", "# Lisbon offsite, October 2026"), + ); + // timeout: a commit made elsewhere, so the spinner-waiter has nothing to extend by + await editor.filter({ hasText: "Lisbon offsite, October 2026" }).waitFor({ timeout: 10_000 }); + await jonasEditor.filter({ hasText: "Lisbon offsite, October 2026" }).waitFor(); + + // Both leave the doc: the processor commits their edits then, not a minute later, as one + // commit by Misha, who typed first, with Jonas as co-author. + await page.getByRole("link", { name: "config", exact: true }).click(); + await jonas.page.getByRole("link", { name: "config", exact: true }).click(); + await expect + .poll(saved) + .toBe( + "# Lisbon offsite, October 2026\n\n**Tuesday** 13 October\n\n- Retro\n - One slide each\n- Book the boat", + ); + await expect + .poll(async () => (await config.log({ limit: 1 }))[0]) + .toMatchObject({ + author: { email: fixture.email }, + message: `docs: edit lisbon-offsite.md\n\nCo-authored-by: ${jonas.email} <${jonas.email}>`, + }); + + await page.goto(proxied(`/projects/${fixture.project.slug}/config/lisbon-offsite.md`).href); + await editor.filter({ hasText: "Book the boat" }).waitFor(); +}); diff --git a/test/playwright/docs/files.spec.ts b/test/playwright/docs/files.spec.ts new file mode 100644 index 0000000..faed806 --- /dev/null +++ b/test/playwright/docs/files.spec.ts @@ -0,0 +1,74 @@ +// Docs opens a repo's other files too: an html file on a Preview of its live text, a page of its +// own whose scripts run as they would served alone, its Source a code editor; any other text file +// in a code editor alone; and a binary file not at all. +import { expect } from "@playwright/test"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("an html file opens on its preview and edits as source, a script in a code editor, and a binary file says it isn't text", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs-files"); + const proxied = (path: string) => + projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path, + })!; + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + await config.commitFiles({ + message: "Files that aren't markdown", + parent: await config.tip(), + changes: [ + { + path: "site/welcome.html", + content: [ + "

Welcome aboard

", + '

Step 1

', + "", + "", + ].join("\n"), + }, + { path: "scripts/greet.ts", content: 'export const greeting = "hello";\n' }, + { path: "assets/logo.png", content: "not really a png" }, + ], + }); + const doc = (path: string) => proxied(`/projects/${fixture.project.slug}/config/${path}`).href; + + await page.goto(doc("site/welcome.html")); + if (ingressRouting?.type === "subdomains") await consent(page, proxied("/").host); + const preview = page.frameLocator('iframe[title="Preview of site/welcome.html"]'); + await preview.getByRole("heading", { name: "Welcome aboard" }).waitFor(); + // a guide that keeps its step in the URL's #hash, as the html would on a host of its own + await preview.getByRole("button", { name: "Next" }).click(); + await preview.getByText("Step 2").waitFor(); + await page.getByRole("button", { name: "Source", exact: true }).click(); + const source = page.getByRole("textbox", { name: "site/welcome.html", exact: true }); + await source.and(page.locator('[contenteditable="true"]')).waitFor(); + await source.filter({ hasText: "

Welcome aboard

" }).waitFor(); + // the preview follows the text, and takes down a find panel open on the source + await source.press("ControlOrMeta+End"); + await source.pressSequentially("

Mind the gap

"); + await source.press("ControlOrMeta+f"); + const find = page.getByRole("textbox", { name: "Find", exact: true }); + await find.waitFor(); + await page.getByRole("button", { name: "Preview", exact: true }).click(); + await preview.getByText("Mind the gap").waitFor(); + await expect.poll(() => find.count()).toBe(0); + + await page.goto(doc("scripts/greet.ts")); + const script = page.getByRole("textbox", { name: "scripts/greet.ts", exact: true }); + await script.and(page.locator('[contenteditable="true"]')).waitFor(); + await script.filter({ hasText: 'export const greeting = "hello";' }).waitFor(); + // a code file has one view: no Rich / Markdown or Preview / Source + await expect.poll(() => page.getByRole("group", { name: "Mode" }).count()).toBe(0); + + await page.goto(doc("assets/logo.png")); + await page.getByText("logo.png isn't text").waitFor(); +}); diff --git a/test/playwright/docs/find.spec.ts b/test/playwright/docs/find.spec.ts new file mode 100644 index 0000000..5fbd4f7 --- /dev/null +++ b/test/playwright/docs/find.spec.ts @@ -0,0 +1,68 @@ +// Cmd/Ctrl-F finds in the whole file, lines not yet drawn included (`find` in +// packages/docs-app/src/editor/extensions.ts says why the browser's find can't): in a doc and in a +// code file, from the page or from the editor. +import { expect } from "@playwright/test"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("Cmd/Ctrl-F finds a line far down a long doc, and a name in a code file", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs-find"); + const proxied = (path: string) => + projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path, + })!; + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + const items = Array.from({ length: 300 }, (_, i) => `Item ${i + 1}: noted.`); + await config.commitFiles({ + message: "A long doc and a long script", + parent: await config.tip(), + changes: [ + { + path: "minutes.md", + content: `# Minutes\n\n${items.join("\n\n")}\n\nThe offsite moves to Porto.\n`, + }, + { + path: "scripts/venue.ts", + content: `${items.map((item) => `// ${item}`).join("\n")}\nexport const venue = "Porto";\n`, + }, + ], + }); + const doc = (path: string) => proxied(`/projects/${fixture.project.slug}/config/${path}`).href; + + await page.goto(doc("minutes.md")); + if (ingressRouting?.type === "subdomains") await consent(page, proxied("/").host); + const minutes = page.getByRole("textbox", { name: "minutes.md", exact: true }); + await minutes.and(page.locator('[contenteditable="true"]')).waitFor(); + const lastLine = minutes.getByText("The offsite moves to Porto."); + // not drawn yet: what the browser's find can't see + await expect.poll(() => lastLine.count()).toBe(0); + + // the editor hasn't got focus: the page opens its panel all the same + await page.keyboard.press("ControlOrMeta+f"); + const find = page.getByRole("textbox", { name: "Find", exact: true }); + await find.pressSequentially("Porto"); + await find.press("Enter"); + await lastLine.waitFor(); + // Escape closes it and hands the keys back to the editor + await find.press("Escape"); + await minutes.and(page.locator(":focus")).waitFor(); + await expect.poll(() => find.count()).toBe(0); + + await page.goto(doc("scripts/venue.ts")); + const script = page.getByRole("textbox", { name: "scripts/venue.ts", exact: true }); + await script.and(page.locator('[contenteditable="true"]')).waitFor(); + await script.press("ControlOrMeta+f"); + await find.pressSequentially("venue"); + await find.press("Enter"); + await script.getByText(`export const venue = "Porto";`).waitFor(); +}); diff --git a/test/playwright/docs/install.spec.ts b/test/playwright/docs/install.spec.ts new file mode 100644 index 0000000..cac75ff --- /dev/null +++ b/test/playwright/docs/install.spec.ts @@ -0,0 +1,38 @@ +// A project that serves Docs but hasn't installed it gets it from a doc's page: one click commits +// `docs.ts` and the pin to its config, and the doc opens once the project runs them. +import { expect } from "@playwright/test"; +import { spinnerWaiter } from "middlewright"; +import { docsModule } from "@iterate-com/docs/install"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, docsBuild, routeDocs } from "./support.ts"; + +test("a project without Docs installs it from a doc's page, and the doc opens", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs-install"); + const docUrl = projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path: `/projects/${fixture.project.slug}/config/plan.md`, + })!; + await routeDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + const config = fixture.itx.repos.get("/repos/config"); + await config.writeFile("plan.md", "# Plan\n"); + // the page installs this deployment's build: wait until pkg.pr.new has published this checkout's + await docsBuild(); + + await page.goto(docUrl.href); + if (ingressRouting?.type === "subdomains") await consent(page, docUrl.host); + await page.getByRole("button", { name: "Install Docs in this project", exact: true }).click(); + + const editor = page.getByRole("textbox", { name: "plan.md", exact: true }); + // "Installing…" lasts as long as the config's publication of it, which can outlast the usual spinner + await spinnerWaiter.settings.run({ spinnerTimeout: 120_000 }, () => editor.click()); + await editor.and(page.locator('[contenteditable="true"]')).waitFor(); + expect(await config.readFile("docs.ts")).toBe(docsModule.content); +}); diff --git a/test/playwright/docs/sidebar.spec.ts b/test/playwright/docs/sidebar.spec.ts new file mode 100644 index 0000000..3ee3dce --- /dev/null +++ b/test/playwright/docs/sidebar.spec.ts @@ -0,0 +1,99 @@ +// The sidebar lists the files of one of the project's repos as a tree (@pierre/trees), folders from +// their paths, and follows the repo: a doc an agent commits shows up without a reload. Its picker +// switches repo. ⌘K (the shell's palette) finds a file by name, one in a closed folder included. +// Right-clicking a folder, or the space below the rows, makes a doc there, named in place. +import { expect } from "@playwright/test"; +import { projectUrlOf } from "iterate/project-ingress"; +import { readOsPlaywrightAuthConfig } from "../../helpers/auth-config.ts"; +import { test } from "../../helpers/test.ts"; +import { workerBaseUrl } from "../../helpers/worker-base-url.ts"; +import { consent, serveDocs } from "./support.ts"; + +test("the sidebar shows a repo's docs as a tree, follows a doc an agent adds, switches repo, and ⌘K finds a doc by name", async ({ + page, + helpers, +}) => { + const { ingressRouting } = readOsPlaywrightAuthConfig(); + await using fixture = await helpers.createFixture("docs-nav"); + const proxied = (path: string) => + projectUrlOf(ingressRouting, workerBaseUrl, { + project: fixture.project.slug, + routingSlug: "docs", + path, + })!; + await serveDocs(fixture.itx, new URL(helpers.appOrigin("docs"))); + // another repo of the project, with a doc of its own + await fixture.itx.repos.create("/repos/handbook"); + await fixture.itx.repos.get("/repos/handbook").writeFile("welcome.md", "# Welcome\n"); + await page.goto(proxied("/projects").href); + if (ingressRouting?.type === "subdomains") await consent(page, proxied("/").host); + + // a title with a slash makes the doc in a folder, which the sidebar opens to show it highlighted + await page + .getByRole("textbox", { name: "New doc title", exact: true }) + .fill("Offsites/Lisbon offsite"); + await page.getByRole("button", { name: "New doc", exact: true }).click(); + const editor = page.getByRole("textbox", { name: "offsites/lisbon-offsite.md", exact: true }); + await editor.and(page.locator('[contenteditable="true"]')).waitFor(); + await editor.filter({ hasText: "Lisbon offsite" }).waitFor(); + const tree = page.locator('[data-slot="sidebar"]').getByRole("tree"); + await tree.getByRole("treeitem", { name: "offsites", exact: true, expanded: true }).waitFor(); + await tree + .getByRole("treeitem", { name: "lisbon-offsite.md", exact: true, selected: true }) + .waitFor(); + // in the sidebar's font, not the tree's own default + const sidebarFont = await page + .locator('[data-slot="sidebar"]') + .evaluate((element) => getComputedStyle(element).fontFamily); + expect( + await tree + .getByRole("treeitem", { name: "offsites", exact: true }) + .evaluate((element) => getComputedStyle(element).fontFamily), + ).toBe(sidebarFont); + + // an agent commits a doc in another folder: it is in the sidebar without a reload, its folder shut + await fixture.itx.repos.get("/repos/config").writeFile("plans/q4-roadmap.md", "# Q4 roadmap\n"); + await tree + .getByRole("treeitem", { name: "plans", exact: true, expanded: false }) + // timeout: a commit made elsewhere, so the spinner-waiter has nothing to extend by + .waitFor({ timeout: 10_000 }); + + // ⌘K finds it by name, though its folder is shut, and opens it + await page.locator('[data-slot="sidebar"]').getByRole("button", { name: "Search" }).click(); + await page.getByRole("combobox", { name: "Search projects and pages" }).fill("roadmap"); + await page.getByRole("option", { name: /q4-roadmap\.md/ }).click(); + await page + .getByRole("textbox", { name: "plans/q4-roadmap.md", exact: true }) + .filter({ hasText: "Q4 roadmap" }) + .waitFor(); + + // right-clicking a folder makes a doc in it, named in the tree; no extension means markdown + const sidebar = page.locator('[data-slot="sidebar"]'); + await tree.getByRole("treeitem", { name: "offsites", exact: true }).click({ button: "right" }); + await page.getByRole("menuitem", { name: "New doc", exact: true }).click(); + await sidebar.getByRole("textbox").fill("agenda"); + await sidebar.getByRole("textbox").press("Enter"); + await page + .getByRole("textbox", { name: "offsites/agenda.md", exact: true }) + .filter({ hasText: "agenda" }) + .waitFor(); + // and below the rows, at the repo's root + const files = sidebar.getByLabel("Files"); + const box = (await files.boundingBox())!; + await files.click({ button: "right", position: { x: 20, y: box.height - 10 } }); + await page.getByRole("menuitem", { name: "New doc", exact: true }).click(); + await sidebar.getByRole("textbox").fill("notes"); + await sidebar.getByRole("textbox").press("Enter"); + await page.getByRole("textbox", { name: "notes.md", exact: true }).waitFor(); + await expect + .poll(() => fixture.itx.repos.get("/repos/config").readFile("notes.md")) + .toBe("# notes\n"); + + // the picker shows another repo's files + await sidebar.getByRole("combobox", { name: "Repo" }).selectOption("handbook"); + await tree.getByRole("treeitem", { name: "welcome.md", exact: true }).click(); + await page + .getByRole("textbox", { name: "welcome.md", exact: true }) + .filter({ hasText: "Welcome" }) + .waitFor(); +}); diff --git a/test/playwright/docs/support.ts b/test/playwright/docs/support.ts new file mode 100644 index 0000000..e4176e0 --- /dev/null +++ b/test/playwright/docs/support.ts @@ -0,0 +1,51 @@ +// What every Docs spec does first: have the fixture's project serve Docs the way a project does, +// a members-only fetch route from its `docs` routing slug to the Docs Worker under test +// (`proxiedAppRoute`), and install Docs in its config (`docs.ts` and a pin, +// @iterate-com/docs/install) at this commit's build. Then consent for its host where it is one. +import { expect, type Page } from "@playwright/test"; +import { installDocs } from "@iterate-com/docs/install"; +import { proxiedAppRoute } from "../../helpers/proxied-app-route.ts"; + +export async function serveDocs(itx: any, docsWorker: URL) { + await routeDocs(itx, docsWorker); + await installDocs(itx, await docsBuild()); +} + +/** The project serving Docs without installing it: its `docs` fetch route alone. */ +export async function routeDocs(itx: any, docsWorker: URL) { + // after the project's own saga has published its seed, which would otherwise land after and win + await itx.waitForEvent({ + type: ["events.iterate.com/project/created", "events.iterate.com/project/create-failed"], + afterOffset: 0, + timeoutMs: 60_000, + }); + await itx.fetchRoutes.set("docs", proxiedAppRoute("docs", docsWorker.href)); +} + +/** @iterate-com/docs at the commit test/playwright/global-setup.ts sets as `PUBLISHED_PACKAGE_COMMIT`, + * once pkg.pr.new serves it (.github/workflows/pkg-pr-new.yml publishes every main commit and pull + * request head). */ +export async function docsBuild() { + const commit = process.env.PUBLISHED_PACKAGE_COMMIT; + if (!commit) + throw new Error( + "test/playwright/global-setup.ts sets PUBLISHED_PACKAGE_COMMIT before the workers start", + ); + const version = `https://pkg.pr.new/iterate/docs/@iterate-com/docs@${commit}`; + await expect + .poll(async () => (await fetch(version, { method: "HEAD" })).status, { + // timeout: fixture setup before any page, so the spinner-waiter has nothing to extend by; pkg.pr.new publishes a push's build beside its preview deploy, in about a minute + timeout: 120_000, + }) + .toBe(200); + return version; +} + +/** Consent for the proxied host, a client of its own under subdomains: Authorize, which hands the + * browser back to the page. */ +export async function consent(page: Page, host: string) { + await page + .getByRole("heading", { name: `${host} wants to access your account`, exact: true }) + .waitFor(); + await page.getByRole("button", { name: "Authorize", exact: true }).click({ noWaitAfter: true }); +} diff --git a/test/playwright/global-setup.ts b/test/playwright/global-setup.ts new file mode 100644 index 0000000..d3ce416 --- /dev/null +++ b/test/playwright/global-setup.ts @@ -0,0 +1,53 @@ +// What every test worker inherits, worked out once before they start: the platform under test's +// credentials and how it addresses a project (helpers/auth-config.ts), and the commit whose +// @iterate-com/docs build the specs install (`PUBLISHED_PACKAGE_COMMIT`, docs/support.ts). After +// iterate/private's test/playwright/global-setup.ts at a541c9d, which reads any of iterate's +// platforms from its envs.ts; Docs' specs know two. +import { execFileSync } from "node:child_process"; +import { z } from "zod"; +import { docsEnvs } from "../../envs.ts"; +import { OsPlaywrightAuthEnv } from "../helpers/auth-config.ts"; +import { workerBaseUrl } from "../helpers/worker-base-url.ts"; + +export default function setup() { + Object.assign(process.env, OsPlaywrightAuthEnv.parse(platformEnv())); + // CI names the pull request's head (a pull request's checkout is a merge commit pkg.pr.new + // never published); locally, the checkout's commit, which has a build once it is pushed + process.env.PUBLISHED_PACKAGE_COMMIT ||= execFileSync("git", ["rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); +} + +/** A local platform (iterate's `pnpm dev`) has fixed dev credentials and routes projects by + * subdomain under localhost. Main on dev's come from Doppler (`doppler run --project os --config + * preview`): its ITERATE config's admin bearer, its password sign-in and its secrets key, and it + * addresses projects by path. */ +function platformEnv() { + if (new URL(workerBaseUrl).hostname === "localhost") + return { + ITERATE_ADMIN_BEARER: "dev-admin-api-secret", + LOGIN_PASSWORD: "dev", + PROJECT_INGRESS_ROUTING: JSON.stringify({ type: "subdomains", hostname: "localhost" }), + MCP_BASE_URL: `${workerBaseUrl}/mcp`, + SECRETS_KEY: "dev-secrets-key", + }; + if (workerBaseUrl !== docsEnvs.preview.urls.os) + throw new Error( + `WORKER_BASE_URL is ${workerBaseUrl}: the specs run against main on dev (${docsEnvs.preview.urls.os}) or a local platform`, + ); + const doppler = z + .object({ + ITERATE: z.string(), + ITERATE__LOGIN__METHODS__PASSWORD__PASSWORD: z.string(), + ITERATE__SECRETS_ENCRYPTION__KEY: z.string(), + }) + .parse(process.env); + return { + ITERATE_ADMIN_BEARER: z.object({ adminBearer: z.string() }).parse(JSON.parse(doppler.ITERATE)) + .adminBearer, + LOGIN_PASSWORD: doppler.ITERATE__LOGIN__METHODS__PASSWORD__PASSWORD, + PROJECT_INGRESS_ROUTING: JSON.stringify({ type: "paths" }), + MCP_BASE_URL: `${workerBaseUrl}/mcp`, + SECRETS_KEY: doppler.ITERATE__SECRETS_ENCRYPTION__KEY, + }; +} diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..b4458ea --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + // The browser specs, their config and helpers. DOM types: a spec's callbacks run in the page. + "extends": "../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"], + "types": ["node"] + }, + "include": ["**/*.ts"] +}