@tekyzinc/gsd-t 3.21.12 → 3.22.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +1 -0
- package/bin/gsd-t.js +201 -14
- package/bin/headless-auto-spawn.cjs +121 -0
- package/bin/playwright-bootstrap.cjs +315 -0
- package/bin/ui-detection.cjs +151 -0
- package/commands/gsd-t-init.md +17 -19
- package/docs/architecture.md +14 -0
- package/docs/requirements.md +27 -0
- package/package.json +6 -1
- package/scripts/hooks/pre-commit-playwright-gate +94 -0
- package/templates/CLAUDE-global.md +11 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [3.22.11] - 2026-05-06
|
|
6
|
+
|
|
7
|
+
### Fixed — viewer Playwright specs are now actually rigorous + adversarially proven (M51)
|
|
8
|
+
|
|
9
|
+
The 5 viewer specs shipped in M50 used substring/existence assertions that would pass on a broken implementation — exactly the M48 BUG-3 LOW pattern that was flagged but never propagated forward. M51 strengthens all 5 specs to outcome-based assertions and proves they catch broken implementations via an adversarial Red Team that writes deliberately-broken viewer code and verifies the specs fail.
|
|
10
|
+
|
|
11
|
+
**Changes:**
|
|
12
|
+
- `e2e/viewer/title.spec.ts`: exact `<title>` equality (not regex); literal `$&` backref defence positive test using a fixture dir literally renamed to contain `$&`.
|
|
13
|
+
- `e2e/viewer/timestamps.spec.ts`: `distinct.size === 3` exactly (not `>= 2`); each rendered timestamp matches the actual `frame.ts` wall-clock value; new missing-`ts` fallback test exercises `arrivedAt` branch.
|
|
14
|
+
- `e2e/viewer/chat-bubbles.spec.ts`: CSS class membership assertions (`.frame.user.user-turn`, `.frame.assistant-turn`, etc.); structural assertions on `.body`/`.prefix`/`.badge`/`.truncated-tag`; `tool_use` 4th renderer coverage.
|
|
15
|
+
- `e2e/viewer/dual-pane.spec.ts`: TEST-M50-001 fix — `MutationObserver` per pane attributes frames by DOM target instead of filtering raw URLs (top pane's `connectMain` URL legitimately contains the in-session id); positive top-pane SSE assertion; hashchange-doesn't-pin-bottom test.
|
|
16
|
+
- `e2e/viewer/lazy-dashboard.spec.ts`: exact regex on banner shape (not substring); dead-pid branch coverage (today's spec only had live + missing).
|
|
17
|
+
- `.gsd-t/red-team-report.md`: new "M51 RED TEAM FINDINGS" section enumerating 5 broken-viewer adversary attempts and confirming all 5 caught.
|
|
18
|
+
- Bonus: fixture port allocation switched from `Math.random()*100` to `port: 0` + `server.address().port` readback (eliminates EADDRINUSE collisions across parallel Playwright workers).
|
|
19
|
+
|
|
20
|
+
**Adversarial Red Team result:** 5 broken viewer impls written, 5 caught by strengthened specs. Production viewer code unchanged — this was pure test-suite rigor work, no real app bugs found behind adversary patches.
|
|
21
|
+
|
|
22
|
+
**Suite:** 2166/2166 pass; E2E 19/19 pass (was 9 — 5 specs grew from 9 tests to 19).
|
|
23
|
+
|
|
24
|
+
**Migration:** none. Specs are stricter; existing v3.22.10 viewer continues to pass.
|
|
25
|
+
|
|
26
|
+
## [3.22.10] — M50 Universal Playwright Bootstrap + Deterministic UI Enforcement
|
|
27
|
+
|
|
28
|
+
### Added — three deterministic enforcement layers replace prose-only Playwright Readiness Guard
|
|
29
|
+
|
|
30
|
+
Closes the gap that allowed M48 viewer fixes to ship without Playwright tests despite the existing prose Readiness Guard. Prior pattern was prose in `~/.claude/CLAUDE.md` + per-command-file reminders that agents could read and decide to skip. M50 converts every layer to executable code so agents cannot self-approve their way around it.
|
|
31
|
+
|
|
32
|
+
**Layer 1 — Bootstrap library (D1):**
|
|
33
|
+
- `bin/playwright-bootstrap.cjs` (new) — `hasPlaywright`, `detectPackageManager`, `installPlaywright`, `installPlaywrightSync`, `verifyPlaywrightHealth`. Idempotent installer: detects package manager (npm/pnpm/yarn/bun), installs `@playwright/test` + chromium, writes `playwright.config.ts` from a single-source template (contract §6), scaffolds `e2e/__placeholder.spec.ts`. Preserves existing config + e2e contents on re-run. Error classifier maps subprocess stderr to `{package-manager-not-found, network, chromium, disk}` with caller-actionable hints; chromium-failure surfaces `partial: true`. Zero external runtime deps.
|
|
34
|
+
- `bin/ui-detection.cjs` (new) — `hasUI`, `detectUIFlavor`. Synchronous, depth-bounded ≤3 BFS, never throws. Detects React/Vue/Svelte/Next/Angular/Flutter/Tailwind via `package.json`, `pubspec.yaml`, `tailwind.config.{js,ts,mjs,cjs}`, or any UI extension (`.tsx`/`.jsx`/`.vue`/`.svelte`/`.css`/`.scss`).
|
|
35
|
+
- `bin/gsd-t.js`: inline `hasPlaywright` (was lines 201-204) replaced with `require('./playwright-bootstrap.cjs')`. `init` flow auto-installs Playwright when `hasUI && !hasPlaywright`. `update-all` auto-installs across all registered UI projects + reports counts (`Auto-installed Playwright in: N project(s)`). New `gsd-t setup-playwright [path] [--force]` subcommand. New `gsd-t doctor --install-playwright` flag. New `gsd-t doctor --install-hooks` flag (D2).
|
|
36
|
+
|
|
37
|
+
**Layer 2 — Spawn-time gate (D2):**
|
|
38
|
+
- `bin/headless-auto-spawn.cjs::autoSpawnHeadless()`: new gate runs before the spawn. When the command is in the `TESTING_OR_UI_COMMANDS` whitelist (9 commands: execute, test-sync, verify, quick, wave, milestone, complete-milestone, debug, integrate) AND `hasUI(projectDir)` AND `!hasPlaywright(projectDir)`, the gate calls `installPlaywrightSync(projectDir)` synchronously. On install failure, writes `mode: 'blocked-needs-human'` + `reason: 'playwright-install-failed'` to the headless session-state file and exits 4. Hot path: three filesystem checks; no install attempt when gate doesn't fire.
|
|
39
|
+
|
|
40
|
+
**Layer 3 — Commit-time gate (D2):**
|
|
41
|
+
- `scripts/hooks/pre-commit-playwright-gate` (new, executable bash) — opt-in via `gsd-t doctor --install-hooks`. Reads `.gsd-t/.last-playwright-pass` (Unix epoch ms in a single line). Detects staged viewer-source files (`scripts/gsd-t-transcript.html`, `scripts/gsd-t-dashboard-server.js`, `e2e/viewer/**`); if any file's mtime exceeds the last-pass timestamp, blocks the commit (exit 1 + clear stderr message). Fails open on missing/corrupt timestamps — broken hook is worse than a permissive one.
|
|
42
|
+
|
|
43
|
+
**E2E specs delivered (the M47/M48/M49 viewer specs we owed):**
|
|
44
|
+
- `playwright.config.ts` (root) — testDir `./e2e`, chromium project, `webServer` omitted (specs manage their own server lifecycle).
|
|
45
|
+
- `e2e/viewer/title.spec.ts` — M48 Bug 1 regression (project basename in `<title>` + header `.title` for `/transcripts` and `/transcripts/{spawn-id}`).
|
|
46
|
+
- `e2e/viewer/timestamps.spec.ts` — M48 Bug 2 regression (per-frame `frame.ts`, not per-batch `new Date()`).
|
|
47
|
+
- `e2e/viewer/chat-bubbles.spec.ts` — M48 Bug 3 regression (`user_turn`/`assistant_turn`/`session_start`/`tool_use_line` render as bubbles, not JSON.stringify dumps).
|
|
48
|
+
- `e2e/viewer/dual-pane.spec.ts` — M48 Bug 4 regression (bottom pane never connects to in-session-* SSE).
|
|
49
|
+
- `e2e/viewer/lazy-dashboard.spec.ts` — M49 banner regression (file-path + visualize hint when no dashboard; URL when alive).
|
|
50
|
+
|
|
51
|
+
**Tests:**
|
|
52
|
+
- `test/m50-d1-ui-detection.test.js` — 18 unit tests (8 mandatory fixtures + 4 hardening + 6 Red Team regressions).
|
|
53
|
+
- `test/m50-d1-playwright-bootstrap.test.js` — 20 unit tests (`hasPlaywright`, `detectPackageManager`, `verifyPlaywrightHealth`, 9 install-path branches).
|
|
54
|
+
- `test/m50-d1-cli-integration.test.js` — 5 CLI wire-up tests (re-export, init gate, doctor flag, setup-playwright).
|
|
55
|
+
- `test/m50-d2-viewer-specs-smoke.test.js` — 4 meta-tests (config + scripts + zero-runtime-dep invariant).
|
|
56
|
+
- `test/m50-d2-spawn-gate.test.js` — 9 gate-firing matrix tests (whitelist + 5 firing scenarios + hot-path overhead).
|
|
57
|
+
- `test/m50-d2-pre-commit-hook.test.js` — 6 hook-behavior tests (clean / blocked / fresh / missing / corrupt / e2e/viewer pattern).
|
|
58
|
+
|
|
59
|
+
Total: 62 new M50 tests; full suite 2163/2166 (3 pre-existing env-flakes preserved). Zero regressions.
|
|
60
|
+
|
|
61
|
+
**Doc-ripple:**
|
|
62
|
+
- `~/.claude/CLAUDE.md` § Playwright Readiness Guard — collapsed to a layered referral.
|
|
63
|
+
- `templates/CLAUDE-global.md` — mirror.
|
|
64
|
+
- `commands/gsd-t-init.md` Step 11 — points at `installPlaywright()` instead of carrying inline package-manager commands.
|
|
65
|
+
- `docs/architecture.md` — new "Playwright Deterministic Enforcement (M50)" subsection.
|
|
66
|
+
- `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0 — new contract.
|
|
67
|
+
- `.gsd-t/contracts/m50-integration-points.md` — D1→D2 checkpoint flipped to PUBLISHED.
|
|
68
|
+
|
|
5
69
|
## [3.21.12] - 2026-05-06
|
|
6
70
|
|
|
7
71
|
### Fixed — dashboard orphan accumulation (M49 lazy autostart + idle-TTL + doctor-prune)
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ A methodology for reliable, parallelizable development using Claude Code with op
|
|
|
16
16
|
**External Task Orchestrator + Streaming Watcher UI (M40, v3.14.10)** — JS orchestrator drives `claude -p` one task per spawn: short-lived, fresh context, architecturally compaction-free. Benchmarks 0.72× wall-clock vs in-session on 20-task/3-wave workloads. Paired with a zero-Claude-cost local streaming UI at `127.0.0.1:7842` that renders all workers' stream-json output as a continuous claude.ai-style feed — task/wave banners, duration + usage chips, token corner bar, localStorage filters, replay via `WS /feed?from=N`. Recovery: `--resume` reconciles interrupted runs using commit + progress.md evidence; ambiguous tasks (commit without progress entry) are flagged for operator triage, never silently claimed done. CLI: `gsd-t orchestrate`, `gsd-t benchmark-orchestrator`, `gsd-t stream-feed`. Contracts: `stream-json-sink-contract.md` v1.1.0, `wave-join-contract.md`, `completion-signal-contract.md`, `metrics-schema-contract.md`.
|
|
17
17
|
**Always-Headless Spawn (M43 D4, v3.16.x+) — Channel Separation** — every GSD-T command spawns detached, unconditionally. No `--watch`, no `--in-session`, no `--headless` opt-in, no context-meter threshold that reroutes. The dialog channel is reserved for human↔Claude conversation; every workflow turn is a detached headless child. Interactive session shows a launch banner + live-transcript URL + event-stream path, then exits — results surface via the read-back banner on the user's next message. Detached workers emit JSONL events to `.gsd-t/events/YYYY-MM-DD.jsonl` at every phase boundary — shared by dashboard and (historically) the watch command. The only in-session surface is the `/gsd` router (for dialog-only exploratory turns). See `.gsd-t/contracts/headless-default-contract.md` v2.0.0 and `unattended-event-stream-contract.md` v1.0.0.
|
|
18
18
|
**Live Transcript as Primary Surface (M43 D6, v3.16.13 — extended in M47, v3.21.10)** — every detached spawn prints a one-line banner (`▶ Live transcript: http://127.0.0.1:{port}/transcript/{id}`) pointing at a browser viewer that SSE-streams the child's stdout and renders a collapsible "Tool Cost" sidebar panel showing per-tool attributed tokens and cost (sourced from `/transcript/:id/tool-cost`, which proxies to the M43 D2 tool-attribution library). The dashboard server auto-starts (`scripts/gsd-t-dashboard-autostart.cjs`) idempotently on each spawn — a port probe backs off when a server is already running, otherwise a fork-detach writes `.gsd-t/.dashboard.pid`. Port is project-scoped via `projectScopedDefaultPort(projectDir)` so multi-project workflows don't clobber each other.
|
|
19
|
+
**Universal Playwright Bootstrap + Deterministic UI Enforcement (M50, v3.22.10)** — converts the prose-only "Playwright Readiness Guard" into three executable enforcement layers so agents cannot skip UI tests. Layer 1: `bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs` wired into `init`/`update-all`/`doctor`/new `gsd-t setup-playwright [path]` subcommand — idempotent installer detects package manager, installs `@playwright/test` + chromium, writes config from a contract-locked template, scaffolds `e2e/`. Layer 2: `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` auto-installs before any of 9 whitelisted testing/UI commands when `hasUI && !hasPlaywright`; install failure → `mode: 'blocked-needs-human'` exit-4. Layer 3: `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) reads `.gsd-t/.last-playwright-pass` and blocks viewer-source commits when staged files are newer than the last playwright test pass. Also ships the M47/M48/M49 E2E viewer specs (`e2e/viewer/title`, `timestamps`, `chat-bubbles`, `dual-pane`, `lazy-dashboard`). 62 new unit tests; E2E 8/9 pass.
|
|
19
20
|
**Focused Visualizer Redesign (M47, v3.21.10)** — `/transcripts` opens directly to a dual-pane focused view: the **top pane** auto-streams the orchestrator's main in-session conversation (zero clicks — fetched via new `GET /api/main-session`), and the **bottom pane** streams whichever spawn the user clicks. A keyboard- and mouse-resizable splitter sits between them, with position persisted in `sessionStorage` (along with selection, completed-section toggle, and right-rail collapsed state). The left rail splits into three sections — `★ Main Session`, `Live Spawns`, and `Completed` (last 100 spawns, newest first, status-badged, collapsible). When a spawn transitions running → completed it moves rail sections live without a full reload, and stays selected if focused. The right rail (Spawn Plan / Parallelism / Tool Cost) is preserved under a new collapsible toggle. Bookmarks to `/transcript/:spawnId` continue to land that spawn pre-selected in the bottom pane. Contract: `dashboard-server-contract.md` v1.3.0 (additive — `status: 'active' \| 'completed'` field on in-session entries, derived from a 30s mtime window; `/api/main-session` endpoint with path-traversal guard + `Cache-Control: no-store`).
|
|
20
21
|
- **Surgical model selection** — `bin/model-selector.js` assigns haiku/sonnet/opus per phase via a declarative rules table; `/advisor` escalation path with convention-based fallback.
|
|
21
22
|
- **Per-spawn token telemetry** — `.gsd-t/token-metrics.jsonl` records one 18-field row per Task subagent spawn.
|
package/bin/gsd-t.js
CHANGED
|
@@ -198,10 +198,15 @@ function copyFile(src, dest, label) {
|
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
201
|
+
// M50 D1: hasPlaywright migrated to bin/playwright-bootstrap.cjs.
|
|
202
|
+
// Re-exported here for back-compat with any caller still requiring it via
|
|
203
|
+
// bin/gsd-t.js. See .gsd-t/contracts/playwright-bootstrap-contract.md §3.
|
|
204
|
+
const {
|
|
205
|
+
hasPlaywright,
|
|
206
|
+
installPlaywright,
|
|
207
|
+
detectPackageManager: _detectPlaywrightPackageManager,
|
|
208
|
+
} = require("./playwright-bootstrap.cjs");
|
|
209
|
+
const { hasUI } = require("./ui-detection.cjs");
|
|
205
210
|
|
|
206
211
|
function readProjectDeps(projectDir) {
|
|
207
212
|
const pkgPath = path.join(projectDir, "package.json");
|
|
@@ -571,6 +576,47 @@ function installContextMeter(projectDir) {
|
|
|
571
576
|
// to .git/hooks/pre-commit; if the file doesn't exist, copies our stock
|
|
572
577
|
// script. Never overwrites an existing hook.
|
|
573
578
|
const CAPTURE_LINT_HOOK_MARKER = "# GSD-T capture lint";
|
|
579
|
+
// M50 D2 — Playwright pre-commit gate marker. Installed via
|
|
580
|
+
// `gsd-t doctor --install-hooks`. Idempotent: appends a delimited block to
|
|
581
|
+
// `.git/hooks/pre-commit` if the marker isn't already present.
|
|
582
|
+
const PLAYWRIGHT_GATE_HOOK_MARKER = "# GSD-T playwright gate";
|
|
583
|
+
|
|
584
|
+
function installPlaywrightGateHook(projectDir) {
|
|
585
|
+
const gitDir = path.join(projectDir, ".git");
|
|
586
|
+
if (!fs.existsSync(gitDir)) {
|
|
587
|
+
warn("No .git directory — not a git repo; skipping playwright-gate install");
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
591
|
+
try { fs.mkdirSync(hooksDir, { recursive: true }); } catch (_) {}
|
|
592
|
+
const hookPath = path.join(hooksDir, "pre-commit");
|
|
593
|
+
const stockSrc = path.join(PKG_ROOT, "scripts", "hooks", "pre-commit-playwright-gate");
|
|
594
|
+
let stock = "";
|
|
595
|
+
try { stock = fs.readFileSync(stockSrc, "utf8"); } catch (_) {
|
|
596
|
+
warn("Could not read pre-commit-playwright-gate script from package");
|
|
597
|
+
return false;
|
|
598
|
+
}
|
|
599
|
+
if (!fs.existsSync(hookPath)) {
|
|
600
|
+
fs.writeFileSync(hookPath, stock);
|
|
601
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
602
|
+
success(`Playwright gate installed at ${path.relative(projectDir, hookPath)}`);
|
|
603
|
+
info("The hook reads .gsd-t/.last-playwright-pass to gate viewer-source commits");
|
|
604
|
+
return true;
|
|
605
|
+
}
|
|
606
|
+
const existing = fs.readFileSync(hookPath, "utf8");
|
|
607
|
+
if (existing.includes(PLAYWRIGHT_GATE_HOOK_MARKER)) {
|
|
608
|
+
info("Playwright-gate block already present in pre-commit hook — no change");
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
const appended = existing.trimEnd() +
|
|
612
|
+
"\n\n" + PLAYWRIGHT_GATE_HOOK_MARKER + "\n" +
|
|
613
|
+
stock.replace(/^#!.*\n/, "") + "\n";
|
|
614
|
+
fs.writeFileSync(hookPath, appended);
|
|
615
|
+
try { fs.chmodSync(hookPath, 0o755); } catch (_) {}
|
|
616
|
+
success(`Playwright-gate block appended to ${path.relative(projectDir, hookPath)}`);
|
|
617
|
+
return true;
|
|
618
|
+
}
|
|
619
|
+
|
|
574
620
|
function installCaptureLintHook(projectDir) {
|
|
575
621
|
const gitDir = path.join(projectDir, ".git");
|
|
576
622
|
if (!fs.existsSync(gitDir)) {
|
|
@@ -1655,6 +1701,25 @@ async function doInit(projectName) {
|
|
|
1655
1701
|
|
|
1656
1702
|
if (registerProject(projectDir)) success("Registered in ~/.claude/.gsd-t-projects");
|
|
1657
1703
|
|
|
1704
|
+
// M50 D1: Universal Playwright bootstrap. If the project has UI signal but
|
|
1705
|
+
// no playwright.config.*, install @playwright/test + chromium and scaffold
|
|
1706
|
+
// playwright.config.ts + e2e/. See playwright-bootstrap-contract.md §5.
|
|
1707
|
+
if (hasUI(projectDir) && !hasPlaywright(projectDir)) {
|
|
1708
|
+
info("Installing Playwright (chromium)…");
|
|
1709
|
+
try {
|
|
1710
|
+
const r = await installPlaywright(projectDir);
|
|
1711
|
+
if (r.ok) {
|
|
1712
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
1713
|
+
} else {
|
|
1714
|
+
warn(`Playwright install failed: ${r.err}`);
|
|
1715
|
+
if (r.hint) info(r.hint);
|
|
1716
|
+
}
|
|
1717
|
+
} catch (e) {
|
|
1718
|
+
warn(`Playwright install errored: ${e.message || e}`);
|
|
1719
|
+
info("Re-run with: gsd-t doctor --install-playwright");
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1658
1723
|
showInitTree(projectDir);
|
|
1659
1724
|
}
|
|
1660
1725
|
|
|
@@ -1968,10 +2033,12 @@ function createProjectChangelog(projectDir, projectName) {
|
|
|
1968
2033
|
}
|
|
1969
2034
|
}
|
|
1970
2035
|
|
|
1971
|
-
function checkProjectHealth(projects) {
|
|
2036
|
+
async function checkProjectHealth(projects) {
|
|
1972
2037
|
heading("Project Health");
|
|
1973
2038
|
const playwrightMissing = [];
|
|
1974
2039
|
const swaggerMissing = [];
|
|
2040
|
+
const playwrightAutoInstalled = [];
|
|
2041
|
+
const playwrightInstallFailed = [];
|
|
1975
2042
|
|
|
1976
2043
|
for (const projectDir of projects) {
|
|
1977
2044
|
if (!fs.existsSync(projectDir)) continue;
|
|
@@ -1980,19 +2047,56 @@ function checkProjectHealth(projects) {
|
|
|
1980
2047
|
if (hasApi(projectDir) && !hasSwagger(projectDir)) swaggerMissing.push(name);
|
|
1981
2048
|
}
|
|
1982
2049
|
|
|
2050
|
+
// M50 D1: auto-install Playwright for any UI project that's missing it.
|
|
2051
|
+
// Non-UI projects stay missing — they don't need Playwright.
|
|
2052
|
+
for (const projectDir of projects) {
|
|
2053
|
+
if (!fs.existsSync(projectDir)) continue;
|
|
2054
|
+
if (hasPlaywright(projectDir)) continue;
|
|
2055
|
+
if (!hasUI(projectDir)) continue;
|
|
2056
|
+
const name = path.basename(projectDir);
|
|
2057
|
+
try {
|
|
2058
|
+
const r = await installPlaywright(projectDir);
|
|
2059
|
+
if (r.ok) playwrightAutoInstalled.push(name);
|
|
2060
|
+
else playwrightInstallFailed.push({ name, err: r.err, hint: r.hint });
|
|
2061
|
+
} catch (e) {
|
|
2062
|
+
playwrightInstallFailed.push({ name, err: e.message || String(e) });
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1983
2066
|
if (playwrightMissing.length === 0 && swaggerMissing.length === 0) {
|
|
1984
2067
|
success("All projects have Playwright and Swagger configured");
|
|
1985
2068
|
} else {
|
|
1986
2069
|
if (playwrightMissing.length > 0) {
|
|
1987
2070
|
warn(`Playwright missing: ${playwrightMissing.join(", ")}`);
|
|
1988
|
-
|
|
2071
|
+
if (playwrightAutoInstalled.length > 0) {
|
|
2072
|
+
success(`Auto-installed Playwright in: ${playwrightAutoInstalled.join(", ")}`);
|
|
2073
|
+
}
|
|
2074
|
+
if (playwrightInstallFailed.length > 0) {
|
|
2075
|
+
for (const f of playwrightInstallFailed) {
|
|
2076
|
+
warn(` ${f.name} — install failed: ${f.err}`);
|
|
2077
|
+
if (f.hint) info(` ${f.hint}`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
const stillMissing = playwrightMissing.filter(
|
|
2081
|
+
(n) => !playwrightAutoInstalled.includes(n),
|
|
2082
|
+
);
|
|
2083
|
+
if (stillMissing.length > 0) {
|
|
2084
|
+
info(
|
|
2085
|
+
`Remaining (no UI signal — skipped): ${stillMissing.join(", ")}`,
|
|
2086
|
+
);
|
|
2087
|
+
}
|
|
1989
2088
|
}
|
|
1990
2089
|
if (swaggerMissing.length > 0) {
|
|
1991
2090
|
warn(`Swagger/OpenAPI missing (API detected): ${swaggerMissing.join(", ")}`);
|
|
1992
2091
|
info("Swagger will be auto-configured when an API endpoint is created or modified");
|
|
1993
2092
|
}
|
|
1994
2093
|
}
|
|
1995
|
-
return {
|
|
2094
|
+
return {
|
|
2095
|
+
playwrightMissing,
|
|
2096
|
+
swaggerMissing,
|
|
2097
|
+
playwrightAutoInstalled,
|
|
2098
|
+
playwrightInstallFailed,
|
|
2099
|
+
};
|
|
1996
2100
|
}
|
|
1997
2101
|
|
|
1998
2102
|
// ── Global Rule Sync (M27) ──────────────────────────────────────────────────
|
|
@@ -2163,8 +2267,19 @@ async function doUpdateAll() {
|
|
|
2163
2267
|
// Global rule sync — propagate proven rules across projects
|
|
2164
2268
|
const syncCount = syncGlobalRules(projects);
|
|
2165
2269
|
|
|
2166
|
-
const {
|
|
2167
|
-
|
|
2270
|
+
const {
|
|
2271
|
+
playwrightMissing,
|
|
2272
|
+
swaggerMissing,
|
|
2273
|
+
playwrightAutoInstalled,
|
|
2274
|
+
} = await checkProjectHealth(projects);
|
|
2275
|
+
showUpdateAllSummary(
|
|
2276
|
+
projects.length,
|
|
2277
|
+
counts,
|
|
2278
|
+
playwrightMissing,
|
|
2279
|
+
swaggerMissing,
|
|
2280
|
+
syncCount,
|
|
2281
|
+
playwrightAutoInstalled,
|
|
2282
|
+
);
|
|
2168
2283
|
}
|
|
2169
2284
|
|
|
2170
2285
|
// Upgrade the globally-installed @tekyzinc/gsd-t to @latest. Returns
|
|
@@ -2510,7 +2625,14 @@ function ensureUnattendedGitignore(projectDir, projectName) {
|
|
|
2510
2625
|
return added;
|
|
2511
2626
|
}
|
|
2512
2627
|
|
|
2513
|
-
function showUpdateAllSummary(
|
|
2628
|
+
function showUpdateAllSummary(
|
|
2629
|
+
total,
|
|
2630
|
+
counts,
|
|
2631
|
+
playwrightMissing,
|
|
2632
|
+
swaggerMissing,
|
|
2633
|
+
syncCount,
|
|
2634
|
+
playwrightAutoInstalled,
|
|
2635
|
+
) {
|
|
2514
2636
|
log("");
|
|
2515
2637
|
heading("Update All Complete");
|
|
2516
2638
|
log(` Projects registered: ${total}`);
|
|
@@ -2519,6 +2641,9 @@ function showUpdateAllSummary(total, counts, playwrightMissing, swaggerMissing,
|
|
|
2519
2641
|
if (counts.missing > 0) log(` Not found: ${counts.missing}`);
|
|
2520
2642
|
if (counts.errors > 0) log(` Errors: ${counts.errors}`);
|
|
2521
2643
|
if (playwrightMissing.length > 0) log(` Missing Playwright: ${playwrightMissing.length}`);
|
|
2644
|
+
if (Array.isArray(playwrightAutoInstalled) && playwrightAutoInstalled.length > 0) {
|
|
2645
|
+
log(` Auto-installed Playwright in: ${playwrightAutoInstalled.length} project(s)`);
|
|
2646
|
+
}
|
|
2522
2647
|
if (swaggerMissing.length > 0) log(` Missing Swagger: ${swaggerMissing.length}`);
|
|
2523
2648
|
if (syncCount > 0) log(` Global rules synced: ${syncCount}`);
|
|
2524
2649
|
log("");
|
|
@@ -2604,14 +2729,32 @@ function checkDoctorEncoding(installed) {
|
|
|
2604
2729
|
return 0;
|
|
2605
2730
|
}
|
|
2606
2731
|
|
|
2607
|
-
function checkDoctorProject() {
|
|
2732
|
+
async function checkDoctorProject(opts) {
|
|
2608
2733
|
let issues = 0;
|
|
2609
2734
|
const cwd = process.cwd();
|
|
2735
|
+
const installFlag = !!(opts && opts.installPlaywright);
|
|
2610
2736
|
if (hasPlaywright(cwd)) {
|
|
2611
2737
|
success("Playwright configured");
|
|
2738
|
+
} else if (installFlag && hasUI(cwd)) {
|
|
2739
|
+
info("Installing Playwright (chromium)…");
|
|
2740
|
+
try {
|
|
2741
|
+
const r = await installPlaywright(cwd);
|
|
2742
|
+
if (r.ok) {
|
|
2743
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
2744
|
+
} else {
|
|
2745
|
+
error(`Playwright install failed: ${r.err}`);
|
|
2746
|
+
if (r.hint) info(r.hint);
|
|
2747
|
+
issues++;
|
|
2748
|
+
}
|
|
2749
|
+
} catch (e) {
|
|
2750
|
+
error(`Playwright install errored: ${e.message || e}`);
|
|
2751
|
+
issues++;
|
|
2752
|
+
}
|
|
2753
|
+
} else if (installFlag && !hasUI(cwd)) {
|
|
2754
|
+
info("Playwright skipped — no UI signal in this project");
|
|
2612
2755
|
} else {
|
|
2613
2756
|
warn("Playwright not configured in this project");
|
|
2614
|
-
info("
|
|
2757
|
+
info("Re-run with --install-playwright, or run any GSD-T testing command (auto-install)");
|
|
2615
2758
|
issues++;
|
|
2616
2759
|
}
|
|
2617
2760
|
if (hasApi(cwd)) {
|
|
@@ -2902,10 +3045,16 @@ async function doDoctor(opts) {
|
|
|
2902
3045
|
let issues = 0;
|
|
2903
3046
|
issues += checkDoctorEnvironment();
|
|
2904
3047
|
issues += checkDoctorInstallation();
|
|
2905
|
-
issues += checkDoctorProject();
|
|
3048
|
+
issues += await checkDoctorProject(opts);
|
|
2906
3049
|
issues += checkDoctorCgc();
|
|
2907
3050
|
issues += await checkDoctorContextMeter(process.cwd());
|
|
2908
3051
|
issues += checkDoctorDashboardOrphans(opts);
|
|
3052
|
+
// M50 D2: opt-in install of the playwright-gate pre-commit hook.
|
|
3053
|
+
if (opts && opts.installHooks) {
|
|
3054
|
+
log("");
|
|
3055
|
+
heading("Installing pre-commit hooks");
|
|
3056
|
+
installPlaywrightGateHook(process.cwd());
|
|
3057
|
+
}
|
|
2909
3058
|
log("");
|
|
2910
3059
|
if (issues === 0) {
|
|
2911
3060
|
log(`${GREEN}${BOLD} All checks passed!${RESET}`);
|
|
@@ -4176,13 +4325,51 @@ if (require.main === module) {
|
|
|
4176
4325
|
doUninstall();
|
|
4177
4326
|
break;
|
|
4178
4327
|
case "doctor": {
|
|
4179
|
-
const doctorOpts = { prune: false };
|
|
4328
|
+
const doctorOpts = { prune: false, installPlaywright: false, installHooks: false };
|
|
4180
4329
|
for (let i = 1; i < args.length; i++) {
|
|
4181
4330
|
if (args[i] === "--prune") doctorOpts.prune = true;
|
|
4331
|
+
if (args[i] === "--install-playwright") doctorOpts.installPlaywright = true;
|
|
4332
|
+
if (args[i] === "--install-hooks") doctorOpts.installHooks = true;
|
|
4182
4333
|
}
|
|
4183
4334
|
doDoctor(doctorOpts).catch((e) => { error(e.message || String(e)); process.exit(1); });
|
|
4184
4335
|
break;
|
|
4185
4336
|
}
|
|
4337
|
+
case "setup-playwright": {
|
|
4338
|
+
// Single-project explicit installer. Thin wrapper around installPlaywright().
|
|
4339
|
+
const targetDir = args[1] && !args[1].startsWith("-") ? path.resolve(args[1]) : process.cwd();
|
|
4340
|
+
heading(`Setup Playwright: ${targetDir}`);
|
|
4341
|
+
log("");
|
|
4342
|
+
if (!fs.existsSync(targetDir)) {
|
|
4343
|
+
error(`Path not found: ${targetDir}`);
|
|
4344
|
+
process.exit(1);
|
|
4345
|
+
}
|
|
4346
|
+
if (hasPlaywright(targetDir)) {
|
|
4347
|
+
info("Playwright already configured (playwright.config.* present)");
|
|
4348
|
+
process.exit(0);
|
|
4349
|
+
}
|
|
4350
|
+
if (!hasUI(targetDir)) {
|
|
4351
|
+
warn("No UI signal detected. Installing anyway? Re-run with --force to install regardless.");
|
|
4352
|
+
const force = args.includes("--force");
|
|
4353
|
+
if (!force) {
|
|
4354
|
+
info("Skipping. Use --force to install Playwright in a non-UI project.");
|
|
4355
|
+
process.exit(0);
|
|
4356
|
+
}
|
|
4357
|
+
}
|
|
4358
|
+
info("Installing Playwright (chromium)…");
|
|
4359
|
+
installPlaywright(targetDir)
|
|
4360
|
+
.then((r) => {
|
|
4361
|
+
if (r.ok) {
|
|
4362
|
+
success("Playwright installed (playwright.config.ts + e2e/ scaffold)");
|
|
4363
|
+
process.exit(0);
|
|
4364
|
+
} else {
|
|
4365
|
+
error(`Playwright install failed: ${r.err}`);
|
|
4366
|
+
if (r.hint) info(r.hint);
|
|
4367
|
+
process.exit(1);
|
|
4368
|
+
}
|
|
4369
|
+
})
|
|
4370
|
+
.catch((e) => { error(e.message || String(e)); process.exit(1); });
|
|
4371
|
+
break;
|
|
4372
|
+
}
|
|
4186
4373
|
case "changelog":
|
|
4187
4374
|
doChangelog();
|
|
4188
4375
|
break;
|
|
@@ -42,6 +42,31 @@ const {
|
|
|
42
42
|
const SESSIONS_DIR_REL = path.join(".gsd-t", "headless-sessions");
|
|
43
43
|
const LOG_DIR_REL = ".gsd-t"; // headless-{id}.log lives directly in .gsd-t
|
|
44
44
|
|
|
45
|
+
// M50 D2 — Whitelist of commands that touch UI / tests. Centralized constant
|
|
46
|
+
// referenced by the spawn-gate (`_isTestingOrUICommand`). When any of these
|
|
47
|
+
// commands is about to spawn for a project where `hasUI(projectDir) === true`
|
|
48
|
+
// and `hasPlaywright(projectDir) === false`, the gate auto-installs Playwright
|
|
49
|
+
// before the spawn proceeds. See playwright-bootstrap-contract.md §3 +
|
|
50
|
+
// m50-integration-points.md "E2E Specs Server Lifecycle".
|
|
51
|
+
const TESTING_OR_UI_COMMANDS = new Set([
|
|
52
|
+
"gsd-t-execute",
|
|
53
|
+
"gsd-t-test-sync",
|
|
54
|
+
"gsd-t-verify",
|
|
55
|
+
"gsd-t-quick",
|
|
56
|
+
"gsd-t-wave",
|
|
57
|
+
"gsd-t-milestone",
|
|
58
|
+
"gsd-t-complete-milestone",
|
|
59
|
+
"gsd-t-debug",
|
|
60
|
+
"gsd-t-integrate",
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
function _isTestingOrUICommand(command) {
|
|
64
|
+
if (typeof command !== "string" || !command) return false;
|
|
65
|
+
// Accept both raw command names and prefixed variants ("execute" or "gsd-t-execute").
|
|
66
|
+
const normalized = command.startsWith("gsd-t-") ? command : `gsd-t-${command}`;
|
|
67
|
+
return TESTING_OR_UI_COMMANDS.has(normalized);
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
// ── Exports ──────────────────────────────────────────────────────────────────
|
|
46
71
|
|
|
47
72
|
module.exports = {
|
|
@@ -57,6 +82,9 @@ module.exports = {
|
|
|
57
82
|
shouldSpawnHeadless: () => true,
|
|
58
83
|
// M49 — exported for tests. Synchronous probe; never throws.
|
|
59
84
|
_probeDashboardLazy,
|
|
85
|
+
// M50 D2 — exported for tests. Whitelist + classifier, both synchronous.
|
|
86
|
+
TESTING_OR_UI_COMMANDS,
|
|
87
|
+
_isTestingOrUICommand,
|
|
60
88
|
};
|
|
61
89
|
|
|
62
90
|
// M43 D4 — one-shot deprecation banner when a caller still passes `watch`
|
|
@@ -121,6 +149,99 @@ function autoSpawnHeadless(opts) {
|
|
|
121
149
|
);
|
|
122
150
|
}
|
|
123
151
|
|
|
152
|
+
// M50 D2 — Spawn-time Playwright gate. When the command being spawned is a
|
|
153
|
+
// testing/UI-touching command AND the project has a UI signal AND there's
|
|
154
|
+
// no playwright.config.* yet, auto-install Playwright before the spawn
|
|
155
|
+
// proceeds. On install failure, write a `mode: 'blocked-needs-human'`
|
|
156
|
+
// session-state file (read by gsd-t-resume Step 0 + read-back banner) and
|
|
157
|
+
// exit 4. See playwright-bootstrap-contract.md §3 + §8 and
|
|
158
|
+
// m50-integration-points.md for the full protocol.
|
|
159
|
+
//
|
|
160
|
+
// Hot path: the gate evaluates `_isTestingOrUICommand` (Set lookup) →
|
|
161
|
+
// `hasUI` (depth-bounded fs walk) → `hasPlaywright` (existsSync). When the
|
|
162
|
+
// gate does NOT fire (non-testing command, non-UI project, or already
|
|
163
|
+
// configured), overhead is the cost of three fast filesystem checks.
|
|
164
|
+
// Tests inject `opts._gateInstaller` to substitute a stub installer; tests
|
|
165
|
+
// also inject `opts._gateProbes` to override `hasUI`/`hasPlaywright` for
|
|
166
|
+
// non-fixture-based assertions.
|
|
167
|
+
if (_isTestingOrUICommand(command)) {
|
|
168
|
+
const probes = (opts && opts._gateProbes) || null;
|
|
169
|
+
let projectHasUI;
|
|
170
|
+
let projectHasPlaywright;
|
|
171
|
+
try {
|
|
172
|
+
const ui = probes && probes.hasUI ? probes.hasUI : require("./ui-detection.cjs").hasUI;
|
|
173
|
+
const pw = probes && probes.hasPlaywright ? probes.hasPlaywright : require("./playwright-bootstrap.cjs").hasPlaywright;
|
|
174
|
+
projectHasUI = !!ui(projectDir);
|
|
175
|
+
projectHasPlaywright = !!pw(projectDir);
|
|
176
|
+
} catch (_e) {
|
|
177
|
+
// Probe failure → fail open: skip the gate. A broken probe is worse
|
|
178
|
+
// than a permissive one (we don't want to block every spawn on a stale
|
|
179
|
+
// import path).
|
|
180
|
+
projectHasUI = false;
|
|
181
|
+
projectHasPlaywright = true;
|
|
182
|
+
}
|
|
183
|
+
if (projectHasUI && !projectHasPlaywright) {
|
|
184
|
+
let installer;
|
|
185
|
+
try {
|
|
186
|
+
installer = (opts && opts._gateInstaller) ||
|
|
187
|
+
require("./playwright-bootstrap.cjs").installPlaywrightSync;
|
|
188
|
+
} catch (_e) {
|
|
189
|
+
installer = null;
|
|
190
|
+
}
|
|
191
|
+
if (typeof installer === "function") {
|
|
192
|
+
const result = installer(projectDir);
|
|
193
|
+
if (result && result.ok) {
|
|
194
|
+
try {
|
|
195
|
+
// Concise stdout signal so the user sees the gate fired.
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`▶ Playwright auto-installed (${path.relative(projectDir, path.join(projectDir, "playwright.config.ts"))})\n`,
|
|
198
|
+
);
|
|
199
|
+
} catch (_) { /* best-effort */ }
|
|
200
|
+
} else {
|
|
201
|
+
// Install failed → mode: 'blocked-needs-human', exit 4.
|
|
202
|
+
const blockedId = makeSessionId(command, new Date());
|
|
203
|
+
try {
|
|
204
|
+
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
205
|
+
writeSessionFile(projectDir, {
|
|
206
|
+
id: blockedId,
|
|
207
|
+
pid: null,
|
|
208
|
+
logPath: null,
|
|
209
|
+
startTimestamp: new Date().toISOString(),
|
|
210
|
+
command,
|
|
211
|
+
args,
|
|
212
|
+
status: "blocked",
|
|
213
|
+
mode: "blocked-needs-human",
|
|
214
|
+
reason: "playwright-install-failed",
|
|
215
|
+
err: (result && result.err) || "unknown",
|
|
216
|
+
hint: (result && result.hint) || null,
|
|
217
|
+
continueFromPath: continue_from,
|
|
218
|
+
surfaced: false,
|
|
219
|
+
});
|
|
220
|
+
} catch (_) { /* best-effort */ }
|
|
221
|
+
try {
|
|
222
|
+
process.stderr.write(
|
|
223
|
+
`[m50-spawn-gate] Playwright install failed (${(result && result.err) || "unknown"}). ` +
|
|
224
|
+
`Run \`gsd-t doctor --install-playwright\` to retry. Spawn aborted.\n`,
|
|
225
|
+
);
|
|
226
|
+
} catch (_) { /* best-effort */ }
|
|
227
|
+
// In test runs we don't want to actually exit the test harness;
|
|
228
|
+
// tests inject `opts._gateExit` to capture the exit instead.
|
|
229
|
+
if (opts && typeof opts._gateExit === "function") {
|
|
230
|
+
opts._gateExit(4);
|
|
231
|
+
return {
|
|
232
|
+
id: blockedId,
|
|
233
|
+
pid: null,
|
|
234
|
+
logPath: null,
|
|
235
|
+
timestamp: new Date().toISOString(),
|
|
236
|
+
mode: "blocked-needs-human",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
process.exit(4);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
124
245
|
const timestamp = new Date().toISOString();
|
|
125
246
|
const id = makeSessionId(command, new Date());
|
|
126
247
|
const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { exec, spawn, spawnSync } = require('child_process');
|
|
6
|
+
|
|
7
|
+
function hasPlaywright(projectDir) {
|
|
8
|
+
const configs = ['playwright.config.ts', 'playwright.config.js', 'playwright.config.mjs'];
|
|
9
|
+
try {
|
|
10
|
+
return configs.some((f) => fs.existsSync(path.join(projectDir, f)));
|
|
11
|
+
} catch (_) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function detectPackageManager(projectDir) {
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(path.join(projectDir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
19
|
+
if (fs.existsSync(path.join(projectDir, 'yarn.lock'))) return 'yarn';
|
|
20
|
+
if (fs.existsSync(path.join(projectDir, 'bun.lockb'))) return 'bun';
|
|
21
|
+
} catch (_) {
|
|
22
|
+
// fall through to default
|
|
23
|
+
}
|
|
24
|
+
return 'npm';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function verifyPlaywrightHealth(projectDir) {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const child = exec(
|
|
30
|
+
'npx playwright --version',
|
|
31
|
+
{ cwd: projectDir, timeout: 5000 },
|
|
32
|
+
(err, stdout, stderr) => {
|
|
33
|
+
if (err) {
|
|
34
|
+
resolve({ ok: false, error: stderr || err.message || String(err) });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const match = (stdout || '').match(/Version\s+([\d.]+)/i);
|
|
38
|
+
if (match) {
|
|
39
|
+
resolve({ ok: true, version: match[1] });
|
|
40
|
+
} else {
|
|
41
|
+
resolve({ ok: false, error: 'Could not parse version from: ' + stdout.trim() });
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
// Belt-and-suspenders: exec timeout option should handle this, but guard anyway
|
|
46
|
+
child.on('error', (err) => {
|
|
47
|
+
resolve({ ok: false, error: err.message || String(err) });
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── installPlaywright ────────────────────────────────────────────────────────
|
|
53
|
+
//
|
|
54
|
+
// Idempotent installer. Per playwright-bootstrap-contract.md §3 + §6 + §7 + §8.
|
|
55
|
+
// Returns { ok: true } on success, { ok: false, err, hint } on failure.
|
|
56
|
+
|
|
57
|
+
const PLAYWRIGHT_CONFIG_TEMPLATE = `import { defineConfig, devices } from '@playwright/test';
|
|
58
|
+
|
|
59
|
+
export default defineConfig({
|
|
60
|
+
testDir: './e2e',
|
|
61
|
+
fullyParallel: true,
|
|
62
|
+
forbidOnly: !!process.env.CI,
|
|
63
|
+
retries: process.env.CI ? 2 : 0,
|
|
64
|
+
workers: process.env.CI ? 1 : undefined,
|
|
65
|
+
reporter: 'html',
|
|
66
|
+
use: {
|
|
67
|
+
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000',
|
|
68
|
+
trace: 'on-first-retry',
|
|
69
|
+
},
|
|
70
|
+
projects: [
|
|
71
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
72
|
+
],
|
|
73
|
+
// webServer is intentionally omitted — projects manage their own server lifecycle.
|
|
74
|
+
});
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const PLACEHOLDER_SPEC_TEMPLATE = `import { test } from '@playwright/test';
|
|
78
|
+
|
|
79
|
+
// Placeholder spec — replace with real specs when UI tests land.
|
|
80
|
+
test.skip('placeholder', () => {});
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
const INSTALL_COMMANDS = {
|
|
84
|
+
npm: { cmd: 'npm', args: ['install', '-D', '@playwright/test'] },
|
|
85
|
+
pnpm: { cmd: 'pnpm', args: ['add', '-D', '@playwright/test'] },
|
|
86
|
+
yarn: { cmd: 'yarn', args: ['add', '-D', '@playwright/test'] },
|
|
87
|
+
bun: { cmd: 'bun', args: ['add', '-d', '@playwright/test'] },
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function _classifyError(stderr, code, command) {
|
|
91
|
+
const text = String(stderr || '').toLowerCase();
|
|
92
|
+
if (code === 127 || /command not found|enoent|not recognized|spawn .* enoent/i.test(text)) {
|
|
93
|
+
return {
|
|
94
|
+
err: 'package-manager-not-found',
|
|
95
|
+
hint: 'Install the package manager (' + command + ') and re-run: gsd-t doctor --install-playwright',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (/network|registry|getaddrinfo|enotfound|econnrefused|etimedout|enetunreach/i.test(text)) {
|
|
99
|
+
return {
|
|
100
|
+
err: stderr || 'network-failure',
|
|
101
|
+
hint: 'Check network connectivity and retry: gsd-t doctor --install-playwright',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (/chromium|browsers? could not be downloaded|browser.*download/i.test(text)) {
|
|
105
|
+
return {
|
|
106
|
+
err: stderr || 'chromium-download-failed',
|
|
107
|
+
hint: 'Run npx playwright install chromium manually',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (/eacces|eperm|permission|read-only|enospc|disk/i.test(text)) {
|
|
111
|
+
return {
|
|
112
|
+
err: stderr || 'disk-write-failed',
|
|
113
|
+
hint: 'Check filesystem permissions',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
err: stderr || 'install-failed',
|
|
118
|
+
hint: 'Run gsd-t doctor --install-playwright to retry',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _runSubprocess(cmd, args, cwd) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let stderr = '';
|
|
126
|
+
let child;
|
|
127
|
+
try {
|
|
128
|
+
child = spawn(cmd, args, {
|
|
129
|
+
cwd,
|
|
130
|
+
env: process.env,
|
|
131
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
resolve({ code: 127, stdout: '', stderr: err.message || String(err) });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
138
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
139
|
+
child.on('error', (err) => {
|
|
140
|
+
resolve({ code: 127, stdout, stderr: stderr + (err.message || String(err)) });
|
|
141
|
+
});
|
|
142
|
+
child.on('close', (code) => {
|
|
143
|
+
resolve({ code: code == null ? 1 : code, stdout, stderr });
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _writeIfAbsent(filePath, content) {
|
|
149
|
+
try {
|
|
150
|
+
if (fs.existsSync(filePath)) return { ok: true, wrote: false };
|
|
151
|
+
fs.writeFileSync(filePath, content);
|
|
152
|
+
return { ok: true, wrote: true };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { ok: false, error: err.message || String(err) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _ensureE2EPlaceholder(projectDir) {
|
|
159
|
+
try {
|
|
160
|
+
const e2eDir = path.join(projectDir, 'e2e');
|
|
161
|
+
let dirExists = false;
|
|
162
|
+
try {
|
|
163
|
+
dirExists = fs.statSync(e2eDir).isDirectory();
|
|
164
|
+
} catch (_e) {
|
|
165
|
+
dirExists = false;
|
|
166
|
+
}
|
|
167
|
+
if (!dirExists) {
|
|
168
|
+
fs.mkdirSync(e2eDir, { recursive: true });
|
|
169
|
+
} else {
|
|
170
|
+
// If e2e exists and is non-empty, do not overwrite.
|
|
171
|
+
const entries = fs.readdirSync(e2eDir);
|
|
172
|
+
if (entries.length > 0) return { ok: true, wrote: false };
|
|
173
|
+
}
|
|
174
|
+
const specPath = path.join(e2eDir, '__placeholder.spec.ts');
|
|
175
|
+
if (!fs.existsSync(specPath)) {
|
|
176
|
+
fs.writeFileSync(specPath, PLACEHOLDER_SPEC_TEMPLATE);
|
|
177
|
+
return { ok: true, wrote: true };
|
|
178
|
+
}
|
|
179
|
+
return { ok: true, wrote: false };
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return { ok: false, error: err.message || String(err) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function installPlaywright(projectDir, opts) {
|
|
186
|
+
// Idempotent short-circuit: already configured.
|
|
187
|
+
if (hasPlaywright(projectDir)) return { ok: true };
|
|
188
|
+
|
|
189
|
+
const pm = detectPackageManager(projectDir);
|
|
190
|
+
const install = INSTALL_COMMANDS[pm] || INSTALL_COMMANDS.npm;
|
|
191
|
+
|
|
192
|
+
// Tests use opts.runner to inject a stub of `_runSubprocess` so we can
|
|
193
|
+
// exercise each package-manager branch and error path without actually
|
|
194
|
+
// hitting npm/pnpm/yarn/bun. Production callers omit it.
|
|
195
|
+
const runner = (opts && opts.runner) || _runSubprocess;
|
|
196
|
+
|
|
197
|
+
// Step 3: install @playwright/test as a devDependency
|
|
198
|
+
let r = await runner(install.cmd, install.args, projectDir);
|
|
199
|
+
if (r.code !== 0) {
|
|
200
|
+
const c = _classifyError(r.stderr, r.code, install.cmd);
|
|
201
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Step 4: install chromium browser
|
|
205
|
+
r = await runner('npx', ['playwright', 'install', 'chromium'], projectDir);
|
|
206
|
+
if (r.code !== 0) {
|
|
207
|
+
const c = _classifyError(r.stderr, r.code, 'npx');
|
|
208
|
+
// Partial install: @playwright/test landed, chromium did not. Surface that.
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
err: c.err,
|
|
212
|
+
hint: c.hint,
|
|
213
|
+
partial: true,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Step 5: write playwright.config.ts (idempotent — does not overwrite)
|
|
218
|
+
const configPath = path.join(projectDir, 'playwright.config.ts');
|
|
219
|
+
const cfgWrite = _writeIfAbsent(configPath, PLAYWRIGHT_CONFIG_TEMPLATE);
|
|
220
|
+
if (!cfgWrite.ok) {
|
|
221
|
+
const c = _classifyError(cfgWrite.error, 1, 'fs.writeFile');
|
|
222
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Step 6: e2e/ scaffolding (idempotent — does not overwrite existing files)
|
|
226
|
+
const placeholderWrite = _ensureE2EPlaceholder(projectDir);
|
|
227
|
+
if (!placeholderWrite.ok) {
|
|
228
|
+
const c = _classifyError(placeholderWrite.error, 1, 'fs.writeFile');
|
|
229
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return { ok: true };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── installPlaywrightSync ────────────────────────────────────────────────────
|
|
236
|
+
//
|
|
237
|
+
// Synchronous variant of installPlaywright(). Same idempotency + template +
|
|
238
|
+
// error-classifier semantics as the async form, implemented with `spawnSync`
|
|
239
|
+
// so it can be embedded inside synchronous code paths (notably the M50 D2
|
|
240
|
+
// spawn-gate in bin/headless-auto-spawn.cjs::autoSpawnHeadless, which must
|
|
241
|
+
// remain sync to preserve the existing return-value contract relied on by
|
|
242
|
+
// bin/gsd-t-parallel.cjs::runDispatch).
|
|
243
|
+
//
|
|
244
|
+
// Returns the same shape as installPlaywright(): {ok: true} or
|
|
245
|
+
// {ok: false, err, hint, partial?: true}. Tests inject opts.runner the same
|
|
246
|
+
// way; production callers omit it.
|
|
247
|
+
|
|
248
|
+
function _runSubprocessSync(cmd, args, cwd) {
|
|
249
|
+
let res;
|
|
250
|
+
try {
|
|
251
|
+
res = spawnSync(cmd, args, {
|
|
252
|
+
cwd,
|
|
253
|
+
env: process.env,
|
|
254
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
255
|
+
encoding: 'utf8',
|
|
256
|
+
});
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return { code: 127, stdout: '', stderr: err.message || String(err) };
|
|
259
|
+
}
|
|
260
|
+
if (res.error) {
|
|
261
|
+
return { code: 127, stdout: '', stderr: res.error.message || String(res.error) };
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
code: res.status == null ? 1 : res.status,
|
|
265
|
+
stdout: res.stdout || '',
|
|
266
|
+
stderr: res.stderr || '',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function installPlaywrightSync(projectDir, opts) {
|
|
271
|
+
if (hasPlaywright(projectDir)) return { ok: true };
|
|
272
|
+
|
|
273
|
+
const pm = detectPackageManager(projectDir);
|
|
274
|
+
const install = INSTALL_COMMANDS[pm] || INSTALL_COMMANDS.npm;
|
|
275
|
+
const runner = (opts && opts.runner) || _runSubprocessSync;
|
|
276
|
+
|
|
277
|
+
let r = runner(install.cmd, install.args, projectDir);
|
|
278
|
+
if (r.code !== 0) {
|
|
279
|
+
const c = _classifyError(r.stderr, r.code, install.cmd);
|
|
280
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
r = runner('npx', ['playwright', 'install', 'chromium'], projectDir);
|
|
284
|
+
if (r.code !== 0) {
|
|
285
|
+
const c = _classifyError(r.stderr, r.code, 'npx');
|
|
286
|
+
return { ok: false, err: c.err, hint: c.hint, partial: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const configPath = path.join(projectDir, 'playwright.config.ts');
|
|
290
|
+
const cfgWrite = _writeIfAbsent(configPath, PLAYWRIGHT_CONFIG_TEMPLATE);
|
|
291
|
+
if (!cfgWrite.ok) {
|
|
292
|
+
const c = _classifyError(cfgWrite.error, 1, 'fs.writeFile');
|
|
293
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const placeholderWrite = _ensureE2EPlaceholder(projectDir);
|
|
297
|
+
if (!placeholderWrite.ok) {
|
|
298
|
+
const c = _classifyError(placeholderWrite.error, 1, 'fs.writeFile');
|
|
299
|
+
return { ok: false, err: c.err, hint: c.hint };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return { ok: true };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
hasPlaywright,
|
|
307
|
+
detectPackageManager,
|
|
308
|
+
verifyPlaywrightHealth,
|
|
309
|
+
installPlaywright,
|
|
310
|
+
installPlaywrightSync,
|
|
311
|
+
// Exposed for tests; treat as private.
|
|
312
|
+
_PLAYWRIGHT_CONFIG_TEMPLATE: PLAYWRIGHT_CONFIG_TEMPLATE,
|
|
313
|
+
_PLACEHOLDER_SPEC_TEMPLATE: PLACEHOLDER_SPEC_TEMPLATE,
|
|
314
|
+
_INSTALL_COMMANDS: INSTALL_COMMANDS,
|
|
315
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// UI detection probe — synchronous, never throws, depth-bounded.
|
|
4
|
+
// Contract: .gsd-t/contracts/playwright-bootstrap-contract.md §4
|
|
5
|
+
//
|
|
6
|
+
// hasUI(projectDir): true iff the project has any UI signal.
|
|
7
|
+
// Probe order (first match wins, short-circuit):
|
|
8
|
+
// 1. package.json deps/devDeps include react/vue/svelte/next/@angular/core/@vue/runtime-core
|
|
9
|
+
// 2. pubspec.yaml at project root (Flutter)
|
|
10
|
+
// 3. tailwind.config.{js,ts}
|
|
11
|
+
// 4. any .tsx/.jsx/.vue/.svelte/.css/.scss within depth 3, excluding ignored dirs
|
|
12
|
+
//
|
|
13
|
+
// detectUIFlavor(projectDir): more specific category, or null when hasUI() is false.
|
|
14
|
+
|
|
15
|
+
const fs = require("fs");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
|
|
18
|
+
const FRAMEWORK_DEPS = {
|
|
19
|
+
next: "next",
|
|
20
|
+
angular: "@angular/core",
|
|
21
|
+
react: "react",
|
|
22
|
+
vue: "vue",
|
|
23
|
+
svelte: "svelte",
|
|
24
|
+
// @vue/runtime-core implies vue
|
|
25
|
+
"vue-runtime": "@vue/runtime-core",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const UI_FILE_EXTS = new Set([".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss"]);
|
|
29
|
+
|
|
30
|
+
const IGNORED_DIRS = new Set([
|
|
31
|
+
"node_modules",
|
|
32
|
+
".git",
|
|
33
|
+
"dist",
|
|
34
|
+
"build",
|
|
35
|
+
".next",
|
|
36
|
+
".nuxt",
|
|
37
|
+
"coverage",
|
|
38
|
+
".gsd-t",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
const MAX_WALK_DEPTH = 3;
|
|
42
|
+
|
|
43
|
+
function _readPkgDeps(projectDir) {
|
|
44
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
45
|
+
if (!fs.existsSync(pkgPath)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const raw = fs.readFileSync(pkgPath, "utf8");
|
|
48
|
+
const pkg = JSON.parse(raw);
|
|
49
|
+
const deps = Object.assign({}, pkg.dependencies || {}, pkg.devDependencies || {});
|
|
50
|
+
return deps;
|
|
51
|
+
} catch (_e) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function _frameworkFromDeps(deps) {
|
|
57
|
+
if (!deps) return null;
|
|
58
|
+
// Order matters: next before react (Next ships with react), vue-runtime → vue, angular, svelte.
|
|
59
|
+
if (deps[FRAMEWORK_DEPS.next]) return "next";
|
|
60
|
+
if (deps[FRAMEWORK_DEPS.angular]) return "angular";
|
|
61
|
+
if (deps[FRAMEWORK_DEPS.vue] || deps[FRAMEWORK_DEPS["vue-runtime"]]) return "vue";
|
|
62
|
+
if (deps[FRAMEWORK_DEPS.svelte]) return "svelte";
|
|
63
|
+
if (deps[FRAMEWORK_DEPS.react]) return "react";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function _isFile(p) {
|
|
68
|
+
try {
|
|
69
|
+
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
70
|
+
return !!(st && st.isFile());
|
|
71
|
+
} catch (_e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _hasFlutter(projectDir) {
|
|
77
|
+
return _isFile(path.join(projectDir, "pubspec.yaml"));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _hasTailwindConfig(projectDir) {
|
|
81
|
+
return (
|
|
82
|
+
_isFile(path.join(projectDir, "tailwind.config.js")) ||
|
|
83
|
+
_isFile(path.join(projectDir, "tailwind.config.ts")) ||
|
|
84
|
+
_isFile(path.join(projectDir, "tailwind.config.mjs")) ||
|
|
85
|
+
_isFile(path.join(projectDir, "tailwind.config.cjs"))
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Depth-bounded short-circuit walk. Returns true on first UI file found.
|
|
90
|
+
function _findUIFileWithinDepth(rootDir, maxDepth) {
|
|
91
|
+
// BFS-style iterative walk (avoid recursion depth + stack issues on weird trees).
|
|
92
|
+
const stack = [{ dir: rootDir, depth: 0 }];
|
|
93
|
+
while (stack.length > 0) {
|
|
94
|
+
const { dir, depth } = stack.pop();
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
98
|
+
} catch (_e) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const name = entry.name;
|
|
103
|
+
if (entry.isFile()) {
|
|
104
|
+
const ext = path.extname(name).toLowerCase();
|
|
105
|
+
if (UI_FILE_EXTS.has(ext)) return true;
|
|
106
|
+
} else if (entry.isDirectory()) {
|
|
107
|
+
// Contract §4 enumerates the exclusion set — do not over-exclude
|
|
108
|
+
// dot-prefixed dirs (e.g. .storybook houses real UI code).
|
|
109
|
+
if (IGNORED_DIRS.has(name)) continue;
|
|
110
|
+
if (depth + 1 <= maxDepth) {
|
|
111
|
+
stack.push({ dir: path.join(dir, name), depth: depth + 1 });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasUI(projectDir) {
|
|
120
|
+
if (typeof projectDir !== "string" || projectDir.length === 0) return false;
|
|
121
|
+
try {
|
|
122
|
+
const deps = _readPkgDeps(projectDir);
|
|
123
|
+
if (_frameworkFromDeps(deps) !== null) return true;
|
|
124
|
+
if (_hasFlutter(projectDir)) return true;
|
|
125
|
+
if (_hasTailwindConfig(projectDir)) return true;
|
|
126
|
+
if (_findUIFileWithinDepth(projectDir, MAX_WALK_DEPTH)) return true;
|
|
127
|
+
return false;
|
|
128
|
+
} catch (_e) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function detectUIFlavor(projectDir) {
|
|
134
|
+
if (typeof projectDir !== "string" || projectDir.length === 0) return null;
|
|
135
|
+
try {
|
|
136
|
+
const deps = _readPkgDeps(projectDir);
|
|
137
|
+
const framework = _frameworkFromDeps(deps);
|
|
138
|
+
if (framework !== null) return framework;
|
|
139
|
+
if (_hasFlutter(projectDir)) return "flutter";
|
|
140
|
+
if (_hasTailwindConfig(projectDir)) return "css-only";
|
|
141
|
+
if (_findUIFileWithinDepth(projectDir, MAX_WALK_DEPTH)) return "css-only";
|
|
142
|
+
return null;
|
|
143
|
+
} catch (_e) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
hasUI,
|
|
150
|
+
detectUIFlavor,
|
|
151
|
+
};
|
package/commands/gsd-t-init.md
CHANGED
|
@@ -332,25 +332,23 @@ After initialization, verify all created documentation is consistent:
|
|
|
332
332
|
|
|
333
333
|
## Step 11: Playwright Setup (MANDATORY)
|
|
334
334
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
Skip silently if `playwright.config.*` already exists.
|
|
335
|
+
M50: this step is now executable code, not prose. The `bin/gsd-t.js init` flow calls `installPlaywright(projectDir)` from `bin/playwright-bootstrap.cjs` automatically when `hasUI(projectDir) && !hasPlaywright(projectDir)`. See `.gsd-t/contracts/playwright-bootstrap-contract.md`.
|
|
336
|
+
|
|
337
|
+
The installer:
|
|
338
|
+
1. Detects the package manager via `detectPackageManager(projectDir)` (`pnpm-lock.yaml` → `pnpm`; `yarn.lock` → `yarn`; `bun.lockb` → `bun`; default `npm`).
|
|
339
|
+
2. Installs `@playwright/test` as a devDependency + `npx playwright install chromium`.
|
|
340
|
+
3. Writes `playwright.config.ts` (testDir `./e2e`, chromium project) idempotently — does NOT overwrite an existing config.
|
|
341
|
+
4. Creates `e2e/__placeholder.spec.ts` (empty `test.skip`) when `e2e/` is absent or empty.
|
|
342
|
+
|
|
343
|
+
Fallback (when not running through `bin/gsd-t.js init`):
|
|
344
|
+
- bun: `bun add -d @playwright/test && bunx playwright install chromium`
|
|
345
|
+
- npm: `npm install -D @playwright/test && npx playwright install chromium`
|
|
346
|
+
- yarn: `yarn add -D @playwright/test && yarn playwright install chromium`
|
|
347
|
+
- pnpm: `pnpm add -D @playwright/test && pnpm exec playwright install chromium`
|
|
348
|
+
|
|
349
|
+
Operator overrides: `gsd-t setup-playwright [path]` (explicit single-project install) or `gsd-t doctor --install-playwright`.
|
|
350
|
+
|
|
351
|
+
The spawn-time gate in `bin/headless-auto-spawn.cjs` re-runs the install on first need if the project skipped this step (e.g., older project that pre-dates M50).
|
|
354
352
|
|
|
355
353
|
## Step 12: Test Verification
|
|
356
354
|
|
package/docs/architecture.md
CHANGED
|
@@ -1086,3 +1086,17 @@ Contract: `.gsd-t/contracts/headless-default-contract.md` v2.1.0 §Worker Sub-Di
|
|
|
1086
1086
|
The production main loop currently runs exactly one iter per pass (`batchSize === 1`) always, unless a caller explicitly threads `opts.maxIterParallel` as a number through `_computeIterBatchSize` — which today's supervisor CLI does not. The four helpers are exported via `module.exports.__test__` so the T7 unit suite and any future caller can exercise batched iteration deterministically, but iter-parallelism at this layer is **scaffolded, not engaged in production**. The gate is intentional: `_runOneIter` mutates shared `state` fields (`state.iter`, heartbeat bookkeeping, the `writeState` side effect) that are not safe to execute concurrently against the same state object. Backlog #24 tracks the follow-up to make `_runOneIter` state-clone-safe and lift the production gate so the supervisor CLI can set a non-1 default.
|
|
1087
1087
|
|
|
1088
1088
|
Contract: `.gsd-t/contracts/iter-parallel-contract.md` v1.0.0.
|
|
1089
|
+
|
|
1090
|
+
## Playwright Deterministic Enforcement (M50, v3.21.x+)
|
|
1091
|
+
|
|
1092
|
+
M50 retires the prose-only "Playwright Readiness Guard" in favor of executable enforcement. Three layers, each runnable from the CLI or from any caller via the exported library:
|
|
1093
|
+
|
|
1094
|
+
1. **Bootstrap library** (`bin/playwright-bootstrap.cjs` + `bin/ui-detection.cjs`) — single-source library exposing `hasPlaywright`, `detectPackageManager`, `installPlaywright`, `installPlaywrightSync`, `verifyPlaywrightHealth`, `hasUI`, `detectUIFlavor`. Zero external runtime dependencies. The async + sync install variants share the same template, error classifier, and idempotency invariants per `playwright-bootstrap-contract.md` §3-§8.
|
|
1095
|
+
|
|
1096
|
+
2. **Spawn-time gate** (`bin/headless-auto-spawn.cjs::autoSpawnHeadless`) — when the command being spawned is in the `TESTING_OR_UI_COMMANDS` whitelist (`gsd-t-execute`, `gsd-t-test-sync`, `gsd-t-verify`, `gsd-t-quick`, `gsd-t-wave`, `gsd-t-milestone`, `gsd-t-complete-milestone`, `gsd-t-debug`, `gsd-t-integrate`) AND `hasUI(projectDir)` AND `!hasPlaywright(projectDir)`, the gate auto-installs via `installPlaywrightSync`. On install failure, the gate writes `mode: 'blocked-needs-human'` to the headless session-state file and exits with code 4. Hot-path overhead: three filesystem checks (Set lookup + depth-bounded fs walk + existsSync).
|
|
1097
|
+
|
|
1098
|
+
3. **Commit-time gate** (`scripts/hooks/pre-commit-playwright-gate`) — opt-in via `gsd-t doctor --install-hooks`. The bash hook reads `.gsd-t/.last-playwright-pass` (Unix epoch ms) and blocks commits that touch viewer-source files (`scripts/gsd-t-transcript.html`, `scripts/gsd-t-dashboard-server.js`, `e2e/viewer/**`) when any staged file's mtime exceeds the recorded pass. Fails open on missing/corrupt timestamps — a broken hook is worse than a permissive one.
|
|
1099
|
+
|
|
1100
|
+
CLI surface added in M50: `gsd-t setup-playwright [path]` (single-project explicit installer), `gsd-t doctor --install-playwright` (fix-it-now flag), `gsd-t doctor --install-hooks` (pre-commit-gate installer). `gsd-t init` and `gsd-t update-all` invoke `installPlaywright` automatically for any UI project that's missing it.
|
|
1101
|
+
|
|
1102
|
+
Contract: `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0.
|
package/docs/requirements.md
CHANGED
|
@@ -700,3 +700,30 @@ Acceptance:
|
|
|
700
700
|
| REQ-M47-D2-02 | New `GET /api/main-session` endpoint returns `{ filename, sessionId, mtimeMs }` for the most-recently-modified `in-session-*.ndjson` (or `{ null, null, null }` when none exist); path-traversal-guarded; no caching. | m47-d2-server-helpers | T2, T5 | done |
|
|
701
701
|
| REQ-M47-D2-03 | `dashboard-server-contract.md` bumped to v1.3.0 documenting the additive `status` field semantics + `/api/main-session` schema; module exports updated. | m47-d2-server-helpers | T3 | done |
|
|
702
702
|
| REQ-M47-D2-04 | Test suite passes baseline 2045/2047 + new M47 tests (D1 + D2 net add); no NEW regressions in the 7 existing viewer-route/HTML tests (success criterion 5). | m47-d1-viewer-redesign + m47-d2-server-helpers | D1 T7, D2 T4–T5 | done |
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
## M50 Universal Playwright Bootstrap + Deterministic UI Enforcement (planned — 2026-05-06)
|
|
706
|
+
|
|
707
|
+
| REQ-ID | Requirement Summary | Domain | Task(s) | Status |
|
|
708
|
+
|--------|---------------------|--------|---------|--------|
|
|
709
|
+
| REQ-M50-D1-01 | `bin/playwright-bootstrap.cjs` exports `hasPlaywright`, `detectPackageManager`, `installPlaywright` (idempotent), `verifyPlaywrightHealth`. Zero external runtime deps. | m50-bootstrap-and-detection | T2, T3 | done |
|
|
710
|
+
| REQ-M50-D1-02 | `bin/ui-detection.cjs` exports `hasUI` (depth-bounded, short-circuits on first hit) + `detectUIFlavor`. Recognizes React/Vue/Svelte/Next/Angular/Flutter/Tailwind/css-only. | m50-bootstrap-and-detection | T1 | done |
|
|
711
|
+
| REQ-M50-D1-03 | `bin/gsd-t.js` migrates inline `hasPlaywright` (line 201-204) to `require('./playwright-bootstrap.cjs')`; `init`/`update-all`/`doctor` invoke `installPlaywright` when `hasUI && !hasPlaywright`. | m50-bootstrap-and-detection | T4 | done |
|
|
712
|
+
| REQ-M50-D1-04 | New `gsd-t setup-playwright` subcommand: explicit one-shot `installPlaywright(cwd)` invocation with verbose output. | m50-bootstrap-and-detection | T4 | done |
|
|
713
|
+
| REQ-M50-D1-05 | New flag `gsd-t doctor --install-playwright` directly invokes `installPlaywright(cwd)`. Fixes all 14 of 19 registered projects flagged Playwright-missing in one command. | m50-bootstrap-and-detection | T4 | done |
|
|
714
|
+
| REQ-M50-D1-06 | ~25 unit tests across `test/m50-d1-playwright-bootstrap.test.js` + `test/m50-d1-ui-detection.test.js` + `test/m50-d1-cli-integration.test.js` pass. | m50-bootstrap-and-detection | T1, T2, T3, T4, T5 | done |
|
|
715
|
+
| REQ-M50-D2-01 | `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` inserts a spawn-gate: when `isTestingOrUICommand && hasUI && !hasPlaywright`, auto-installs; on install fail, exits with `mode: 'blocked-needs-human'` (exit code 4). Hot-path overhead ≤ 10ms when no install is needed. | m50-gates-and-specs | T2 | done |
|
|
716
|
+
| REQ-M50-D2-02 | `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) reads `.gsd-t/.last-playwright-pass` and blocks viewer-source commits when any touched viewer-source file's mtime > the timestamp. Fail-open on config errors. | m50-gates-and-specs | T3 | done |
|
|
717
|
+
| REQ-M50-D2-03 | `playwright.config.ts` at GSD-T project root with `testDir: 'e2e'`, chromium project, `webServer: undefined` (specs manage their own server lifecycle). | m50-gates-and-specs | T1 | done |
|
|
718
|
+
| REQ-M50-D2-04 | `e2e/viewer/title.spec.ts` regression-tests M48 Bug 1 (project basename in `<title>` + header `.title` for `/transcripts` and `/transcripts/{spawnId}`). | m50-gates-and-specs | T4 | done |
|
|
719
|
+
| REQ-M50-D2-05 | `e2e/viewer/timestamps.spec.ts` regression-tests M48 Bug 2 (per-frame timestamps from `frame.ts`, not per-batch `new Date()`). | m50-gates-and-specs | T5 | done |
|
|
720
|
+
| REQ-M50-D2-06 | `e2e/viewer/chat-bubbles.spec.ts` regression-tests M48 Bug 3 (`user_turn`/`assistant_turn`/`session_start`/`tool_use_line` render as styled bubbles, not `JSON.stringify` dumps). | m50-gates-and-specs | T6 | done |
|
|
721
|
+
| REQ-M50-D2-07 | `e2e/viewer/dual-pane.spec.ts` regression-tests M48 Bug 4 (clicking `in-session-*` rail entry pins to top pane only; bottom pane stays on its own SSE stream). | m50-gates-and-specs | T7 | done |
|
|
722
|
+
| REQ-M50-D2-08 | `e2e/viewer/lazy-dashboard.spec.ts` regression-tests M49 banner (URL banner when dashboard alive; fallback "Transcript file:" banner when not). | m50-gates-and-specs | T8 | done |
|
|
723
|
+
| REQ-M50-D2-09 | ~14 unit tests across `test/m50-d2-spawn-gate.test.js` + `test/m50-d2-pre-commit-hook.test.js` + `test/m50-d2-viewer-specs-smoke.test.js` pass. | m50-gates-and-specs | T1, T2, T3 | done |
|
|
724
|
+
| REQ-M50-D2-10 | Doc-ripple: `~/.claude/CLAUDE.md` + `templates/CLAUDE-global.md` + 8 command files (`gsd-t-execute`, `gsd-t-test-sync`, `gsd-t-verify`, `gsd-t-quick`, `gsd-t-wave`, `gsd-t-milestone`, `gsd-t-complete-milestone`, `gsd-t-debug`) + `docs/architecture.md` + `CHANGELOG.md`. Replace prose Playwright reminders with referrals to `playwright-bootstrap-contract.md`. | m50-gates-and-specs | T9 | done |
|
|
725
|
+
| REQ-M50-VERIFY | Full unit suite: 2104 baseline + ~25 D1 + ~14 D2 = ~2143 expected, ≥2141 passing (preserves 2 known env-sensitive flakes). All 5 E2E specs pass. Spawn-gate fixture + pre-commit-hook fixture pass. | both | T10 (D2) | done |
|
|
726
|
+
|
|
727
|
+
Supporting contracts:
|
|
728
|
+
- `.gsd-t/contracts/playwright-bootstrap-contract.md` v1.0.0 — D1 library API + CLI wiring + idempotency invariants + error-path contract.
|
|
729
|
+
- `.gsd-t/contracts/m50-integration-points.md` — D1↔D2 cross-domain checkpoint, the `bin/gsd-t.js` file-overlap coordination rules, and the doc-ripple ordering.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.22.11",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,8 +23,13 @@
|
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
25
|
"test": "node --test",
|
|
26
|
+
"e2e": "playwright test",
|
|
27
|
+
"e2e:install": "playwright install chromium",
|
|
26
28
|
"prepublishOnly": "npm test"
|
|
27
29
|
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@playwright/test": "^1.55.0"
|
|
32
|
+
},
|
|
28
33
|
"files": [
|
|
29
34
|
"bin/",
|
|
30
35
|
"commands/",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# GSD-T Playwright gate (M50 D2)
|
|
3
|
+
# Blocks commits that touch viewer/UI source files when Playwright tests have
|
|
4
|
+
# not passed since the most recent change. Reads `.gsd-t/.last-playwright-pass`
|
|
5
|
+
# (Unix epoch ms in a single line) — written by `npx playwright test` post-pass.
|
|
6
|
+
#
|
|
7
|
+
# Install (opt-in): gsd-t doctor --install-hooks
|
|
8
|
+
# Remove: rm .git/hooks/pre-commit (or remove this block if merged
|
|
9
|
+
# into an existing hook)
|
|
10
|
+
#
|
|
11
|
+
# Exit codes:
|
|
12
|
+
# 0 — clean (no viewer-source files staged, OR fresh pass, OR fail-open path)
|
|
13
|
+
# 1 — blocked (viewer-source modified after the last playwright pass)
|
|
14
|
+
#
|
|
15
|
+
# Fail-open philosophy (per m50 D2 constraints): a broken hook is worse than a
|
|
16
|
+
# permissive one. Missing or corrupt `.last-playwright-pass` → exit 0 with a
|
|
17
|
+
# stderr warning, never block.
|
|
18
|
+
|
|
19
|
+
set -e
|
|
20
|
+
|
|
21
|
+
# Resolve the project root (the dir that contains the repository's .git).
|
|
22
|
+
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
23
|
+
PASS_FILE="$ROOT/.gsd-t/.last-playwright-pass"
|
|
24
|
+
|
|
25
|
+
# Patterns that count as "viewer/UI source" for gating purposes. Globs are
|
|
26
|
+
# checked against the staged file list returned by git.
|
|
27
|
+
VIEWER_SOURCE_PATTERNS=(
|
|
28
|
+
"scripts/gsd-t-transcript.html"
|
|
29
|
+
"scripts/gsd-t-dashboard-server.js"
|
|
30
|
+
"e2e/viewer/"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Load staged file list (only added/modified — deletions don't need testing).
|
|
34
|
+
STAGED="$(git diff --cached --name-only --diff-filter=AM 2>/dev/null || true)"
|
|
35
|
+
|
|
36
|
+
# Quick exit when nothing is staged.
|
|
37
|
+
[ -z "$STAGED" ] && exit 0
|
|
38
|
+
|
|
39
|
+
# Find any staged path matching a viewer-source pattern.
|
|
40
|
+
matches=""
|
|
41
|
+
while IFS= read -r f; do
|
|
42
|
+
for p in "${VIEWER_SOURCE_PATTERNS[@]}"; do
|
|
43
|
+
case "$f" in
|
|
44
|
+
"$p"|"$p"*) matches="$matches $f"; break ;;
|
|
45
|
+
esac
|
|
46
|
+
done
|
|
47
|
+
done <<EOF
|
|
48
|
+
$STAGED
|
|
49
|
+
EOF
|
|
50
|
+
|
|
51
|
+
# No viewer-source files staged → silent pass.
|
|
52
|
+
if [ -z "$matches" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Viewer-source staged. Now require a fresh pass.
|
|
57
|
+
if [ ! -f "$PASS_FILE" ]; then
|
|
58
|
+
echo "[playwright-gate] WARNING: .gsd-t/.last-playwright-pass missing — fail-open. Run 'npx playwright test' to record a pass." >&2
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# Read timestamp; fail-open on corrupt content.
|
|
63
|
+
LAST_PASS="$(tr -d '[:space:]' < "$PASS_FILE")"
|
|
64
|
+
if ! echo "$LAST_PASS" | grep -Eq '^[0-9]+$'; then
|
|
65
|
+
echo "[playwright-gate] WARNING: .gsd-t/.last-playwright-pass is not a valid timestamp — fail-open." >&2
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Compare each staged viewer-source file's mtime (in ms) against LAST_PASS.
|
|
70
|
+
# If any file has mtime > LAST_PASS, block.
|
|
71
|
+
stale=""
|
|
72
|
+
for f in $matches; do
|
|
73
|
+
fpath="$ROOT/$f"
|
|
74
|
+
if [ -f "$fpath" ]; then
|
|
75
|
+
# GNU stat: -c %Y (seconds); BSD stat: -f %m. We want milliseconds.
|
|
76
|
+
if mtime_s=$(stat -c %Y "$fpath" 2>/dev/null) || mtime_s=$(stat -f %m "$fpath" 2>/dev/null); then
|
|
77
|
+
mtime_ms=$((mtime_s * 1000))
|
|
78
|
+
if [ "$mtime_ms" -gt "$LAST_PASS" ]; then
|
|
79
|
+
stale="$stale $f"
|
|
80
|
+
fi
|
|
81
|
+
fi
|
|
82
|
+
fi
|
|
83
|
+
done
|
|
84
|
+
|
|
85
|
+
if [ -n "$stale" ]; then
|
|
86
|
+
echo "[playwright-gate] BLOCKED: viewer-source modified since last playwright pass." >&2
|
|
87
|
+
echo " Stale files:$stale" >&2
|
|
88
|
+
echo " Last pass: $(date -r "$((LAST_PASS / 1000))" 2>/dev/null || echo "$LAST_PASS")" >&2
|
|
89
|
+
echo "" >&2
|
|
90
|
+
echo " Run 'npx playwright test' before committing, or run 'gsd-t doctor --install-playwright' if Playwright isn't set up." >&2
|
|
91
|
+
exit 1
|
|
92
|
+
fi
|
|
93
|
+
|
|
94
|
+
exit 0
|
|
@@ -194,15 +194,19 @@ If any are missing:
|
|
|
194
194
|
|
|
195
195
|
**Exempt commands** (do not trigger auto-init): `gsd-t-init`, `gsd-t-init-scan-setup`, `gsd-t-help`, `gsd-t-version-update`, `gsd-t-version-update-all`.
|
|
196
196
|
|
|
197
|
-
## Playwright Readiness Guard
|
|
197
|
+
## Playwright Readiness Guard (M50 — deterministic enforcement)
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
1. Detect the package manager and install Playwright (`@playwright/test` + chromium)
|
|
201
|
-
2. Create a basic `playwright.config.ts` with sensible defaults
|
|
202
|
-
3. Create the E2E test directory with a placeholder spec
|
|
203
|
-
4. Then continue with the original command
|
|
199
|
+
Playwright readiness is enforced by executable code, not prose. Three layers:
|
|
204
200
|
|
|
205
|
-
|
|
201
|
+
1. **Bootstrap library** — `bin/playwright-bootstrap.cjs` exports `hasPlaywright`, `detectPackageManager`, `installPlaywright`, `verifyPlaywrightHealth`. `bin/ui-detection.cjs` exports `hasUI`, `detectUIFlavor`. See `.gsd-t/contracts/playwright-bootstrap-contract.md`.
|
|
202
|
+
2. **Spawn-time gate** — `bin/headless-auto-spawn.cjs::autoSpawnHeadless()` auto-installs Playwright before the spawn proceeds, when the command being run is in the testing/UI whitelist (`gsd-t-execute`, `gsd-t-test-sync`, `gsd-t-verify`, `gsd-t-quick`, `gsd-t-wave`, `gsd-t-milestone`, `gsd-t-complete-milestone`, `gsd-t-debug`, `gsd-t-integrate`) AND `hasUI(projectDir)` AND `!hasPlaywright(projectDir)`. On install failure, the gate writes `mode: 'blocked-needs-human'` to the headless session-state file and exits 4.
|
|
203
|
+
3. **Commit-time gate** — `scripts/hooks/pre-commit-playwright-gate` (opt-in via `gsd-t doctor --install-hooks`) blocks commits that touch viewer/UI source files when Playwright tests have not passed since the most recent change. Reads `.gsd-t/.last-playwright-pass`; fails open on missing/corrupt timestamps.
|
|
204
|
+
|
|
205
|
+
Operator overrides:
|
|
206
|
+
- Manual install: `gsd-t setup-playwright [path]` (or `gsd-t doctor --install-playwright`).
|
|
207
|
+
- Health check: `gsd-t doctor` reports `playwright missing` for any UI project without `playwright.config.*`.
|
|
208
|
+
|
|
209
|
+
You no longer need to run a check yourself before testing commands — the gate runs every spawn.
|
|
206
210
|
|
|
207
211
|
### Playwright Cleanup
|
|
208
212
|
|