rcf-lite 0.16.0 → 0.17.0
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 +14 -0
- package/blueprints/application-spa/assets/tc-templates/e2e.md +85 -0
- package/blueprints/application-spa/blueprint.json +24 -2
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1134.json +24 -0
- package/blueprints/application-spa/contributions/user-stories/application-spa-us-1135.json +24 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/github-actions/pull-request-checks.yml +69 -0
- package/blueprints/delivery-ci-workflows/assets/ci-provider-examples/notes.md +18 -0
- package/blueprints/delivery-ci-workflows/blueprint.json +301 -60
- package/blueprints/delivery-ci-workflows/contributions/user-stories/delivery-ci-workflows-us-6124.json +28 -0
- package/fixtures/canary-manifest.json +9 -9
- package/package.json +13 -1
- package/rcf/code-nodes/cn-070.json +12 -0
- package/rcf/code-nodes/cn-071.json +12 -0
- package/rcf/code-nodes/cn-072.json +12 -0
- package/rcf/code-nodes/cn-073.json +12 -0
- package/rcf/fbs/fbs-020.json +18 -0
- package/rcf/fbs/fbs-021.json +18 -0
- package/rcf/fbs/fbs-022.json +18 -0
- package/rcf/fbs/fbs-023.json +18 -0
- package/rcf/prd.json +3 -2
- package/rcf/requirements/req-011.json +22 -0
- package/rcf/test-suites/ts-030.json +66 -0
- package/rcf/test-suites/ts-031.json +59 -0
- package/rcf/test-suites/ts-032.json +50 -0
- package/rcf/test-suites/ts-033.json +83 -0
- package/rcf/user-stories/us-1101.json +51 -0
- package/rcf/user-stories/us-1102.json +60 -0
- package/rcf/user-stories/us-1103.json +51 -0
- package/rcf/user-stories/us-1104.json +60 -0
- package/releases/releases.yaml +11 -1
- package/src/blueprint/supersede.js +2 -2
- package/src/cli/doctor.js +182 -5
- package/src/cli/init.js +166 -0
- package/src/setup/playwright-checks.js +426 -0
- package/src/verify/cli/run.js +28 -0
- package/src/verify/engine/index.js +24 -4
- package/src/verify/engine/launcher.js +41 -10
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
// Doctor's browser-facing project checks (spec 2026-09-03, section 3) plus
|
|
2
|
+
// init-time Playwright signature detection and the Claude Code cross-scope
|
|
3
|
+
// probe (spec sections 4.1-4.3). All probes are injectable via a `deps`
|
|
4
|
+
// argument so the unit suite runs with none of these tools installed.
|
|
5
|
+
//
|
|
6
|
+
// A project is browser-facing exactly when its rcf/manifest.json carries at
|
|
7
|
+
// least one blueprint in manifest.blueprints[] whose loaded blueprint.json
|
|
8
|
+
// declares `browserSurface`. Detection is a manifest fact, not string grammar:
|
|
9
|
+
// doctor loads the manifest, walks manifest.blueprints[].source, reads each
|
|
10
|
+
// blueprint.json, and evaluates the flag. Blueprints not shipping the field
|
|
11
|
+
// are treated as not-browser-facing, and no per-slug allowlist exists.
|
|
12
|
+
//
|
|
13
|
+
// The 15-second cap on the reachability probe is a diagnostic ceiling; on a
|
|
14
|
+
// warm cache the probe returns in well under a second, and the ceiling only
|
|
15
|
+
// matters where npx would be doing a fresh network fetch it should not be
|
|
16
|
+
// doing on a diagnostic run.
|
|
17
|
+
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
import { homedir } from 'node:os';
|
|
22
|
+
import { basename, isAbsolute, join, resolve as pathResolve } from 'node:path';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
|
|
25
|
+
import { PLAYWRIGHT_MCP_VERSION } from '../verify/engine/launcher.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The three fix lines emitted per check, verbatim from the spec's fix-line
|
|
29
|
+
* table. Tests assert the strings; changing them requires a spec amendment.
|
|
30
|
+
*/
|
|
31
|
+
export const FIX_LINES = Object.freeze({
|
|
32
|
+
'playwright-present':
|
|
33
|
+
'Install the peer dependency: npm i -D playwright@^1.50.0 (or the pnpm/yarn equivalent for your project).',
|
|
34
|
+
'browser-present':
|
|
35
|
+
'Install a browser: npx @playwright/mcp install-browser chromium (Playwright-managed) or install system Google Chrome (used by @playwright/mcp by default).',
|
|
36
|
+
'playwright-mcp-reachable':
|
|
37
|
+
`Install @playwright/mcp: npm i -D @playwright/mcp@${PLAYWRIGHT_MCP_VERSION} (the pinned version rcf verify runs).`,
|
|
38
|
+
'playwright-mcp-redundant':
|
|
39
|
+
"project-scope .mcp.json carries a Playwright MCP entry that is also declared at user scope. The project entry shadows the user entry. Remove the project entry with `rcf init --no-playwright-mcp` (which re-runs init without writing it), or delete the entry from .mcp.json by hand.",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The single skip line for non-browser-facing projects. Not suppressed by
|
|
44
|
+
* --quiet (spec 3.4).
|
|
45
|
+
*/
|
|
46
|
+
export const SKIP_LINE_NON_BROWSER_FACING =
|
|
47
|
+
'rcf doctor: skipping playwright-present, browser-present, playwright-mcp-reachable (no applied blueprint declares a browser surface).';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Is the manifest a browser-facing project per spec 3.1?
|
|
51
|
+
*
|
|
52
|
+
* @param {string} projectRoot
|
|
53
|
+
* @param {object} [deps]
|
|
54
|
+
* @param {(source: string) => Promise<object|null>} [deps.readBlueprintManifest] - test seam
|
|
55
|
+
* @returns {Promise<{ browserFacing: boolean, sources: string[] }>}
|
|
56
|
+
*/
|
|
57
|
+
export async function loadBrowserFacingSources(projectRoot, deps = {}) {
|
|
58
|
+
const readManifest = deps.readBlueprintManifest ?? defaultReadBlueprintManifest;
|
|
59
|
+
const rootManifestPath = join(projectRoot, 'rcf', 'manifest.json');
|
|
60
|
+
let manifest = null;
|
|
61
|
+
try {
|
|
62
|
+
manifest = JSON.parse(await readFile(rootManifestPath, 'utf8'));
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if (err.code === 'ENOENT') return { browserFacing: false, sources: [] };
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
const applied = Array.isArray(manifest?.blueprints) ? manifest.blueprints : [];
|
|
68
|
+
const sources = [];
|
|
69
|
+
for (const record of applied) {
|
|
70
|
+
if (!record || typeof record.source !== 'string') continue;
|
|
71
|
+
const resolved = isAbsolute(record.source)
|
|
72
|
+
? record.source
|
|
73
|
+
: pathResolve(projectRoot, record.source);
|
|
74
|
+
let bp;
|
|
75
|
+
try {
|
|
76
|
+
bp = await readManifest(resolved);
|
|
77
|
+
} catch {
|
|
78
|
+
bp = null;
|
|
79
|
+
}
|
|
80
|
+
if (bp && bp.browserSurface && bp.browserSurface.declared === true) {
|
|
81
|
+
sources.push(resolved);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { browserFacing: sources.length > 0, sources };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function defaultReadBlueprintManifest(source) {
|
|
88
|
+
const metaPath = join(source, 'blueprint.json');
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(await readFile(metaPath, 'utf8'));
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Check that `playwright` is resolvable from the project root.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} projectRoot
|
|
100
|
+
* @returns {{ ok: boolean, resolvedFrom: (string|null) }}
|
|
101
|
+
*/
|
|
102
|
+
export function checkPlaywrightPresent(projectRoot) {
|
|
103
|
+
const req = createRequire(join(projectRoot, '__rcf-require-anchor__'));
|
|
104
|
+
try {
|
|
105
|
+
const resolvedFrom = req.resolve('playwright');
|
|
106
|
+
return { ok: true, resolvedFrom };
|
|
107
|
+
} catch {
|
|
108
|
+
return { ok: false, resolvedFrom: null };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check that some Chromium is available to @playwright/mcp: system Chrome on
|
|
114
|
+
* PATH first, then a Playwright-managed cache directory, then the tool's own
|
|
115
|
+
* first-party status probe.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [deps]
|
|
118
|
+
* @param {(name: string) => boolean} [deps.pathHas]
|
|
119
|
+
* @param {(path: string) => boolean} [deps.dirExists]
|
|
120
|
+
* @param {() => Promise<{ ok: boolean, source: string }>} [deps.probeMcpBrowserStatus]
|
|
121
|
+
* @param {() => string} [deps.homedir]
|
|
122
|
+
* @returns {Promise<{ ok: boolean, source: (string|null) }>}
|
|
123
|
+
*/
|
|
124
|
+
export async function checkBrowserPresent(deps = {}) {
|
|
125
|
+
const pathHas = deps.pathHas ?? defaultPathHas;
|
|
126
|
+
const dirExists = deps.dirExists ?? existsSync;
|
|
127
|
+
const home = deps.homedir ?? homedir;
|
|
128
|
+
for (const bin of ['google-chrome-stable', 'chromium', 'chrome', 'Google Chrome']) {
|
|
129
|
+
if (pathHas(bin)) return { ok: true, source: `path:${bin}` };
|
|
130
|
+
}
|
|
131
|
+
const playwrightCache = join(home(), '.cache', 'ms-playwright');
|
|
132
|
+
if (dirExists(playwrightCache)) {
|
|
133
|
+
try {
|
|
134
|
+
const entries = await readdir(playwrightCache);
|
|
135
|
+
if (entries.some((e) => /^chromium/i.test(e))) {
|
|
136
|
+
return { ok: true, source: `cache:${playwrightCache}` };
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// fall through to first-party probe
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const probe = deps.probeMcpBrowserStatus ?? defaultProbeMcpBrowserStatus;
|
|
143
|
+
try {
|
|
144
|
+
const res = await probe();
|
|
145
|
+
if (res && res.ok) return { ok: true, source: `mcp:${res.source ?? 'ok'}` };
|
|
146
|
+
} catch {
|
|
147
|
+
// treat any probe failure as no browser
|
|
148
|
+
}
|
|
149
|
+
return { ok: false, source: null };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function defaultPathHas(binName) {
|
|
153
|
+
const path = process.env.PATH ?? '';
|
|
154
|
+
const sep = process.platform === 'win32' ? ';' : ':';
|
|
155
|
+
for (const dir of path.split(sep)) {
|
|
156
|
+
if (!dir) continue;
|
|
157
|
+
try {
|
|
158
|
+
const p = join(dir, binName);
|
|
159
|
+
if (existsSync(p)) return true;
|
|
160
|
+
} catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function defaultProbeMcpBrowserStatus() {
|
|
168
|
+
return runNpxProbe(['--no-install', '@playwright/mcp', 'browser-status', '--json'], 15000)
|
|
169
|
+
.then((r) => (r.exitCode === 0 ? { ok: true, source: 'browser-status' } : { ok: false }))
|
|
170
|
+
.catch(() => ({ ok: false }));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Check that `npx --no-install @playwright/mcp --help` runs and exits 0 in
|
|
175
|
+
* under 15 seconds. `--no-install` so the check does not silently pull the
|
|
176
|
+
* package during a diagnostic run.
|
|
177
|
+
*
|
|
178
|
+
* @param {object} [deps]
|
|
179
|
+
* @param {(args: string[], timeoutMs: number) => Promise<{ exitCode: number, timedOut: boolean }>} [deps.runNpx]
|
|
180
|
+
* @param {number} [deps.timeoutMs]
|
|
181
|
+
* @returns {Promise<{ ok: boolean, exitCode: (number|null), timedOut: boolean }>}
|
|
182
|
+
*/
|
|
183
|
+
export async function checkPlaywrightMcpReachable(deps = {}) {
|
|
184
|
+
const runNpx = deps.runNpx ?? runNpxProbe;
|
|
185
|
+
const timeoutMs = deps.timeoutMs ?? 15000;
|
|
186
|
+
try {
|
|
187
|
+
const res = await runNpx(['--no-install', '@playwright/mcp', '--help'], timeoutMs);
|
|
188
|
+
return {
|
|
189
|
+
ok: res.exitCode === 0,
|
|
190
|
+
exitCode: res.exitCode ?? null,
|
|
191
|
+
timedOut: Boolean(res.timedOut),
|
|
192
|
+
};
|
|
193
|
+
} catch (err) {
|
|
194
|
+
return { ok: false, exitCode: null, timedOut: false, error: err.message };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function runNpxProbe(args, timeoutMs) {
|
|
199
|
+
return new Promise((resolvePromise) => {
|
|
200
|
+
let child;
|
|
201
|
+
try {
|
|
202
|
+
child = spawn('npx', args, { stdio: 'ignore' });
|
|
203
|
+
} catch (err) {
|
|
204
|
+
resolvePromise({ exitCode: null, timedOut: false, error: err.message });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let done = false;
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
done = true;
|
|
210
|
+
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
|
211
|
+
resolvePromise({ exitCode: null, timedOut: true });
|
|
212
|
+
}, timeoutMs);
|
|
213
|
+
child.on('close', (code) => {
|
|
214
|
+
if (done) return;
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
done = true;
|
|
217
|
+
resolvePromise({ exitCode: code ?? null, timedOut: false });
|
|
218
|
+
});
|
|
219
|
+
child.on('error', (err) => {
|
|
220
|
+
if (done) return;
|
|
221
|
+
clearTimeout(timer);
|
|
222
|
+
done = true;
|
|
223
|
+
resolvePromise({ exitCode: null, timedOut: false, error: err.message });
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ================================================================== */
|
|
229
|
+
/* Playwright signature detection (spec 4.1) + probe (spec 4.2/4.3). */
|
|
230
|
+
/* ================================================================== */
|
|
231
|
+
|
|
232
|
+
/** Regex naming a @playwright/mcp args token (any pinning suffix accepted). */
|
|
233
|
+
const PLAYWRIGHT_MCP_TOKEN = /^@playwright\/mcp(?:@|$)/;
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* True iff a parsed mcpServers[<name>] entry has a Playwright signature by
|
|
237
|
+
* command tail: any `args` string matching /^@playwright\/mcp(@|$)/, or a
|
|
238
|
+
* `command` path whose basename is `mcp` and lives under a @playwright/mcp
|
|
239
|
+
* package directory.
|
|
240
|
+
*
|
|
241
|
+
* @param {unknown} entry
|
|
242
|
+
* @returns {boolean}
|
|
243
|
+
*/
|
|
244
|
+
export function hasPlaywrightSignature(entry) {
|
|
245
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
246
|
+
const args = Array.isArray(entry.args) ? entry.args : [];
|
|
247
|
+
if (args.some((a) => typeof a === 'string' && PLAYWRIGHT_MCP_TOKEN.test(a))) return true;
|
|
248
|
+
if (typeof entry.command === 'string') {
|
|
249
|
+
const cmd = entry.command;
|
|
250
|
+
if (basename(cmd) === 'mcp' && /@playwright\/mcp/.test(cmd)) return true;
|
|
251
|
+
}
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Look up the first project-scope entry key carrying a Playwright signature
|
|
257
|
+
* in a parsed .mcp.json body. Returns null when no entry matches.
|
|
258
|
+
*
|
|
259
|
+
* @param {object|null|undefined} mcpJson - the parsed .mcp.json body
|
|
260
|
+
* @returns {string|null}
|
|
261
|
+
*/
|
|
262
|
+
export function findProjectPlaywrightKey(mcpJson) {
|
|
263
|
+
const servers = mcpJson?.mcpServers;
|
|
264
|
+
if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null;
|
|
265
|
+
for (const [key, entry] of Object.entries(servers)) {
|
|
266
|
+
if (hasPlaywrightSignature(entry)) return key;
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Probe the Claude Code harness for a Playwright entry at any scope by
|
|
273
|
+
* shelling out to `claude mcp list` and parsing its text output. `list`
|
|
274
|
+
* itself does not name the scope column (as of Claude Code 1.x); when the
|
|
275
|
+
* list surfaces a Playwright entry, follow up with `claude mcp get <name>`
|
|
276
|
+
* to read the scope. Result shapes:
|
|
277
|
+
*
|
|
278
|
+
* - { kind: 'found', name, scope } - list matched signature and get named scope
|
|
279
|
+
* - { kind: 'none' } - claude ran cleanly and reported no Playwright entry
|
|
280
|
+
* - { kind: 'inconclusive', reason } - probe could not prove either way
|
|
281
|
+
*
|
|
282
|
+
* @param {object} [deps]
|
|
283
|
+
* @param {(cmd: string, args: string[], timeoutMs: number) => Promise<{ exitCode: number, stdout: string, timedOut: boolean, error?: string }>} [deps.runProbe]
|
|
284
|
+
* @param {number} [deps.timeoutMs]
|
|
285
|
+
* @returns {Promise<{ kind: 'found', name: string, scope: string }
|
|
286
|
+
* | { kind: 'none' } | { kind: 'inconclusive', reason: string }>}
|
|
287
|
+
*/
|
|
288
|
+
export async function probeClaudeCodeMcp(deps = {}) {
|
|
289
|
+
const run = deps.runProbe ?? defaultRunProbeText;
|
|
290
|
+
const timeoutMs = deps.timeoutMs ?? 5000;
|
|
291
|
+
let res;
|
|
292
|
+
try {
|
|
293
|
+
res = await run('claude', ['mcp', 'list'], timeoutMs);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
return { kind: 'inconclusive', reason: `claude probe threw: ${err.message}` };
|
|
296
|
+
}
|
|
297
|
+
if (res.timedOut) return { kind: 'inconclusive', reason: 'claude mcp list timed out' };
|
|
298
|
+
if (res.error) return { kind: 'inconclusive', reason: `claude probe error: ${res.error}` };
|
|
299
|
+
if (res.exitCode !== 0) return { kind: 'inconclusive', reason: `claude mcp list exited ${res.exitCode}` };
|
|
300
|
+
const parsed = parseClaudeMcpListOutput(res.stdout ?? '');
|
|
301
|
+
if (parsed.kind === 'unparseable') {
|
|
302
|
+
return { kind: 'inconclusive', reason: 'claude mcp list output did not parse' };
|
|
303
|
+
}
|
|
304
|
+
if (parsed.kind === 'none') return { kind: 'none' };
|
|
305
|
+
// parsed.kind === 'found': list has a Playwright line but the shipping
|
|
306
|
+
// `claude mcp list` format does not carry an explicit scope column. Ask
|
|
307
|
+
// for the per-server detail so we can name the scope on the print-out.
|
|
308
|
+
let scope = parsed.scope;
|
|
309
|
+
if (!scope || scope === 'unknown') {
|
|
310
|
+
try {
|
|
311
|
+
const detail = await run('claude', ['mcp', 'get', parsed.name], timeoutMs);
|
|
312
|
+
if (detail.exitCode === 0 && typeof detail.stdout === 'string') {
|
|
313
|
+
scope = parseClaudeMcpGetScope(detail.stdout) ?? 'unknown';
|
|
314
|
+
} else {
|
|
315
|
+
scope = 'unknown';
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
scope = 'unknown';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return { kind: 'found', name: parsed.name, scope };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Read the Scope line out of `claude mcp get <name>` output. Format observed
|
|
326
|
+
* on Claude Code 1.x: ` Scope: User config (available in all your projects)`
|
|
327
|
+
* for user, ` Scope: Local config` / ` Scope: Project config` otherwise.
|
|
328
|
+
* Returns 'user' | 'project' | 'local', or null when the format is not
|
|
329
|
+
* recognised (the caller then reports scope as 'unknown').
|
|
330
|
+
*
|
|
331
|
+
* @param {string} text
|
|
332
|
+
* @returns {'user'|'project'|'local'|null}
|
|
333
|
+
*/
|
|
334
|
+
export function parseClaudeMcpGetScope(text) {
|
|
335
|
+
if (typeof text !== 'string') return null;
|
|
336
|
+
const m = text.match(/Scope:\s*(User|Project|Local)/i);
|
|
337
|
+
if (!m) return null;
|
|
338
|
+
return m[1].toLowerCase();
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parse `claude mcp list` text output for a Playwright signature. Claude
|
|
343
|
+
* Code 1.x prints each server on its own line as
|
|
344
|
+
* `<name>: <command line> - <status>`; there is no scope column on the
|
|
345
|
+
* list output (per-server scope is available via `claude mcp get <name>`),
|
|
346
|
+
* so this parser returns the name and leaves the scope as 'unknown' for
|
|
347
|
+
* the caller to resolve if it wants one.
|
|
348
|
+
*
|
|
349
|
+
* Accepted line shapes:
|
|
350
|
+
* - `<name>: <command line> - <status>` (Claude Code 1.x)
|
|
351
|
+
* - `<name> <scope> <command line>` (older columnar form, kept for
|
|
352
|
+
* tolerance)
|
|
353
|
+
*
|
|
354
|
+
* @param {string} text
|
|
355
|
+
* @returns {{ kind: 'found', name: string, scope: string } | { kind: 'none' } | { kind: 'unparseable' }}
|
|
356
|
+
*/
|
|
357
|
+
export function parseClaudeMcpListOutput(text) {
|
|
358
|
+
if (typeof text !== 'string') return { kind: 'unparseable' };
|
|
359
|
+
const lines = text.split('\n');
|
|
360
|
+
let sawAnyEntry = false;
|
|
361
|
+
for (const raw of lines) {
|
|
362
|
+
const line = raw.trim();
|
|
363
|
+
if (line.length === 0) continue;
|
|
364
|
+
if (/^(No |No MCP servers|Usage:|Error:|Checking |name\b)/i.test(line)) continue;
|
|
365
|
+
// Recognise a non-Playwright entry so we can distinguish "parseable but
|
|
366
|
+
// no Playwright" from "unparseable output".
|
|
367
|
+
if (/^[A-Za-z0-9_.\/@-]+:\s+/.test(line) || /\b(user|project|local)\b/.test(line)) {
|
|
368
|
+
sawAnyEntry = true;
|
|
369
|
+
}
|
|
370
|
+
if (!/@playwright\/mcp/.test(line)) continue;
|
|
371
|
+
|
|
372
|
+
// Extract the name. Both accepted shapes start with the name; the
|
|
373
|
+
// 1.x shape ends the name with a colon, the older shape is space-
|
|
374
|
+
// separated.
|
|
375
|
+
let name;
|
|
376
|
+
const colonForm = line.match(/^([A-Za-z0-9_.\/@-]+):\s+/);
|
|
377
|
+
if (colonForm) {
|
|
378
|
+
name = colonForm[1];
|
|
379
|
+
} else {
|
|
380
|
+
name = line.split(/\s+/)[0];
|
|
381
|
+
}
|
|
382
|
+
if (!name) continue;
|
|
383
|
+
// Older columnar shape may name the scope inline; keep that when present.
|
|
384
|
+
const scopeMatch = line.match(/\b(user|project|local)\b/);
|
|
385
|
+
const scope = scopeMatch ? scopeMatch[1] : 'unknown';
|
|
386
|
+
return { kind: 'found', name, scope };
|
|
387
|
+
}
|
|
388
|
+
if (sawAnyEntry) return { kind: 'none' };
|
|
389
|
+
if (lines.every((l) => l.trim().length === 0)) return { kind: 'none' };
|
|
390
|
+
return { kind: 'none' };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function defaultRunProbeText(cmd, args, timeoutMs) {
|
|
394
|
+
return new Promise((resolvePromise) => {
|
|
395
|
+
let child;
|
|
396
|
+
try {
|
|
397
|
+
child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
resolvePromise({ exitCode: null, stdout: '', timedOut: false, error: err.message });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
let stdout = '';
|
|
403
|
+
child.stdout?.on('data', (d) => { stdout += d.toString(); });
|
|
404
|
+
let done = false;
|
|
405
|
+
const timer = setTimeout(() => {
|
|
406
|
+
done = true;
|
|
407
|
+
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
|
408
|
+
resolvePromise({ exitCode: null, stdout, timedOut: true });
|
|
409
|
+
}, timeoutMs);
|
|
410
|
+
child.on('close', (code) => {
|
|
411
|
+
if (done) return;
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
done = true;
|
|
414
|
+
resolvePromise({ exitCode: code ?? null, stdout, timedOut: false });
|
|
415
|
+
});
|
|
416
|
+
child.on('error', (err) => {
|
|
417
|
+
if (done) return;
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
done = true;
|
|
420
|
+
resolvePromise({ exitCode: null, stdout, timedOut: false, error: err.message });
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Silence unused import warnings for values consumed only via names.
|
|
426
|
+
void stat;
|
package/src/verify/cli/run.js
CHANGED
|
@@ -12,6 +12,7 @@ import { parseArgs } from 'node:util';
|
|
|
12
12
|
import { formatError, isRcfError } from '#core/errors';
|
|
13
13
|
|
|
14
14
|
import { runVerification } from '../engine/index.js';
|
|
15
|
+
import { isSemverString, PLAYWRIGHT_MCP_VERSION } from '../engine/launcher.js';
|
|
15
16
|
import { serialiseReport } from '../report/index.js';
|
|
16
17
|
import { gateTripped, FINDING_SEVERITIES } from '../verdict/index.js';
|
|
17
18
|
|
|
@@ -26,6 +27,7 @@ const OPTION_SPEC = {
|
|
|
26
27
|
'severity-gate': { type: 'string' },
|
|
27
28
|
'provision-mode': { type: 'string' },
|
|
28
29
|
persona: { type: 'string' },
|
|
30
|
+
'playwright-mcp-version': { type: 'string' },
|
|
29
31
|
help: { type: 'boolean' },
|
|
30
32
|
};
|
|
31
33
|
|
|
@@ -51,6 +53,12 @@ Optional:
|
|
|
51
53
|
PASS | COSMETIC | DEGRADED | BROKEN
|
|
52
54
|
--provision-mode <m> run | skip (default: run)
|
|
53
55
|
--persona <name> Adversarial persona flavour (default: generic-sceptic)
|
|
56
|
+
--playwright-mcp-version <semver>
|
|
57
|
+
Override the pinned Playwright MCP version for
|
|
58
|
+
one run (emergency use only; a loud stderr notice
|
|
59
|
+
fires and the report records the overridden pin
|
|
60
|
+
as runStats.playwrightMcpVersion). Must be an
|
|
61
|
+
exact semver X.Y.Z.
|
|
54
62
|
--help Print this help
|
|
55
63
|
|
|
56
64
|
Exit codes:
|
|
@@ -119,6 +127,25 @@ export async function main(argv, deps = {}) {
|
|
|
119
127
|
return 2;
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
// --playwright-mcp-version override (spec 2026-09-03, section 1.4). Semver
|
|
131
|
+
// only; anything else refuses exit 2 with the spec-named message. When set,
|
|
132
|
+
// the effective pin fires a loud stderr override notice on preflight; when
|
|
133
|
+
// unset, preflight prints the pinned default.
|
|
134
|
+
const overrideRaw = flags['playwright-mcp-version'];
|
|
135
|
+
if (overrideRaw !== undefined && !isSemverString(overrideRaw)) {
|
|
136
|
+
// Spec sections 1.4 and 6 name the message without a prefix. The
|
|
137
|
+
// module's other refusals prefix with `[error] usage`, but this one
|
|
138
|
+
// is the spec-verbatim line and stands on its own.
|
|
139
|
+
stderr.write(`--playwright-mcp-version expects a semver string, got '${overrideRaw}'\n`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
if (overrideRaw !== undefined) {
|
|
143
|
+
// Loud override notice on stderr (spec 1.4: not silenceable with quiet).
|
|
144
|
+
stderr.write(`Playwright MCP: OVERRIDE @playwright/mcp@${overrideRaw} (pinned default: ${PLAYWRIGHT_MCP_VERSION})\n`);
|
|
145
|
+
} else {
|
|
146
|
+
stderr.write(`Playwright MCP: pinned to @playwright/mcp@${PLAYWRIGHT_MCP_VERSION}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
const result = await runVerification({
|
|
123
150
|
repo: flags.repo,
|
|
124
151
|
chainRef: flags.chain,
|
|
@@ -129,6 +156,7 @@ export async function main(argv, deps = {}) {
|
|
|
129
156
|
provisionMode,
|
|
130
157
|
persona: flags.persona,
|
|
131
158
|
severityGate: gate,
|
|
159
|
+
playwrightMcpVersion: overrideRaw,
|
|
132
160
|
}, deps);
|
|
133
161
|
|
|
134
162
|
if (isRcfError(result)) {
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { readChain as defaultReadChain } from '../chain/index.js';
|
|
19
19
|
import { runProvisioning, cleanup as defaultCleanup } from '../provision/index.js';
|
|
20
20
|
import { composeBrief } from './brief.js';
|
|
21
|
-
import { resolveLauncher } from './launcher.js';
|
|
21
|
+
import { PLAYWRIGHT_MCP_VERSION, playwrightMcpConfig, resolveLauncher } from './launcher.js';
|
|
22
22
|
import { aggregateVerdict, derivePerAcVerdicts, validateFinding } from '../verdict/index.js';
|
|
23
23
|
import { buildReport } from '../report/index.js';
|
|
24
24
|
|
|
@@ -127,11 +127,23 @@ export async function runVerification(opts = {}, deps = {}) {
|
|
|
127
127
|
if (cleanupResult.cleanupBlocked?.length) provisioning.cleanupBlocked = cleanupResult.cleanupBlocked;
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
+
// Playwright MCP effective pin (spec 2026-09-03, section 1). The override
|
|
131
|
+
// overrides the constant for one run; unset means the pinned default. The
|
|
132
|
+
// effective pin lands on the report as runStats.playwrightMcpVersion so a
|
|
133
|
+
// report re-render tells the operator what browser tooling the pass ran
|
|
134
|
+
// against, whether default or overridden (spec Q1).
|
|
135
|
+
const playwrightMcpVersion = opts.playwrightMcpVersion ?? PLAYWRIGHT_MCP_VERSION;
|
|
136
|
+
const playwrightMcpOverridden = opts.playwrightMcpVersion !== undefined
|
|
137
|
+
&& opts.playwrightMcpVersion !== null;
|
|
138
|
+
const mcpConfigForPin = playwrightMcpOverridden
|
|
139
|
+
? playwrightMcpConfig(playwrightMcpVersion)
|
|
140
|
+
: undefined;
|
|
141
|
+
|
|
130
142
|
// 6. Launch the isolated verifier agent (§7.3 isolation env, §9 fresh session).
|
|
131
143
|
let launchResult;
|
|
132
144
|
try {
|
|
133
145
|
const launchAgent = await resolveLauncher(deps);
|
|
134
|
-
launchResult = await launchAgent({ brief, url, profile });
|
|
146
|
+
launchResult = await launchAgent({ brief, url, profile, mcpConfig: mcpConfigForPin });
|
|
135
147
|
} catch (err) {
|
|
136
148
|
// A verifier agent that could not run — or whose output could not be
|
|
137
149
|
// ingested — is NEVER a fabricated PASS (§9). But the report is still
|
|
@@ -147,6 +159,7 @@ export async function runVerification(opts = {}, deps = {}) {
|
|
|
147
159
|
findings: [], blockedAcs, provisioning,
|
|
148
160
|
launchFailure: { message: err.message, rawOutputPath: err.rawOutputPath ?? null },
|
|
149
161
|
perAcVerdicts,
|
|
162
|
+
runStats: { playwrightMcpVersion },
|
|
150
163
|
});
|
|
151
164
|
return { report };
|
|
152
165
|
}
|
|
@@ -162,14 +175,21 @@ export async function runVerification(opts = {}, deps = {}) {
|
|
|
162
175
|
// 9. Aggregate the verdict — split, never averaged (§5.1).
|
|
163
176
|
const verdict = aggregateVerdict({ findings, blockedAcs, notDeployed: false });
|
|
164
177
|
|
|
165
|
-
// 10. Build the ingestible report (§5.3).
|
|
178
|
+
// 10. Build the ingestible report (§5.3). The effective Playwright MCP pin
|
|
179
|
+
// (default or overridden) always lands on the report as
|
|
180
|
+
// runStats.playwrightMcpVersion so a report re-render tells the operator
|
|
181
|
+
// which browser tooling this pass ran against (spec 2026-09-03, Q1).
|
|
182
|
+
const runStatsForReport = {
|
|
183
|
+
...(launchResult?.runStats ?? {}),
|
|
184
|
+
playwrightMcpVersion,
|
|
185
|
+
};
|
|
166
186
|
const report = buildReport({
|
|
167
187
|
profile, url, parityEnv, reachability, chainRef: chain.chainRef, repo: opts.repo,
|
|
168
188
|
persona: opts.persona, startedAt, finishedAt: now(),
|
|
169
189
|
verifierIsolation: isolationProvenance(),
|
|
170
190
|
verdict, verdictAuthority,
|
|
171
191
|
findings, blockedAcs, provisioning,
|
|
172
|
-
runStats:
|
|
192
|
+
runStats: runStatsForReport,
|
|
173
193
|
perAcVerdicts,
|
|
174
194
|
});
|
|
175
195
|
|
|
@@ -17,6 +17,46 @@ import { isolationEnv, isolationProvenance } from '#core/isolation';
|
|
|
17
17
|
/** Env var naming a module (exporting `launchAgent`) to use instead of the default spawn launcher. The integration + manual-e2e seam. */
|
|
18
18
|
export const LAUNCHER_ENV = 'RCF_VERIFY_LAUNCHER';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The pinned Playwright MCP version verify runs against. Verify is the ship
|
|
22
|
+
* gate; @latest re-resolves per verify run and any newly published
|
|
23
|
+
* Playwright MCP behaviour would become a silent runtime change on the next
|
|
24
|
+
* pass. The pin closes that reproducibility hole (spec 2026-09-03, section
|
|
25
|
+
* 1). A bump is a deliberate rcf-lite change with its own commit, CHANGELOG
|
|
26
|
+
* entry, and, where behaviour is affected, a re-run of the verify test set;
|
|
27
|
+
* pre-1.0, a Playwright MCP major bump ships as a rcf-lite minor bump per
|
|
28
|
+
* the pre-1.0 minor-is-breaking convention, a patch bump otherwise.
|
|
29
|
+
* `rcf verify run --playwright-mcp-version <semver>` overrides for one run
|
|
30
|
+
* only (emergency use), printed on stderr and recorded on the report.
|
|
31
|
+
*/
|
|
32
|
+
export const PLAYWRIGHT_MCP_VERSION = '0.0.80';
|
|
33
|
+
|
|
34
|
+
/** True iff the value is a semver X.Y.Z (numeric segments; no pre-release). Verify's override
|
|
35
|
+
* accepts only exact semver so a typo cannot silently install a range. */
|
|
36
|
+
export function isSemverString(value) {
|
|
37
|
+
return typeof value === 'string' && /^\d+\.\d+\.\d+$/.test(value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compose the Playwright MCP config with the given pin. Exported so the CLI
|
|
42
|
+
* override can build the same shape without duplicating the DEFAULT_MCP_CONFIG
|
|
43
|
+
* literal.
|
|
44
|
+
* @param {string} version - the semver pin
|
|
45
|
+
* @returns {object}
|
|
46
|
+
*/
|
|
47
|
+
export function playwrightMcpConfig(version) {
|
|
48
|
+
return {
|
|
49
|
+
mcpServers: {
|
|
50
|
+
playwright: {
|
|
51
|
+
type: 'stdio',
|
|
52
|
+
command: 'npx',
|
|
53
|
+
args: ['-y', `@playwright/mcp@${version}`],
|
|
54
|
+
env: {},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
20
60
|
/**
|
|
21
61
|
* The minimal, SCOPED tool surface the verifier agent needs to drive a live
|
|
22
62
|
* app and collect runtime evidence (spec §9 method). Deliberately narrow —
|
|
@@ -40,16 +80,7 @@ export const DEFAULT_ALLOWED_TOOLS = Object.freeze([
|
|
|
40
80
|
* context (§9). Server name `playwright` -> tool prefix `mcp__playwright__*`.
|
|
41
81
|
* `npx` is resolved off PATH for portability (no machine-specific absolute).
|
|
42
82
|
*/
|
|
43
|
-
export const DEFAULT_MCP_CONFIG = Object.freeze(
|
|
44
|
-
mcpServers: {
|
|
45
|
-
playwright: {
|
|
46
|
-
type: 'stdio',
|
|
47
|
-
command: 'npx',
|
|
48
|
-
args: ['-y', '@playwright/mcp@latest'],
|
|
49
|
-
env: {},
|
|
50
|
-
},
|
|
51
|
-
},
|
|
52
|
-
});
|
|
83
|
+
export const DEFAULT_MCP_CONFIG = Object.freeze(playwrightMcpConfig(PLAYWRIGHT_MCP_VERSION));
|
|
53
84
|
|
|
54
85
|
/**
|
|
55
86
|
* Build the child-process launch configuration for the verifier agent. Pure
|