Skip to content

Commit 1252767

Browse files
authored
Merge pull request #65 from ChanMeng666/ci/nightly-prod-e2e
ci(nightly): run the read-only Playwright subset against production
2 parents 2303d3b + 5f26a3a commit 1252767

8 files changed

Lines changed: 220 additions & 67 deletions

File tree

.github/workflows/nightly.yml

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -160,33 +160,104 @@ jobs:
160160
- run: npm test
161161

162162
# ---------------------------------------------------------------------------
163-
# PLACEHOLDER — read-only end-to-end checks against production (Playwright).
163+
# Read-only end-to-end checks against PRODUCTION (Playwright).
164164
#
165-
# DISABLED ON PURPOSE. This repo has no Playwright config or browser install step
166-
# yet; those land with workstreams C (playground E2E) and D (docs E2E). Enabling
167-
# this job before then would just fail on a missing config, so it stays commented
168-
# out rather than guessing at spec paths that do not exist.
165+
# `prod-smoke` above proves the routes answer; this proves the sites still WORK —
166+
# the playground boots and compiles a plan in a real browser, the embed page draws
167+
# from a `#z=` link, the docs site's machine artifacts are byte-identical to this
168+
# checkout, and `<ArchLive>` hydrates into a live compiler rather than a dead
169+
# `<pre>`. ci.yml runs the same specs against a locally BUILT site before merge;
170+
# this runs a subset against what visitors actually get.
169171
#
170-
# To enable, AFTER C/D have landed their configs: delete the comment markers, point
171-
# `--config`/`--project` at whatever those workstreams actually committed (do not
172-
# invent paths here), and keep the run READ-ONLY — this drives *production*, so no
173-
# form submits, no writes, no state mutation, only navigation and assertions.
172+
# Two rules govern this job, and neither is negotiable:
174173
#
175-
# e2e-prod:
176-
# name: E2E against production (read-only)
177-
# runs-on: ubuntu-latest
178-
# steps:
179-
# - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
180-
# - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
181-
# with:
182-
# node-version: 22
183-
# cache: npm
184-
# - run: npm ci
185-
# - run: npx playwright install --with-deps chromium
186-
# - run: <the E2E command workstream C/D committed>
187-
# env:
188-
# PLAYWRIGHT_BASE_URL: https://playground.archlang.uk
174+
# 1. READ-ONLY. It drives production, so the run is restricted to `--grep @prod`
175+
# — a tag each spec file carries a comment explaining. Tagged cases navigate,
176+
# read and assert; nothing downloads, writes the clipboard, submits a form or
177+
# depends on persisted state. Never widen this to the full suite.
178+
# 2. NO BUILD. `E2E_BASE_URL` makes each config drop its `webServer` and point at
179+
# the live origin, so there is nothing to build and no preview server — which
180+
# is also what makes the docs byte-equality cases meaningful here: they compare
181+
# PRODUCTION's bytes against THIS checkout of `main`, i.e. they fail when a
182+
# deploy has silently gone stale.
183+
#
184+
# A red night here means production is broken or stale, not that a PR is bad — it
185+
# lands in the same pinned issue the `report` job writes.
189186
# ---------------------------------------------------------------------------
187+
e2e-prod:
188+
name: E2E against production (read-only)
189+
runs-on: ubuntu-latest
190+
steps:
191+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
192+
193+
- name: Set up Node.js
194+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
195+
with:
196+
# Node 22, matching ci.yml's e2e-docs leg: archlive.spec.ts imports the
197+
# playground's `#z=` decoder, which needs `DecompressionStream("deflate-raw")`
198+
# (Node ≥ 21.2) at module scope even when the tagged subset never calls it.
199+
node-version: 22
200+
cache: npm
201+
202+
- name: Install dependencies
203+
run: npm ci
204+
205+
# NB: no `npm run build`, no site build. With E2E_BASE_URL set, neither config
206+
# starts a preview server — the specs drive the live origins directly.
207+
208+
# Keyed on the RESOLVED @playwright/test version: a browser build is pinned
209+
# to its driver, so a version bump must miss the cache rather than reuse an
210+
# incompatible download. (Same key as ci.yml's e2e jobs, so a nightly can hit
211+
# a cache a PR run populated.)
212+
- name: Resolve Playwright version
213+
id: pw
214+
run: |
215+
version=$(node -p "require('@playwright/test/package.json').version")
216+
echo "version=$version" >> "$GITHUB_OUTPUT"
217+
218+
- name: Cache Playwright browsers
219+
id: pw-cache
220+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
221+
with:
222+
path: ~/.cache/ms-playwright
223+
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}
224+
225+
# On a cache hit the browser binary is already there but the OS packages it
226+
# links against are not (they live outside the cached path), so the two
227+
# cases install different things.
228+
- name: Install Chromium (browser + system deps)
229+
if: steps.pw-cache.outputs.cache-hit != 'true'
230+
run: npx playwright install --with-deps chromium
231+
232+
- name: Install Chromium system deps only (cached browser)
233+
if: steps.pw-cache.outputs.cache-hit == 'true'
234+
run: npx playwright install-deps chromium
235+
236+
- name: Playground — https://playground.archlang.uk
237+
run: npx playwright test -c playground --grep @prod
238+
env:
239+
E2E_BASE_URL: https://playground.archlang.uk
240+
241+
# `!cancelled()`, not the default: if the playground is down, the docs site's
242+
# verdict is still worth having tonight. Both still fail the job.
243+
- name: Docs site — https://archlang.uk
244+
if: ${{ !cancelled() }}
245+
run: npx playwright test -c docs-site --grep @prod
246+
env:
247+
E2E_BASE_URL: https://archlang.uk
248+
249+
- name: Upload Playwright report
250+
if: failure()
251+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
252+
with:
253+
name: playwright-report-prod
254+
path: |
255+
playground/playwright-report
256+
playground/test-results
257+
docs-site/playwright-report
258+
docs-site/test-results
259+
retention-days: 7
260+
if-no-files-found: ignore
190261

