@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.
@@ -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
- // M43 D6-T4 Ensure dashboard is running (idempotent; no-op if already up).
130
- // Must happen BEFORE the URL banner print (D6-T3) so the link is live.
131
- // Never throws autostart is best-effort.
132
- let autostartInfo = null;
133
- try {
134
- const { ensureDashboardRunning } = require("../scripts/gsd-t-dashboard-autostart.cjs");
135
- autostartInfo = ensureDashboardRunning({ projectDir });
136
- } catch (_) {
137
- /* best-effort; fall through without banner port info */
138
- }
252
+ // M49Lazy 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
- // M43 D6-T3 Live transcript URL banner. Printed for every spawn so the
159
- // viewer at :PORT is "the" primary watching surface. Never throws.
160
- // Text is coordinated with D4 exact line shape is part of
161
- // dashboard-server-contract.md §Banner Format.
283
+ // M49Conditional 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
- let port = autostartInfo && autostartInfo.port;
164
- if (!port) {
165
- const { projectScopedDefaultPort } = require("../scripts/gsd-t-dashboard-server.js");
166
- port = projectScopedDefaultPort(projectDir);
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
+ };