From 6075d39c763619ddf65c88a4f2f83346214e5a9b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:32:58 +0000 Subject: [PATCH 1/3] chore(dev): per-worktree e2e port, ci:failures, db-gate --db-only, .nvmrc Fixes the environment problems that cost agents the most time in recent sessions: - npm run verify ran Playwright on 18080 in every worktree, so two worktrees verifying at once failed. .superset/e2e-port.sh allocates a port per worktree from 18100-18179 through the registry dev.sh uses. The registry now drops entries for worktrees that no longer exist, and teardown releases the e2e port. Protected staging stays on 18080, the origin deployed CORS allows. - npm run ci:failures [pr|run-id] prints failed jobs, the Playwright or Vitest summary, and each failed test's error and on-page dialogs from the failure artifact's error-context.md. - db-gate.sh --db-only runs the database tests under the shared lock and stops there. - .nvmrc pins Node 22. The VPS default is Node 24. - .gitignore no longer drops every new Markdown file, and ignores .playwright-mcp/ and .ci-failures/. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 16 +-- .nvmrc | 1 + .superset/db-gate.sh | 15 +++ .superset/e2e-port.sh | 16 +++ .superset/lib/port-alloc.cjs | 12 ++- .superset/teardown.sh | 1 + AGENTS.md | 11 ++- docs/development.md | 7 +- docs/testing.md | 10 +- package.json | 3 +- scripts/ci-failures.mjs | 185 +++++++++++++++++++++++++++++++++++ 11 files changed, 256 insertions(+), 21 deletions(-) create mode 100644 .nvmrc create mode 100755 .superset/e2e-port.sh create mode 100644 scripts/ci-failures.mjs diff --git a/.gitignore b/.gitignore index 038649d67..de5855aba 100644 --- a/.gitignore +++ b/.gitignore @@ -30,10 +30,14 @@ coverage/ *.tsbuildinfo .vite/ .playwright-cli/ +.playwright-mcp/ # Verification skill state, logs, and evidence .verify/ +# CI failure artifacts downloaded by `npm run ci:failures` +.ci-failures/ + # Playwright artifacts test-results/ playwright-report/ @@ -62,16 +66,12 @@ deno.lock tasks.txt humanizer.md -# Local notes/docs, except the project README -*.md +# Local notes. Other Markdown files are not ignored: a blanket *.md rule +# silently dropped new root-level docs. +/TASKS.md +*.local.md *.txt *.info -!README.md -!AGENTS.md -!**/README.md -!docs/**/*.md -!plans/**/*.md -!.claude/skills/**/*.md .vercel .env* .worktrees/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.superset/db-gate.sh b/.superset/db-gate.sh index 9be7c0a30..204a95624 100755 --- a/.superset/db-gate.sh +++ b/.superset/db-gate.sh @@ -7,8 +7,18 @@ # once collide in Docker, not just on a socket. Use this instead of calling # `supabase db start` directly whenever more than one workspace might be # active. +# +# --db-only stops after `npm run test:db` (pgTAP and the database scripts), +# still holding the lock and stopping the stack on exit. set -euo pipefail +DB_ONLY=false +case "${1:-}" in + --db-only) DB_ONLY=true ;; + "") ;; + *) echo "usage: $0 [--db-only]" >&2; exit 2 ;; +esac + DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$DIR/lib/lock.sh" @@ -25,6 +35,10 @@ if [ "$LIVE_PROVIDERS" = 1 ] && { [ -z "${LIVE_OPENROUTER_API_KEY:-}" ] || [ -z echo "LIVE_PROVIDERS=1 requires LIVE_OPENROUTER_API_KEY and LIVE_EXA_API_KEY" >&2 exit 1 fi +if [ "$DB_ONLY" = true ] && [ "$LIVE_PROVIDERS" = 1 ]; then + echo "--db-only runs the database tests, which LIVE_PROVIDERS=1 skips" >&2 + exit 2 +fi stack_started=false runtime_dir="" owned_pids=() @@ -83,6 +97,7 @@ for attempt in 1 2 3; do sleep $((attempt * 30)) done if [ "$LIVE_PROVIDERS" != 1 ]; then npm run test:db; fi +if [ "$DB_ONLY" = true ]; then exit 0; fi status_json="$(supabase status -o json 2>/dev/null)" status_value() { diff --git a/.superset/e2e-port.sh b/.superset/e2e-port.sh new file mode 100755 index 000000000..db114d67b --- /dev/null +++ b/.superset/e2e-port.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Prints this worktree's port for the credential-free Playwright server. +# Every worktree used to run `npm run verify` on 18080, so two worktrees +# verifying at once failed on "port already in use". Ports come from +# 18100-18179 through the same registry dev.sh uses, keyed by worktree. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$DIR/lib/lock.sh" + +PORTS_LOCK="$HOME/.superset/debatelab-ports/lock" +WORKSPACE_KEY="e2e:$(git -C "$DIR/.." rev-parse --show-toplevel)" + +lock_acquire "$PORTS_LOCK" 30 >&2 +trap 'lock_release "$PORTS_LOCK"' EXIT +node "$DIR/lib/port-alloc.cjs" allocate "$WORKSPACE_KEY" 18100 18179 diff --git a/.superset/lib/port-alloc.cjs b/.superset/lib/port-alloc.cjs index 412cd2e58..694755191 100755 --- a/.superset/lib/port-alloc.cjs +++ b/.superset/lib/port-alloc.cjs @@ -25,6 +25,16 @@ function readData() { } } +// Keys are worktree paths, optionally prefixed ("e2e:/path"). Drop entries +// whose worktree is gone so deleted worktrees don't hold ports forever. +function pruneRemoved(data) { + for (const key of Object.keys(data)) { + const dir = key.replace(/^[a-z0-9]+:(?=\/)/, ''); + if (path.isAbsolute(dir) && !fs.existsSync(dir)) delete data[key]; + } + return data; +} + function writeData(data) { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify(data, null, 2)); @@ -38,7 +48,7 @@ if (!cmd || !workspace) { } if (cmd === 'allocate') { - const data = readData(); + const data = pruneRemoved(readData()); if (data[workspace]) { console.log(data[workspace]); process.exit(0); diff --git a/.superset/teardown.sh b/.superset/teardown.sh index 306fa212c..61b591503 100755 --- a/.superset/teardown.sh +++ b/.superset/teardown.sh @@ -12,6 +12,7 @@ PORTS_LOCK="$HOME/.superset/debatelab-ports/lock" lock_acquire "$PORTS_LOCK" 30 node "$DIR/lib/port-alloc.cjs" release "$WORKSPACE_KEY" || true +node "$DIR/lib/port-alloc.cjs" release "e2e:$(git rev-parse --show-toplevel)" || true lock_release "$PORTS_LOCK" if command -v supabase >/dev/null 2>&1 && supabase status >/dev/null 2>&1; then diff --git a/AGENTS.md b/AGENTS.md index 5e9990d1b..322342f5b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - The repository is DebateLab. Never infer its directory from an old repository name. - Run `git rev-parse --show-toplevel` and stay inside that returned worktree. On Adam's VPS, `/home/codex/Projects/DebateLab` is the primary checkout, but Superset and other agents use linked worktrees elsewhere. - Before editing, inspect the branch and working tree. Preserve unrelated changes. -- Use Node 22, npm, and the committed `package-lock.json`. Install with `npm ci`. +- Use Node 22, npm, and the committed `package-lock.json`. The VPS default is Node 24, so run `nvm use` first (`.nvmrc` pins 22). Install with `npm ci`; T3 worktrees start without `node_modules`. ## Parallel Superset workspaces @@ -30,7 +30,7 @@ The frontend is hosted on Vercel at `https://debate.pi-stack.org`. Merging to `m ## Normal ports - Vite development server: `8080` by default outside Superset; Superset allocates `8080-8179` per worktree. -- Automated browser server: isolated `127.0.0.1:18080`, never an existing dev server. +- Automated browser server: a per-worktree port from `18100-18179`, printed by `bash .superset/e2e-port.sh`, never an existing dev server. Protected staging keeps `127.0.0.1:18080`, the origin deployed CORS allows. - Tailnet previews for Adam: `npm run dev:preview` listens locally on `9200-9299` and is published on tailnet HTTPS `9100-9149`; it pins the app to staging. See [docs/development.md](docs/development.md#preview-on-the-tailnet). - Local Supabase: CLI defaults beginning at `54321`. The stack is not parallel-safe across worktrees. @@ -46,17 +46,20 @@ npm test -- --coverage npm run test:collaboration npm run test:edge npm run build -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e ``` `npm run verify` runs the complete credential-free ladder, including desktop and mobile Chromium against a fresh Vite server. If GitHub Actions jobs fail within 2 to 5 seconds with no logs, they did not run: the account is out of Actions minutes (billing), not failing on the code. In that case run the same checks locally at the final head (`npm run verify` and the database gate) and record the exact results in the PR. A local pass is not a CI pass; say which one you have. +When a CI job fails, start with `npm run ci:failures -- `. It prints the failing jobs and specs, downloads the failure artifact into `.ci-failures/`, and shows each failed test's error and the dialogs and headings on the page from its `error-context.md`. Read that before guessing at timeouts or infrastructure. + Database migrations and pgTAP policies use a separate Docker-backed gate. In Superset, use the locking wrapper: ```bash ./.superset/db-gate.sh +./.superset/db-gate.sh --db-only # pgTAP and database scripts only ``` Outside Superset, run the underlying commands directly: @@ -73,7 +76,7 @@ WebKit is a separate release smoke test: ```bash npx playwright install --with-deps webkit -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit +CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e:webkit ``` ## Verifying features in the real app diff --git a/docs/development.md b/docs/development.md index 2c990effc..1faca91f4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,9 +25,10 @@ Never overwrite unrelated changes. Do not switch to the primary checkout because - Playwright browser binaries for browser gates - Xcode and CocoaPods or Swift Package Manager support for native iOS work -Install the locked JavaScript tree: +Install the locked JavaScript tree with the Node version from `.nvmrc`: ```bash +nvm use npm ci ``` @@ -47,7 +48,7 @@ For another explicit local port: npm run dev -- --host 127.0.0.1 --port 8081 ``` -Automated browser tests own `127.0.0.1:18080` by default and always start a fresh server. +Automated browser tests always start a fresh server. `npm run verify` puts it on this worktree's port from `18100-18179` (`bash .superset/e2e-port.sh`); a bare Playwright run falls back to `127.0.0.1:18080`. ## Preview on the tailnet @@ -142,7 +143,7 @@ Never point local tests at staging or production. Never apply a migration to an [`playwright.config.ts`](../playwright.config.ts) starts Vite on an isolated port and refuses to reuse an existing server. ```bash -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e ``` Failure artifacts go to ignored `test-results/` and `playwright-report/`. Protected staging disables traces, screenshots, and video. diff --git a/docs/testing.md b/docs/testing.md index 7f4b0afb6..e01546f95 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -108,10 +108,10 @@ Run this after `npm run build`. It serves `dist/` through Vite preview and prove ## Credential-free browser gate ```bash -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e +CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e ``` -Playwright starts a new Vite server on `127.0.0.1:18080` and runs: +`.superset/e2e-port.sh` gives each worktree its own port from `18100-18179`, recorded in `~/.superset/debatelab-ports/allocations.json`, so two worktrees can run the gate at once. Without `PORT`, Playwright falls back to `18080`. Playwright starts a new Vite server on that port and runs: - desktop Chromium at 1280 by 800 - mobile Chromium using the Pixel 5 device profile @@ -164,7 +164,7 @@ In Superset, run: ./.superset/db-gate.sh ``` -The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. Outside Superset, run only the database portion with: +The wrapper acquires `~/.superset/debatelab-db.lock`, starts full local Supabase, runs `npm run test:db`, then holds the same lock while a controlled Mailpit relay, collaboration process, fake model provider, and Vite client run the real-Auth desktop/mobile workspace and full-stack feature journeys. Pull requests run this same gate in the `database-tests` job and retain its logs and browser traces on failure. It stops every owned process and the stack on exit, including after failure. To run only the database portion under the same lock, use `./.superset/db-gate.sh --db-only`. Outside Superset, run it with: ```bash supabase db start @@ -264,7 +264,7 @@ npx playwright install --with-deps webkit Run: ```bash -CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e:webkit +CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e:webkit ``` This is the Safari-engine smoke gate. State explicitly when it did not run. @@ -311,6 +311,8 @@ A skipped staging workflow is not a pass. Missing credentials or provider access [`.github/workflows/live-e2e.yml`](../.github/workflows/live-e2e.yml) runs the live provider journeys, and [`.github/workflows/prod-canary.yml`](../.github/workflows/prod-canary.yml) runs the production canary. Branch protection on `main` requires `quality`, `database-tests`, `browser-tests`, and `live-e2e`. It also requires a pull request, and administrators are not exempt. +To see why a run failed, use `npm run ci:failures` (current branch's PR), `npm run ci:failures -- ` or `npm run ci:failures -- `. It works while the run is still going, as soon as a job fails. It lists failed jobs and steps, prints the Playwright or Vitest summary from each job log, downloads the `*-failure-` artifacts into ignored `.ci-failures/`, and prints each failed test's error plus the dialogs and headings from its `error-context.md` page snapshot. A dialog the spec never answered shows up there. Protected staging uploads no artifact, so only its job log is available. + Before calling CI green: 1. identify the current head commit; diff --git a/package.json b/package.json index 32fe683cf..48a66ec2e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:preview": "VITE_STAGING_PREVIEW=true vite --host 127.0.0.1", "docs:check": "node scripts/check-docs.mjs", "backend:status": "node scripts/backend-status.mjs", + "ci:failures": "node scripts/ci-failures.mjs", "build": "vite build && node scripts/check-build-boundaries.mjs", "build:dev": "vite build --mode development", "ios:build": "vite build --mode capacitor && cap sync ios", @@ -29,7 +30,7 @@ "test:e2e:webkit": "playwright test --project=webkit", "test:sw": "node scripts/test-service-worker-offline.mjs", "test:e2e:staging": "playwright test --project=staging", - "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" + "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e" }, "dependencies": { "@capacitor/app": "^8.1.0", diff --git a/scripts/ci-failures.mjs b/scripts/ci-failures.mjs new file mode 100644 index 000000000..48a7ce023 --- /dev/null +++ b/scripts/ci-failures.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node +// Shows why a pull request's CI failed: the failing jobs and steps, the +// Playwright or Vitest summary from each failed job's log, and the test info, +// error and visible dialogs from every error-context.md in the run's failure +// artifact. Works while a run is still in progress, as soon as a job fails. +// +// Usage: +// npm run ci:failures # PR for the current branch +// npm run ci:failures -- 94 # PR number +// npm run ci:failures -- 37009268147 # workflow run ID +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { join, relative } from "node:path"; + +const DOWNLOAD_ROOT = ".ci-failures"; +const SNAPSHOT_MARKERS = /\b(dialog|alertdialog|alert|heading)\b/; + +function gh(args, options = {}) { + return execFileSync("gh", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024, ...options }); +} + +function ghJson(args, options) { + return JSON.parse(gh(args, options)); +} + +function runsForArgument(argument) { + // Run IDs are 11 digits; PR numbers are small. + if (argument && /^\d{9,}$/.test(argument)) { + return [ghJson(["run", "view", argument, "--json", "databaseId,name,status,conclusion,url"])]; + } + let pr; + try { + pr = ghJson(["pr", "view", ...(argument ? [argument] : []), "--json", "number,headRefOid,url"], { stdio: ["ignore", "pipe", "pipe"] }); + } catch { + console.error(argument ? `No pull request or run ${argument}.` : "No pull request for this branch. Pass a PR number or run ID."); + process.exit(1); + } + console.log(`PR #${pr.number} at ${pr.headRefOid.slice(0, 7)}: ${pr.url}`); + return ghJson([ + "run", "list", "--commit", pr.headRefOid, "--limit", "50", + "--json", "databaseId,name,status,conclusion,url", + ]); +} + +function cleanLog(text) { + return text + .split("\n") + .map((line) => line.replace(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z ?/, "").replace(/\u001b\[[0-9;]*m/g, "").trimEnd()); +} + +// The Playwright summary: "N failed", "N flaky" and their indented test lines. +function playwrightSummary(lines) { + const start = lines.findIndex((line) => /^\s+\d+ (failed|flaky|interrupted)$/.test(line)); + if (start === -1) return []; + const summary = []; + for (const line of lines.slice(start)) { + if (/^\s+\d+ (passed|did not run)/.test(line) || line.startsWith("##[") || !/^\s+\S/.test(line)) break; + if (/^\s+\d+ skipped$/.test(line)) continue; + summary.push(line.replace(/\s*─+$/, "")); + } + return summary; +} + +function vitestFailures(lines) { + return [...new Set(lines.filter((line) => /^\s*(FAIL|×)\s/.test(line) || /^\s*Test Files\s.*failed/.test(line)))]; +} + +function failedJobSummary(jobId) { + let lines; + try { + lines = cleanLog(gh(["api", "--allow-escape-sequences", `repos/{owner}/{repo}/actions/jobs/${jobId}/logs`])); + } catch { + return ["(job log not available yet)"]; + } + const playwright = playwrightSummary(lines); + if (playwright.length) return playwright; + const vitest = vitestFailures(lines); + if (vitest.length) return vitest.slice(0, 40); + // Anything else (lint, typecheck, a shell step): the lines before the exit. + const exit = lines.findIndex((line) => line.startsWith("##[error]Process completed with exit code")); + const end = exit === -1 ? lines.length : exit; + return lines.slice(Math.max(0, end - 25), end + 1).filter((line) => !line.startsWith("##[group]") && line !== "##[endgroup]"); +} + +function findFiles(dir, name, found = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) findFiles(path, name, found); + else if (entry.name === name) found.push(path); + } + return found; +} + +function section(markdown, heading) { + const match = markdown.match(new RegExp(`^# ${heading}\\n([\\s\\S]*?)(?=^# |$(?![\\s\\S]))`, "m")); + return match ? match[1].trim() : ""; +} + +// One entry per test and project; retries share a directory name plus -retryN. +function latestAttempts(files) { + const byTest = new Map(); + for (const file of files) { + const dir = file.split("/").at(-2); + const retry = Number(dir.match(/-retry(\d+)$/)?.[1] ?? 0); + const key = dir.replace(/-retry\d+$/, ""); + const entry = byTest.get(key) ?? { file, retry: -1, attempts: 0 }; + byTest.set(key, { + file: retry > entry.retry ? file : entry.file, + retry: Math.max(retry, entry.retry), + attempts: entry.attempts + 1, + }); + } + return [...byTest.values()]; +} + +function codeBlocks(markdown) { + return [...markdown.matchAll(/```(\w*)\n([\s\S]*?)```/g)].map(([, language, body]) => ({ language, body: body.trim() })); +} + +function printErrorContext(file, attempts) { + const markdown = readFileSync(file, "utf8"); + const info = section(markdown, "Test info").split("\n").filter(Boolean); + console.log(`\n ${info.map((line) => line.replace(/^- /, "")).join("\n ")}`); + if (attempts > 1) console.log(` Attempts with an error context: ${attempts}`); + // Error details hold the error, the call log and sometimes an ARIA snapshot. + const blocks = codeBlocks(section(markdown, "Error details")); + const errors = blocks.filter((block) => block.language !== "yaml").map((block) => block.body); + console.log(` Error:\n ${errors.join("\n\n").split("\n").slice(0, 30).join("\n ")}`); + const snapshots = [...blocks, ...codeBlocks(section(markdown, "Page snapshot"))].filter((block) => block.language === "yaml"); + const visible = [...new Set(snapshots.flatMap((block) => block.body.split("\n")).filter((line) => SNAPSHOT_MARKERS.test(line)).map((line) => line.trim()))]; + if (visible.length) { + console.log(" Dialogs and headings on the page when it failed:"); + for (const line of visible.slice(0, 15)) console.log(` ${line}`); + } + console.log(` Full context: ${relative(process.cwd(), file)}`); +} + +function downloadFailureArtifacts(run) { + const { artifacts } = ghJson(["api", `repos/{owner}/{repo}/actions/runs/${run.databaseId}/artifacts`]); + const failures = artifacts.filter((artifact) => artifact.name.includes("failure")); + if (!failures.length) { + console.log(" No failure artifact on this run. Staging runs upload none on purpose."); + return; + } + for (const artifact of failures) { + if (artifact.expired) { + console.log(` Artifact ${artifact.name} has expired.`); + continue; + } + const dir = join(DOWNLOAD_ROOT, String(run.databaseId), artifact.name); + if (existsSync(dir)) rmSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true }); + gh(["run", "download", String(run.databaseId), "-n", artifact.name, "-D", dir]); + console.log(`\n Artifact ${artifact.name} saved to ${dir}`); + const contexts = latestAttempts(findFiles(dir, "error-context.md")); + if (!contexts.length) console.log(" It has no error-context.md; check its logs and test-results."); + for (const { file, attempts } of contexts) printErrorContext(file, attempts); + } +} + +const runs = runsForArgument(process.argv[2]); +const failed = []; +for (const run of runs) { + const { jobs } = ghJson(["run", "view", String(run.databaseId), "--json", "jobs"]); + const failedJobs = jobs.filter((job) => job.conclusion === "failure"); + if (failedJobs.length) failed.push({ run, failedJobs }); +} + +const pending = runs.filter((run) => run.status !== "completed").map((run) => run.name); +if (!failed.length) { + console.log(pending.length ? `No failed jobs yet. Still running: ${pending.join(", ")}.` : "No failed jobs."); + process.exit(0); +} + +for (const { run, failedJobs } of failed) { + console.log(`\n=== ${run.name} (${run.status === "completed" ? run.conclusion : run.status}): ${run.url}`); + for (const job of failedJobs) { + const steps = job.steps.filter((step) => step.conclusion === "failure").map((step) => step.name); + console.log(`\n- Job ${job.name} failed at: ${steps.join(", ") || "(no failed step reported)"}`); + for (const line of failedJobSummary(job.databaseId)) console.log(` ${line}`); + } + // Artifacts upload at the end of a job, so they exist once the job has failed. + downloadFailureArtifacts(run); +} +if (pending.length) console.log(`\nStill running: ${pending.join(", ")}.`); From 6a0b2f8df25478d4e7a311742ebb9b20c1f3728e Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:42:22 +0000 Subject: [PATCH 2/3] docs(agents): say how to get Node 22 when nvm use fails Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 322342f5b..3473a5f89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,7 @@ - The repository is DebateLab. Never infer its directory from an old repository name. - Run `git rev-parse --show-toplevel` and stay inside that returned worktree. On Adam's VPS, `/home/codex/Projects/DebateLab` is the primary checkout, but Superset and other agents use linked worktrees elsewhere. - Before editing, inspect the branch and working tree. Preserve unrelated changes. -- Use Node 22, npm, and the committed `package-lock.json`. The VPS default is Node 24, so run `nvm use` first (`.nvmrc` pins 22). Install with `npm ci`; T3 worktrees start without `node_modules`. +- Use Node 22, npm, and the committed `package-lock.json`. `.nvmrc` pins 22, but the VPS default is Node 24: check `node -v` and run `nvm use` (with `NVM_DIR=$HOME/.nvm` set) when it isn't 22. Install with `npm ci` when `node_modules` is missing. ## Parallel Superset workspaces From 8c753c87606b5eb6291236c82f49bf4a0b0f81dc Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 2 Oct 2026 18:50:37 +0000 Subject: [PATCH 3/3] fix(dev): pin the e2e base URL to the allocated port; report timed-out jobs An exported PLAYWRIGHT_BASE_URL kept pointing at 18080 while Vite moved to the allocated port. The verify script and documented commands now set both from one allocation, joined with && so an earlier failure in verify still fails the run. ci:failures ignored jobs that ended as timed_out, so a run that only timed out printed "No failed jobs". Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 ++-- docs/development.md | 2 +- docs/testing.md | 4 ++-- package.json | 2 +- scripts/ci-failures.mjs | 8 +++++--- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3473a5f89..29edc3dc3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ npm test -- --coverage npm run test:collaboration npm run test:edge npm run build -CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e +P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e ``` `npm run verify` runs the complete credential-free ladder, including desktop and mobile Chromium against a fresh Vite server. @@ -76,7 +76,7 @@ WebKit is a separate release smoke test: ```bash npx playwright install --with-deps webkit -CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e:webkit +P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e:webkit ``` ## Verifying features in the real app diff --git a/docs/development.md b/docs/development.md index 1faca91f4..88a9c81a0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -143,7 +143,7 @@ Never point local tests at staging or production. Never apply a migration to an [`playwright.config.ts`](../playwright.config.ts) starts Vite on an isolated port and refuses to reuse an existing server. ```bash -CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e +P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e ``` Failure artifacts go to ignored `test-results/` and `playwright-report/`. Protected staging disables traces, screenshots, and video. diff --git a/docs/testing.md b/docs/testing.md index e01546f95..0c446771b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -108,7 +108,7 @@ Run this after `npm run build`. It serves `dist/` through Vite preview and prove ## Credential-free browser gate ```bash -CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e +P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e ``` `.superset/e2e-port.sh` gives each worktree its own port from `18100-18179`, recorded in `~/.superset/debatelab-ports/allocations.json`, so two worktrees can run the gate at once. Without `PORT`, Playwright falls back to `18080`. Playwright starts a new Vite server on that port and runs: @@ -264,7 +264,7 @@ npx playwright install --with-deps webkit Run: ```bash -CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e:webkit +P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e:webkit ``` This is the Safari-engine smoke gate. State explicitly when it did not run. diff --git a/package.json b/package.json index 48a66ec2e..93f19b7da 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "test:e2e:webkit": "playwright test --project=webkit", "test:sw": "node scripts/test-service-worker-offline.mjs", "test:e2e:staging": "playwright test --project=staging", - "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && CI=1 PORT=$(bash .superset/e2e-port.sh) npm run test:e2e" + "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && npm run test:sw && P=$(bash .superset/e2e-port.sh) && CI=1 PORT=$P PLAYWRIGHT_BASE_URL=http://127.0.0.1:$P npm run test:e2e" }, "dependencies": { "@capacitor/app": "^8.1.0", diff --git a/scripts/ci-failures.mjs b/scripts/ci-failures.mjs index 48a7ce023..50788a427 100644 --- a/scripts/ci-failures.mjs +++ b/scripts/ci-failures.mjs @@ -13,6 +13,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:f import { join, relative } from "node:path"; const DOWNLOAD_ROOT = ".ci-failures"; +// A job that hit its timeout-minutes ends as timed_out, not failure. +const FAILED_CONCLUSIONS = new Set(["failure", "timed_out"]); const SNAPSHOT_MARKERS = /\b(dialog|alertdialog|alert|heading)\b/; function gh(args, options = {}) { @@ -162,7 +164,7 @@ const runs = runsForArgument(process.argv[2]); const failed = []; for (const run of runs) { const { jobs } = ghJson(["run", "view", String(run.databaseId), "--json", "jobs"]); - const failedJobs = jobs.filter((job) => job.conclusion === "failure"); + const failedJobs = jobs.filter((job) => FAILED_CONCLUSIONS.has(job.conclusion)); if (failedJobs.length) failed.push({ run, failedJobs }); } @@ -175,8 +177,8 @@ if (!failed.length) { for (const { run, failedJobs } of failed) { console.log(`\n=== ${run.name} (${run.status === "completed" ? run.conclusion : run.status}): ${run.url}`); for (const job of failedJobs) { - const steps = job.steps.filter((step) => step.conclusion === "failure").map((step) => step.name); - console.log(`\n- Job ${job.name} failed at: ${steps.join(", ") || "(no failed step reported)"}`); + const steps = job.steps.filter((step) => FAILED_CONCLUSIONS.has(step.conclusion) || step.conclusion === "cancelled").map((step) => step.name); + console.log(`\n- Job ${job.name} ${job.conclusion === "timed_out" ? "timed out" : "failed"} at: ${steps.join(", ") || "(no failed step reported)"}`); for (const line of failedJobSummary(job.databaseId)) console.log(` ${line}`); } // Artifacts upload at the end of a job, so they exist once the job has failed.