191262
# ---------------------------------------------------------------------------
192263
# One writer for the pinned issue. Folding "advisories found" and "a job failed"
@@ -195,7 +266,7 @@ jobs:
195266
# ---------------------------------------------------------------------------
196267
report:
197268
name: Report
198-
needs: [prod-smoke, audit, secrets, full-matrix]
269+
needs: [prod-smoke, audit, secrets, full-matrix, e2e-prod]
199270
if: always()
200271
runs-on: ubuntu-latest
201272
permissions:

AGENTS.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,9 @@ npm run typecheck:all # full-repo typecheck: root tsconfig.dev.json (src+test
242242
npm run test:coverage # vitest run --coverage — report-only v8 coverage over src/ (CI: Node 22 leg)
243243
npm run e2e:playground # Playwright E2E against the built playground (build core + playground:build:only first)
244244
npm run e2e:docs # Playwright E2E against the built docs site (build core + docs:build:only first)
245+
# Set E2E_BASE_URL=<origin> on either and the config drops its webServer and
246+
# drives THAT origin — no build, no preview server. Nightly pairs it with
247+
# `--grep @prod` (the READ-ONLY subset) against the live sites.
245248

246249
npm run playground:dev # build core, then run the Vite playground dev server
247250
npm run docs:build # build core, then build the VitePress docs site
@@ -252,8 +255,13 @@ CI runs five PR gates in parallel: the Node 18/20/22 test matrix (+ report-only
252255
the 22 leg), a **builds** job (all four workspaces compile, `typecheck:all`, MCP dist-resource
253256
freshness, VS Code bundle tests), a **Windows** leg (tests + drift at runner-default line
254257
endings), two **Playwright E2E** jobs (playground, docs), and **CodeQL**; `nightly.yml` adds
255-
production smoke (`scripts/smoke.mjs`), `npm audit` (report-only issue), gitleaks and a full
256-
OS×Node matrix.
258+
production smoke (`scripts/smoke.mjs`), `npm audit` (report-only issue), gitleaks, a full
259+
OS×Node matrix, and **`e2e-prod`** — the `@prod`-tagged READ-ONLY Playwright subset re-run
260+
against the live `playground.archlang.uk` / `archlang.uk` via `E2E_BASE_URL` (no build, no
261+
preview server). Only tag a case `@prod` if it purely navigates, reads and asserts: no
262+
downloads, no clipboard, no persisted-state or typing flows. Its docs half is also a
263+
**deploy-staleness probe** — the raw `/<page>.md` cases compare production's bytes against
264+
the checkout's, so a stale deploy fails the night.
257265

