@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
|
@@ -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 = {
|
|
@@ -55,6 +80,11 @@ module.exports = {
|
|
|
55
80
|
// it now unconditionally returns true. See headless-default-contract
|
|
56
81
|
// v2.0.0 §Invariants.
|
|
57
82
|
shouldSpawnHeadless: () => true,
|
|
83
|
+
// M49 — exported for tests. Synchronous probe; never throws.
|
|
84
|
+
_probeDashboardLazy,
|
|
85
|
+
// M50 D2 — exported for tests. Whitelist + classifier, both synchronous.
|
|
86
|
+
TESTING_OR_UI_COMMANDS,
|
|
87
|
+
_isTestingOrUICommand,
|
|
58
88
|
};
|
|
59
89
|
|
|
60
90
|
// M43 D4 — one-shot deprecation banner when a caller still passes `watch`
|
|
@@ -119,6 +149,99 @@ function autoSpawnHeadless(opts) {
|
|
|
119
149
|
);
|
|
120
150
|
}
|
|
121
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
|
+
|
|
122
245
|
const timestamp = new Date().toISOString();
|
|
123
246
|
const id = makeSessionId(command, new Date());
|
|
124
247
|
const logPath = path.join(projectDir, LOG_DIR_REL, `headless-${id}.log`);
|
|
@@ -126,16 +249,18 @@ function autoSpawnHeadless(opts) {
|
|
|
126
249
|
ensureDir(path.join(projectDir, LOG_DIR_REL));
|
|
127
250
|
ensureDir(path.join(projectDir, SESSIONS_DIR_REL));
|
|
128
251
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
252
|
+
// M49 — Lazy dashboard. Spawns no longer autostart the dashboard. Each
|
|
253
|
+
// spawn would otherwise fork-detach a fresh `gsd-t-dashboard-server.js`
|
|
254
|
+
// (M43 D6-T4); 99% of those banners are never opened so they accumulated
|
|
255
|
+
// to 88+ orphans on the project-scoped port range. The dashboard now only
|
|
256
|
+
// starts when the user explicitly invokes `/gsd-t-visualize`. The banner
|
|
257
|
+
// below remains, but becomes conditional on whether a dashboard is already
|
|
258
|
+
// listening on the project's scoped port.
|
|
259
|
+
//
|
|
260
|
+
// Probe is sync + cheap: read `.gsd-t/.dashboard.pid` and `process.kill(pid, 0)`.
|
|
261
|
+
// Falls back to "no dashboard" on any error — never throws.
|
|
262
|
+
// See .gsd-t/contracts/headless-default-contract.md v2.0.0 §M49 (lazy banner).
|
|
263
|
+
const dashboardInfo = _probeDashboardLazy(projectDir);
|
|
139
264
|
|
|
140
265
|
// M46 follow-up — Date + version banner. Printed before the transcript URL
|
|
141
266
|
// so multi-day-old read-backs are immediately dated. Best-effort.
|
|
@@ -155,17 +280,27 @@ function autoSpawnHeadless(opts) {
|
|
|
155
280
|
/* best-effort — never crash the spawn on banner failure */
|
|
156
281
|
}
|
|
157
282
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
283
|
+
// M49 — Conditional transcript banner. If a dashboard is already running
|
|
284
|
+
// on the project's scoped port, point at the live URL (M43 D6-T3 shape).
|
|
285
|
+
// Otherwise, point at the on-disk log path and hint at /gsd-t-visualize so
|
|
286
|
+
// the user knows how to open the viewer if they want it. The viewer URL
|
|
287
|
+
// is no longer printed for spawns where no listener exists — that is what
|
|
288
|
+
// caused users to assume one was running and accumulated orphans on retry.
|
|
289
|
+
//
|
|
290
|
+
// Text is coordinated with D4 — banner format spec in
|
|
291
|
+
// .gsd-t/contracts/dashboard-server-contract.md v1.3.0 §Banner Format
|
|
292
|
+
// and headless-default-contract.md v2.0.0 §M49.
|
|
162
293
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
294
|
+
if (dashboardInfo.running && dashboardInfo.port) {
|
|
295
|
+
process.stdout.write(
|
|
296
|
+
`▶ Live transcript: http://127.0.0.1:${dashboardInfo.port}/transcript/${id}\n`,
|
|
297
|
+
);
|
|
298
|
+
} else {
|
|
299
|
+
const relLog = path.relative(projectDir, logPath);
|
|
300
|
+
process.stdout.write(
|
|
301
|
+
`▶ Transcript file: ${relLog}\n (to view live: gsd-t-visualize)\n`,
|
|
302
|
+
);
|
|
167
303
|
}
|
|
168
|
-
process.stdout.write(`▶ Live transcript: http://127.0.0.1:${port}/transcript/${id}\n`);
|
|
169
304
|
} catch (_) {
|
|
170
305
|
/* best-effort — never crash the spawn on banner failure */
|
|
171
306
|
}
|
|
@@ -515,6 +650,57 @@ function appendTokenLog(projectDir, entry) {
|
|
|
515
650
|
|
|
516
651
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
517
652
|
|
|
653
|
+
/**
|
|
654
|
+
* M49 — Cheap synchronous probe: is a dashboard listening on this project's
|
|
655
|
+
* scoped port? Strategy: read `.gsd-t/.dashboard.pid`; if the pid is alive
|
|
656
|
+
* (`process.kill(pid, 0)` doesn't throw), assume the dashboard is up and
|
|
657
|
+
* resolve the port via `projectScopedDefaultPort(projectDir)`. No child
|
|
658
|
+
* process forking — this runs on every spawn and must be cheap.
|
|
659
|
+
*
|
|
660
|
+
* Returns `{ running: boolean, port: number|null, pid: number|null }`. Never
|
|
661
|
+
* throws. A return of `{ running: false }` is the safe fallback that drives
|
|
662
|
+
* the file-path-only banner.
|
|
663
|
+
*
|
|
664
|
+
* @param {string} projectDir
|
|
665
|
+
* @returns {{ running: boolean, port: number|null, pid: number|null }}
|
|
666
|
+
*/
|
|
667
|
+
function _probeDashboardLazy(projectDir) {
|
|
668
|
+
const out = { running: false, port: null, pid: null };
|
|
669
|
+
try {
|
|
670
|
+
const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
|
|
671
|
+
if (!fs.existsSync(pidFile)) return out;
|
|
672
|
+
const raw = fs.readFileSync(pidFile, "utf8").trim();
|
|
673
|
+
const pid = parseInt(raw, 10);
|
|
674
|
+
if (!pid || Number.isNaN(pid) || pid <= 0) return out;
|
|
675
|
+
// process.kill(pid, 0) — signal 0 only checks for existence/permission.
|
|
676
|
+
// Throws ESRCH if no such process; EPERM if process exists but owned by
|
|
677
|
+
// someone else. EPERM still implies "alive", so treat both ESRCH-only as
|
|
678
|
+
// dead.
|
|
679
|
+
try {
|
|
680
|
+
process.kill(pid, 0);
|
|
681
|
+
} catch (err) {
|
|
682
|
+
if (err && err.code === "EPERM") {
|
|
683
|
+
// Alive but not owned by us — still a live listener; treat as running.
|
|
684
|
+
} else {
|
|
685
|
+
return out; // ESRCH or unknown — treat as dead.
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
out.pid = pid;
|
|
689
|
+
out.running = true;
|
|
690
|
+
try {
|
|
691
|
+
const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
|
|
692
|
+
out.port = projectScopedDefaultPort(projectDir);
|
|
693
|
+
} catch (_) {
|
|
694
|
+
// Without the port we can't render the URL — fall back to file banner.
|
|
695
|
+
out.running = false;
|
|
696
|
+
out.port = null;
|
|
697
|
+
}
|
|
698
|
+
} catch (_) {
|
|
699
|
+
/* probe is best-effort */
|
|
700
|
+
}
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
518
704
|
function ensureDir(d) {
|
|
519
705
|
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
|
|
520
706
|
}
|
|
@@ -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
|
+
};
|