@tekyzinc/gsd-t 3.21.11 → 3.22.10
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 +67 -0
- package/README.md +1 -0
- package/bin/gsd-t.js +350 -17
- package/bin/headless-auto-spawn.cjs +205 -19
- 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 +16 -2
- package/docs/requirements.md +27 -0
- package/package.json +6 -1
- package/scripts/gsd-t-dashboard-server.js +137 -7
- package/scripts/hooks/pre-commit-playwright-gate +94 -0
- package/templates/CLAUDE-global.md +11 -7
|
@@ -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
|
@@ -73,8 +73,8 @@ The framework has no runtime — it is consumed entirely by Claude Code's slash
|
|
|
73
73
|
### Transcript Viewer as Primary Surface (M43 D6 — complete v3.16.13)
|
|
74
74
|
- **Dashboard server additions** (`scripts/gsd-t-dashboard-server.js`): two new HTTP routes for the per-spawn viewer. `GET /transcript/:id/usage` → `{spawn_id, rows, truncated}` filtered from `.gsd-t/metrics/token-usage.jsonl` by `row.spawn_id === id` OR (no `spawn_id` column + `row.session_id === id` — the session-id branch covers M43 D1 Branch B in-session rows). `GET /transcript/:id/tool-cost` → proxies to `bin/gsd-t-tool-attribution.cjs::aggregateByTool` (M43 D2); returns 503 `{error: "tool-attribution library not yet available"}` when D2 isn't on disk so D6 could ship before D2 in Wave 2 without crashing callers.
|
|
75
75
|
- **Transcript viewer panel** (`scripts/gsd-t-transcript.html`): collapsible "Tool Cost" sidebar panel that fetches `/transcript/:id/tool-cost` on viewer load and debounces a 2s refresh on each SSE `turn_complete` / `result` frame. Renders top-N tools sorted by attributed tokens with name, call count, tokens, and USD cost. Live badge green while SSE is open, muted otherwise. 503 → friendly "tool attribution not yet wired" row. `window.__gsdtRenderToolCostPanel` exposed for DOM tests.
|
|
76
|
-
- **URL banner** (`bin/headless-auto-spawn.cjs
|
|
77
|
-
- **Dashboard autostart** (`scripts/gsd-t-dashboard-autostart.cjs`, ~160 lines, zero deps): `ensureDashboardRunning({projectDir, port?})` probes the port synchronously via a short-lived subprocess (`_isPortBusySync` issues `net.createServer().listen(port)` host-less — matches the server's IPv6-wildcard bind on macOS dual-stack; specifying `127.0.0.1` would falsely report free). If free, fork-detaches the server with `spawn(…, {detached:true, stdio:'ignore'})` + `child.unref()` + writes `.gsd-t/.dashboard.pid` (hyphen → dot distinguishes this lifecycle from M38's `.gsd-t/dashboard.pid`). Idempotent on repeated invocation.
|
|
76
|
+
- **URL banner** (`bin/headless-auto-spawn.cjs`, M49 — lazy): every detached spawn prints either `▶ Live transcript: http://127.0.0.1:{port}/transcript/{spawn-id}` (when a dashboard is already listening, detected via `_probeDashboardLazy()` reading `.gsd-t/.dashboard.pid` + `process.kill(pid, 0)`) OR `▶ Transcript file: {logPath}\n (to view live: gsd-t-visualize)` (when no dashboard is up). Pre-M49 the spawn unconditionally autostarted a dashboard via `ensureDashboardRunning()` and printed the URL — that accumulated 88+ orphan dashboard processes because 99% of those URLs are never opened. M49 removed the autostart from the spawn path; the dashboard now only starts when the user explicitly invokes `/gsd-t-visualize`. Best-effort — banner failure never crashes the spawn.
|
|
77
|
+
- **Dashboard autostart** (`scripts/gsd-t-dashboard-autostart.cjs`, ~160 lines, zero deps): `ensureDashboardRunning({projectDir, port?})` probes the port synchronously via a short-lived subprocess (`_isPortBusySync` issues `net.createServer().listen(port)` host-less — matches the server's IPv6-wildcard bind on macOS dual-stack; specifying `127.0.0.1` would falsely report free). If free, fork-detaches the server with `spawn(…, {detached:true, stdio:'ignore'})` + `child.unref()` + writes `.gsd-t/.dashboard.pid` (hyphen → dot distinguishes this lifecycle from M38's `.gsd-t/dashboard.pid`). Idempotent on repeated invocation. **M49 — only called by `/gsd-t-visualize` now**, never by the spawn path; combined with the dashboard's idle-TTL self-shutdown (4-hour default, configurable via `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms`) this caps the long-tail orphan accumulation.
|
|
78
78
|
- **Contract**: `.gsd-t/contracts/dashboard-server-contract.md` v1.2.0 — new §HTTP Endpoints entries, §Banner Format, §Autostart sections. (Bumped to v1.3.0 in M47 — see Focused Visualizer Redesign below.)
|
|
79
79
|
- **Tests**: `test/m43-dashboard-tool-cost-route.test.js` (9), `test/m43-transcript-panel.test.js` (12), `test/m43-dashboard-autostart.test.js` (6), `test/m43-url-banner.test.js` (3).
|
|
80
80
|
|
|
@@ -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.10",
|
|
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/",
|
|
@@ -773,13 +773,106 @@ function handleSpawnPlanUpdates(req, res, projectDir) {
|
|
|
773
773
|
req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
|
|
774
774
|
}
|
|
775
775
|
|
|
776
|
-
|
|
776
|
+
// ── M49 — Idle-TTL self-shutdown ────────────────────────────────────────────
|
|
777
|
+
//
|
|
778
|
+
// A dashboard with zero HTTP requests AND zero active SSE connections for the
|
|
779
|
+
// full TTL window self-exits cleanly. Safety net for any dashboard that
|
|
780
|
+
// somehow gets started and then walks away — even if a future bug lets one
|
|
781
|
+
// be auto-started, it dies on its own. Configurable via env
|
|
782
|
+
// `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag.
|
|
783
|
+
//
|
|
784
|
+
// "Idle" means: zero HTTP requests AND zero active SSE connections for the
|
|
785
|
+
// full TTL window. `lastActivity` is bumped on every HTTP request handler
|
|
786
|
+
// entry and on SSE connect/disconnect. SSE-active dashboards never exit.
|
|
787
|
+
//
|
|
788
|
+
// On shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe (M49 in
|
|
789
|
+
// `bin/headless-auto-spawn.cjs`) sees a clean state.
|
|
790
|
+
|
|
791
|
+
const DEFAULT_IDLE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
792
|
+
const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60s
|
|
793
|
+
|
|
794
|
+
function _activityTracker() {
|
|
795
|
+
let lastActivity = Date.now();
|
|
796
|
+
let activeSseConnections = 0;
|
|
797
|
+
return {
|
|
798
|
+
bump() { lastActivity = Date.now(); },
|
|
799
|
+
sseConnect() { activeSseConnections++; lastActivity = Date.now(); },
|
|
800
|
+
sseDisconnect() {
|
|
801
|
+
if (activeSseConnections > 0) activeSseConnections--;
|
|
802
|
+
lastActivity = Date.now();
|
|
803
|
+
},
|
|
804
|
+
snapshot() { return { lastActivity, activeSseConnections }; },
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Wrap an SSE handler so it bumps the connect/disconnect counters.
|
|
810
|
+
*/
|
|
811
|
+
function _wrapSseHandler(handler, tracker) {
|
|
812
|
+
return function (req, res, ...rest) {
|
|
813
|
+
tracker.sseConnect();
|
|
814
|
+
let closed = false;
|
|
815
|
+
const onClose = () => {
|
|
816
|
+
if (closed) return;
|
|
817
|
+
closed = true;
|
|
818
|
+
tracker.sseDisconnect();
|
|
819
|
+
};
|
|
820
|
+
req.on("close", onClose);
|
|
821
|
+
res.on("close", onClose);
|
|
822
|
+
res.on("finish", onClose);
|
|
823
|
+
return handler(req, res, ...rest);
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* @param {object} opts { ttlMs, intervalMs, projectDir, server }
|
|
829
|
+
* @returns timer handle (so callers can clearInterval in tests).
|
|
830
|
+
*/
|
|
831
|
+
function _startIdleTtlTimer({ ttlMs, intervalMs, projectDir, server, tracker, onShutdown }) {
|
|
832
|
+
const interval = setInterval(() => {
|
|
833
|
+
const { lastActivity, activeSseConnections } = tracker.snapshot();
|
|
834
|
+
const idle = Date.now() - lastActivity;
|
|
835
|
+
if (activeSseConnections === 0 && idle >= ttlMs) {
|
|
836
|
+
clearInterval(interval);
|
|
837
|
+
try {
|
|
838
|
+
// Remove pid file so the lazy probe in headless-auto-spawn sees clean state.
|
|
839
|
+
if (projectDir) {
|
|
840
|
+
const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
|
|
841
|
+
try { fs.unlinkSync(pidFile); } catch { /* may not exist */ }
|
|
842
|
+
}
|
|
843
|
+
} catch { /* best-effort */ }
|
|
844
|
+
try { if (typeof onShutdown === "function") onShutdown(); } catch { /* best-effort */ }
|
|
845
|
+
try {
|
|
846
|
+
if (server) server.close(() => process.exit(0));
|
|
847
|
+
else process.exit(0);
|
|
848
|
+
} catch { process.exit(0); }
|
|
849
|
+
}
|
|
850
|
+
}, intervalMs);
|
|
851
|
+
if (typeof interval.unref === "function") interval.unref();
|
|
852
|
+
return interval;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, opts) {
|
|
777
856
|
const projDir = projectDir || path.resolve(eventsDir, "..", "..");
|
|
778
857
|
const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
|
|
858
|
+
const tracker = _activityTracker();
|
|
859
|
+
const ttlMs = (opts && Number.isFinite(opts.idleTtlMs))
|
|
860
|
+
? opts.idleTtlMs
|
|
861
|
+
: (Number.parseInt(process.env.GSD_T_DASHBOARD_IDLE_TTL_MS || "", 10) || DEFAULT_IDLE_TTL_MS);
|
|
862
|
+
const intervalMs = (opts && Number.isFinite(opts.idleCheckIntervalMs))
|
|
863
|
+
? opts.idleCheckIntervalMs
|
|
864
|
+
: IDLE_CHECK_INTERVAL_MS;
|
|
865
|
+
|
|
866
|
+
// Wrap the three SSE handlers with the connect/disconnect tracker.
|
|
867
|
+
const handleEventsSse = _wrapSseHandler(handleEvents, tracker);
|
|
868
|
+
const handleTranscriptStreamSse = _wrapSseHandler(handleTranscriptStream, tracker);
|
|
869
|
+
const handleSpawnPlanUpdatesSse = _wrapSseHandler(handleSpawnPlanUpdates, tracker);
|
|
870
|
+
|
|
779
871
|
const server = http.createServer((req, res) => {
|
|
872
|
+
tracker.bump(); // bump on every HTTP request handler entry
|
|
780
873
|
const url = req.url.split("?")[0];
|
|
781
874
|
if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
|
|
782
|
-
if (url === "/events") return
|
|
875
|
+
if (url === "/events") return handleEventsSse(req, res, eventsDir);
|
|
783
876
|
if (url === "/metrics") return handleMetrics(req, res, projDir);
|
|
784
877
|
if (url === "/ping") return handlePing(req, res, port);
|
|
785
878
|
if (url === "/stop") return handleStop(req, res, server);
|
|
@@ -788,7 +881,7 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
788
881
|
if (url === "/api/main-session") return handleMainSession(req, res, projDir);
|
|
789
882
|
// M44 D8 — spawn plans: GET list + SSE change stream
|
|
790
883
|
if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
|
|
791
|
-
if (url === "/api/spawn-plans/stream") return
|
|
884
|
+
if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdatesSse(req, res, projDir);
|
|
792
885
|
// M44 D9 — parallelism observability (additive, read-only)
|
|
793
886
|
if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
|
|
794
887
|
if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
|
|
@@ -805,14 +898,30 @@ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath)
|
|
|
805
898
|
if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
|
|
806
899
|
// /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
|
|
807
900
|
const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
|
|
808
|
-
if (streamMatch) return
|
|
901
|
+
if (streamMatch) return handleTranscriptStreamSse(req, res, decodeURIComponent(streamMatch[1]), projDir);
|
|
809
902
|
// /transcript/:spawnId — HTML viewer page
|
|
810
903
|
const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
|
|
811
904
|
if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
|
|
812
905
|
res.writeHead(404); res.end("Not found");
|
|
813
906
|
});
|
|
814
907
|
server.listen(port);
|
|
815
|
-
|
|
908
|
+
|
|
909
|
+
// M49 — install idle-TTL self-shutdown timer. Skipped only when caller
|
|
910
|
+
// explicitly passes `idleTtlMs: 0` (used by tests that don't want the
|
|
911
|
+
// server to self-exit mid-test).
|
|
912
|
+
let idleTimer = null;
|
|
913
|
+
if (ttlMs > 0) {
|
|
914
|
+
idleTimer = _startIdleTtlTimer({
|
|
915
|
+
ttlMs,
|
|
916
|
+
intervalMs,
|
|
917
|
+
projectDir: projDir,
|
|
918
|
+
server,
|
|
919
|
+
tracker,
|
|
920
|
+
onShutdown: opts && opts.onShutdown,
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
return { server, url: `http://localhost:${port}`, tracker, idleTimer };
|
|
816
925
|
}
|
|
817
926
|
|
|
818
927
|
module.exports = {
|
|
@@ -850,6 +959,11 @@ module.exports = {
|
|
|
850
959
|
handleParallelism,
|
|
851
960
|
handleParallelismReport,
|
|
852
961
|
handleUnattendedStop,
|
|
962
|
+
// M49 — idle-TTL exports for tests
|
|
963
|
+
_activityTracker,
|
|
964
|
+
_wrapSseHandler,
|
|
965
|
+
_startIdleTtlTimer,
|
|
966
|
+
DEFAULT_IDLE_TTL_MS,
|
|
853
967
|
};
|
|
854
968
|
|
|
855
969
|
if (require.main === module) {
|
|
@@ -875,9 +989,25 @@ if (require.main === module) {
|
|
|
875
989
|
fs.writeFileSync(pidFile, String(child.pid));
|
|
876
990
|
process.exit(0);
|
|
877
991
|
}
|
|
878
|
-
|
|
992
|
+
// M49 — idle-TTL flag/env override. Falls through to startServer's default
|
|
993
|
+
// (env var GSD_T_DASHBOARD_IDLE_TTL_MS or 4h).
|
|
994
|
+
const ttlArg = getArg("--idle-ttl-ms");
|
|
995
|
+
const startOpts = {};
|
|
996
|
+
if (ttlArg != null && ttlArg !== "") {
|
|
997
|
+
const n = Number.parseInt(ttlArg, 10);
|
|
998
|
+
if (Number.isFinite(n)) startOpts.idleTtlMs = n;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, startOpts);
|
|
879
1002
|
process.stdout.write("GSD-T Dashboard: " + url + "\n");
|
|
880
|
-
function cleanup() {
|
|
1003
|
+
function cleanup() {
|
|
1004
|
+
try { fs.unlinkSync(pidFile); } catch { /* ok */ }
|
|
1005
|
+
// M49 — also remove the lazy-probe pidfile so headless-auto-spawn sees clean state.
|
|
1006
|
+
try {
|
|
1007
|
+
fs.unlinkSync(path.join(projectDir, ".gsd-t", ".dashboard.pid"));
|
|
1008
|
+
} catch { /* ok */ }
|
|
1009
|
+
server.close(() => process.exit(0));
|
|
1010
|
+
}
|
|
881
1011
|
process.on("SIGTERM", cleanup);
|
|
882
1012
|
process.on("SIGINT", cleanup);
|
|
883
1013
|
}
|
|
@@ -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
|
|