258266
Export to other formats from the CLI: `-f svg|dxf|txt|pdf|png` (`txt` is the
259267
zero-dep ASCII plan; `pdf` needs optional `pdfkit`; `png` needs optional

docs-site/e2e/archlive.spec.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@
1010
* and this component's inline copy), and `test/share-codec.test.ts` welds the copies by
1111
* extracting this one's body and evaluating it. That is a static check. This is the runtime
1212
* one — the real button, in the real bundle, decoded by the playground's real codec.
13+
*
14+
* TAG `@prod` — ONE DESCRIBE ONLY: "the widget hydrates into a live compiler".
15+
* `.github/workflows/nightly.yml` re-runs the tagged subset against the LIVE
16+
* https://archlang.uk (`E2E_BASE_URL` + `--grep @prod`), and hydration is the case that
17+
* answers the production question — "is what is deployed a live compiler, or a dead <pre>?"
18+
* — by loading two pages and looking. The three describes below it are deliberately
19+
* UNTAGGED: they all TYPE into the widget (`setArchLiveSource`), including the XSS probe,
20+
* and the nightly subset is meant to stay small, read-only and boringly deterministic.
21+
* A new describe here does NOT get the tag by default — add it only if the case is pure
22+
* load-and-look.
1323
*/
1424
import { readFileSync } from "node:fs";
1525
import { join } from "node:path";
@@ -33,7 +43,7 @@ const EXPLICIT_PAGE = "/guide";
3343
/** A page whose ArchLives come from plain ```arch fences (fallback slot → swap on mount). */
3444
const FENCE_PAGE = "/relational";
3545

36-
test.describe("the widget hydrates into a live compiler", () => {
46+
test.describe("the widget hydrates into a live compiler", { tag: "@prod" }, () => {
3747
test("a plain ```arch fence ships as a highlighted <pre> and becomes an editor on mount", async ({
3848
page,
3949
request,

docs-site/e2e/routes.spec.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@
1313
*
1414
* Every route list here is DERIVED from sync-docs.mjs (see fixtures.ts) — adding a page or
1515
* an example extends this suite automatically.
16+
*
17+
* TAG `@prod` — WHOLE FILE (one tag per describe, so a describe added later must opt in
18+
* explicitly). `.github/workflows/nightly.yml` re-runs the tagged subset against the LIVE
19+
* https://archlang.uk (`E2E_BASE_URL` + `--grep @prod`). Everything here is a plain GET or a
20+
* navigation, which is why the whole file qualifies — and against production it buys one
21+
* thing more than it does in CI: the byte-equality cases below compare the DEPLOYED bytes
22+
* against the LOCAL checkout of `main`, so a docs deploy that silently went stale (a failed
23+
* build, a rolled-back deployment, a `public/` copy that never re-synced) fails here. That
24+
* staleness probe is the point, not an accident — do not relax those assertions to make a
25+
* red night green.
1626
*/
1727
import { expect, test } from "@playwright/test";
1828
import {
@@ -24,7 +34,7 @@ import {
2434
ROOT_COPY_ROUTES,
2535
} from "./fixtures.js";
2636

27-
test.describe("the homepage renders", () => {
37+
test.describe("the homepage renders", { tag: "@prod" }, () => {
2838
test("/ serves HTML carrying the hand-written agents band", async ({ page }) => {
2939
const res = await page.goto("/");
3040
expect(res?.status()).toBe(200);
@@ -35,7 +45,7 @@ test.describe("the homepage renders", () => {
3545
});
3646
});
3747

38-
test.describe("machine-readable root artifacts", () => {
48+
test.describe("machine-readable root artifacts", { tag: "@prod" }, () => {
3949
test("every ROOT_COPIES route is served and non-empty", async ({ request }) => {
4050
// Derived, so /llms.txt, /llms-full.txt, both schemas and the grammar are all covered
4151
// — and so is anything added to that table later.
@@ -76,7 +86,7 @@ test.describe("machine-readable root artifacts", () => {
7686
});
7787
});
7888

79-
test.describe("the raw /<page>.md copies serve the canonical markdown", () => {
89+
test.describe("the raw /<page>.md copies serve the canonical markdown", { tag: "@prod" }, () => {
8090
test("there are as many routes as sync-docs publishes pages", () => {
8191
expect(PAGE_ROUTES.length).toBe(7);
8292
});
@@ -101,7 +111,7 @@ test.describe("the raw /<page>.md copies serve the canonical markdown", () => {
101111
}
102112
});
103113

104-
test.describe("the example gallery", () => {
114+
test.describe("the example gallery", { tag: "@prod" }, () => {
105115
test("the derived gallery is non-trivial and holds the flagships", () => {
106116
expect(GALLERY_EXAMPLES.length).toBeGreaterThan(5);
107117
for (const flagship of ["studio", "museum", "aquarium", "gallery-l"]) {

docs-site/playwright.config.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,19 @@ import { defineConfig, devices } from "@playwright/test";
2323
* Serving the built output rather than the dev server is deliberate: SSR'd HTML,
2424
* hydration and the `public/` passthrough are all part of what is under test, and
2525
* only the production build exercises them the way a visitor does.
26+
*
27+
* ESCAPE HATCH — `E2E_BASE_URL`: set it and the suite drives THAT origin with no
28+
* local server and no build at all (the `webServer` key is omitted, not disabled
29+
* — Playwright would otherwise still try to start it). The nightly workflow uses
30+
* this to run the READ-ONLY `@prod` subset against https://archlang.uk, which is
31+
* why every spec navigates relatively (`page.goto("/")`, `request.get("/llms.txt")`)
32+
* rather than to a hardcoded localhost URL. Note what that turns the raw-`.md`
33+
* byte-equality cases into: they compare PRODUCTION's bytes against the LOCAL
34+
* checkout's, i.e. a deployment-staleness probe. Unset, everything below behaves
35+
* exactly as before.
2636
*/
37+
const externalBaseUrl = process.env.E2E_BASE_URL;
38+
2739
export default defineConfig({
2840
testDir: "./e2e",
2941
// Every spec asserts on a stateless page; they may run in parallel.
@@ -34,27 +46,33 @@ export default defineConfig({
3446
workers: process.env.CI ? 2 : undefined,
3547
reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : [["list"]],
3648
use: {
37-
baseURL: "http://localhost:4174",
49+
baseURL: externalBaseUrl ?? "http://localhost:4174",
3850
trace: "retain-on-failure",
3951
screenshot: "only-on-failure",
4052
},
4153
// Chromium only: the site's own behaviour is what these specs test, not browser
4254
// support for `CompressionStream` (the `#z=` codec already has a documented
4355
// fallback and its own unit gate in test/share-codec.test.ts).
4456
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
45-
webServer: {
46-
// 4174, not the Vite default 4173: that is the PLAYGROUND E2E's port
47-
// (playground/playwright.config.ts), and running both suites locally must not
48-
// have one silently answer the other's requests. `--strictPort` makes a clash
49-
// fail loudly instead of drifting to the next free port, which would hang the
50-
// readiness probe until it timed out.
51-
//
52-
// NB the URL says `localhost`, not `127.0.0.1`: the preview server binds the
53-
// hostname, not the loopback address, so a 127.0.0.1 probe never connects.
54-
command: "npm run preview -- --port 4174 --strictPort",
55-
cwd: ".",
56-
url: "http://localhost:4174/",
57-
reuseExistingServer: !process.env.CI,
58-
timeout: 60_000,
59-
},
57+
// Spread, not a `webServer: undefined` key: with an external base URL there is
58+
// nothing to start and nothing to build.
59+
...(externalBaseUrl
60+
? {}
61+
: {
62+
webServer: {
63+
// 4174, not the Vite default 4173: that is the PLAYGROUND E2E's port
64+
// (playground/playwright.config.ts), and running both suites locally must not
65+
// have one silently answer the other's requests. `--strictPort` makes a clash
66+
// fail loudly instead of drifting to the next free port, which would hang the
67+
// readiness probe until it timed out.
68+
//
69+
// NB the URL says `localhost`, not `127.0.0.1`: the preview server binds the
70+
// hostname, not the loopback address, so a 127.0.0.1 probe never connects.
71+
command: "npm run preview -- --port 4174 --strictPort",
72+
cwd: ".",
73+
url: "http://localhost:4174/",
74+
reuseExistingServer: !process.env.CI,
75+
timeout: 60_000,
76+
},
77+
}),
6078
});

playground/e2e/boot.spec.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ import { watchForProblems } from "./fixtures.js";
99
* externalisation mistake, or a module-scope throw — none of which the unit
1010
* suite or `vite build` can see, because both succeed on a page that then dies
1111
* in the browser.
12+
*
13+
* TAG `@prod` — WHOLE FILE. `.github/workflows/nightly.yml` re-runs the tagged
14+
* subset against the LIVE https://playground.archlang.uk (`E2E_BASE_URL` +
15+
* `--grep @prod`), so a tag here is a promise about production, not just about a
16+
* preview server. Every case below keeps it: they navigate, read the DOM and
17+
* assert — nothing downloads, writes to the clipboard, or depends on state
18+
* surviving a reload. (`selectOption` on the examples picker mutates only this
19+
* throwaway browser context.) Do NOT tag a case that downloads a file, reads or
20+
* writes the clipboard, or asserts on localStorage persistence.
1221
*/
13-
test.describe("boot", () => {
22+
test.describe("boot", { tag: "@prod" }, () => {
1423
test("renders the default example into the stage", async ({ page }) => {
1524
await page.goto("/");
1625
const svg = page.locator(".pz-stage svg");

0 commit comments

Comments
 (0)