@mknrt/autotests-overkill 1.2.6 → 1.3.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/README.md +28 -0
- package/assets/overkill-logo.svg +0 -0
- package/assets/overkill-small.svg +0 -0
- package/bin/autotests-overkill.js +0 -0
- package/dist/src/mcp/cypressTools.d.ts +2 -0
- package/dist/src/mcp/cypressTools.js +66 -0
- package/dist/src/mcp/registerTools.js +2 -0
- package/dist/src/mcp/server.js +19 -3
- package/dist/src/runtime/cypressBrowser.d.ts +32 -0
- package/dist/src/runtime/cypressBrowser.js +280 -0
- package/dist/src/runtime/cypressProcess.d.ts +8 -0
- package/dist/src/runtime/cypressProcess.js +83 -0
- package/dist/src/runtime/cypressRuns.d.ts +36 -0
- package/dist/src/runtime/cypressRuns.js +419 -0
- package/dist/src/runtime/cypressRuntime.d.ts +24 -0
- package/dist/src/runtime/cypressRuntime.js +125 -0
- package/dist/src/runtime/cypressTap.d.ts +95 -0
- package/dist/src/runtime/cypressTap.js +175 -0
- package/dist/src/runtime/redact.d.ts +3 -0
- package/dist/src/runtime/redact.js +40 -0
- package/docs/architecture/overview.md +0 -0
- package/docs/consumer-integration.md +0 -0
- package/docs/operator-cookbook.md +0 -0
- package/docs/plugin-packaging.md +0 -0
- package/docs/superpowers/plans/2026-09-07-cypress-runtime.md +42 -0
- package/docs/tool-catalog.md +0 -0
- package/overkill.config.example.json +0 -0
- package/package.json +1 -1
- package/skills/overkill-debug-failure/SKILL.md +10 -5
- package/skills/overkill-find-gaps/SKILL.md +0 -0
- package/skills/overkill-generate-setup/SKILL.md +0 -0
- package/skills/overkill-generate-test/SKILL.md +0 -0
- package/skills/overkill-impact-analysis/SKILL.md +0 -0
- package/skills/overkill-onboard/SKILL.md +0 -0
- package/skills/overkill-prompt-builder/SKILL.md +0 -0
- package/skills/overkill-reuse-project-patterns/SKILL.md +0 -0
- package/skills/overkill-review-test-draft/SKILL.md +0 -0
- package/templates/prompts/debug-failure.md +0 -0
- package/templates/prompts/find-gaps.md +0 -0
- package/templates/prompts/generate-test.md +0 -0
- package/templates/prompts/impact-analysis.md +0 -0
- package/templates/spec-blueprints/default.md +0 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { cypressEnvironment, cypressInstallation, stopProcessTree } from './cypressProcess.js';
|
|
8
|
+
import { redactValue } from './redact.js';
|
|
9
|
+
const readJson = (file) => {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const availablePort = () => new Promise((resolve, reject) => {
|
|
18
|
+
const server = net.createServer();
|
|
19
|
+
server.once('error', reject);
|
|
20
|
+
server.listen(0, '127.0.0.1', () => {
|
|
21
|
+
const address = server.address();
|
|
22
|
+
server.close((error) => error ? reject(error) : resolve(address.port));
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
const integer = (value, minimum, maximum, name) => {
|
|
26
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
27
|
+
throw new Error(`${name} must be ${minimum}..${maximum}`);
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
export class CypressRuns {
|
|
31
|
+
cacheDir;
|
|
32
|
+
sanitize;
|
|
33
|
+
runs = new Map();
|
|
34
|
+
root;
|
|
35
|
+
lock;
|
|
36
|
+
ownsLock = false;
|
|
37
|
+
starting = false;
|
|
38
|
+
closed = false;
|
|
39
|
+
constructor(repoRoot, cacheDir, sanitize = (text) => text) {
|
|
40
|
+
this.cacheDir = cacheDir;
|
|
41
|
+
this.sanitize = sanitize;
|
|
42
|
+
this.root = fs.realpathSync(repoRoot);
|
|
43
|
+
this.lock = path.join(os.tmpdir(), `overkill-cypress-${createHash('sha256').update(this.root).digest('hex').slice(0, 20)}.lock`);
|
|
44
|
+
}
|
|
45
|
+
active() {
|
|
46
|
+
const run = [...this.runs.values()].find((run) => run.status === 'running' || run.child);
|
|
47
|
+
return run ? { runId: run.runId, mode: run.options.mode } : undefined;
|
|
48
|
+
}
|
|
49
|
+
async start(input) {
|
|
50
|
+
if (this.closed)
|
|
51
|
+
throw new Error('Cypress runner is closed.');
|
|
52
|
+
if (this.starting || [...this.runs.values()].some((run) => run.status === 'running' || run.child))
|
|
53
|
+
throw new Error('A Cypress run is already active for this checkout. Stop it first.');
|
|
54
|
+
const spec = input.spec.replaceAll('\\', '/');
|
|
55
|
+
if (!spec.startsWith('cypress/') || !/\.cy\.[cm]?[jt]sx?$/.test(spec) || /[\n\r\0]/.test(spec))
|
|
56
|
+
throw new Error('spec must be one existing Cypress spec inside cypress/.');
|
|
57
|
+
const realRoot = fs.realpathSync(this.root);
|
|
58
|
+
const realSpec = fs.realpathSync(path.resolve(this.root, spec));
|
|
59
|
+
const relative = path.relative(realRoot, realSpec);
|
|
60
|
+
if (relative.startsWith('..') || path.isAbsolute(relative) || !fs.statSync(realSpec).isFile())
|
|
61
|
+
throw new Error('spec escapes the repository.');
|
|
62
|
+
const browser = input.browser ?? 'chrome-for-testing';
|
|
63
|
+
if (!['chrome-for-testing', 'chrome', 'chromium', 'edge', 'yandex'].includes(browser))
|
|
64
|
+
throw new Error('Use an installed Chromium browser name.');
|
|
65
|
+
const options = {
|
|
66
|
+
spec, browser, mode: input.mode ?? 'run', repeats: integer(input.repeats ?? 1, 1, 20, 'repeats'),
|
|
67
|
+
retries: integer(input.retries ?? 0, 0, 5, 'retries'),
|
|
68
|
+
timeoutSeconds: integer(input.timeoutSeconds ?? 900, 1, 7200, 'timeoutSeconds'), debug: input.debug ?? false,
|
|
69
|
+
};
|
|
70
|
+
if (!['run', 'open'].includes(options.mode))
|
|
71
|
+
throw new Error('mode must be run or open.');
|
|
72
|
+
if (options.mode === 'open' && options.repeats !== 1)
|
|
73
|
+
throw new Error('Open mode keeps one session alive; use TAP run for reruns or run mode for independent repeats.');
|
|
74
|
+
cypressInstallation(this.root, options.mode === 'open');
|
|
75
|
+
for (const file of ['.codex/cypress-mcp.config.cjs']) {
|
|
76
|
+
if (!fs.existsSync(path.join(this.root, file)))
|
|
77
|
+
throw new Error(`Project adapter missing: ${file}`);
|
|
78
|
+
}
|
|
79
|
+
this.starting = true;
|
|
80
|
+
let acquired = false;
|
|
81
|
+
try {
|
|
82
|
+
this.acquireLock();
|
|
83
|
+
acquired = true;
|
|
84
|
+
const runId = randomUUID();
|
|
85
|
+
const directory = path.join(this.cacheDir, 'cypress-runs', runId);
|
|
86
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
87
|
+
const run = { runId, directory, spec, status: 'running', startedAt: new Date().toISOString(), debugPort: await availablePort(), options, repeats: [] };
|
|
88
|
+
if (this.closed)
|
|
89
|
+
throw new Error('Cypress runner is closed.');
|
|
90
|
+
this.runs.set(runId, run);
|
|
91
|
+
this.save(run);
|
|
92
|
+
run.finished = this.execute(run);
|
|
93
|
+
return this.get(runId);
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (acquired)
|
|
97
|
+
this.releaseLock();
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.starting = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
get(runId) {
|
|
105
|
+
const run = this.requireRun(runId);
|
|
106
|
+
const repeats = run.repeats.map((repeat) => ({ ...repeat, results: readJson(path.join(repeat.directory, 'results.json')) ?? repeat.results }));
|
|
107
|
+
const tests = new Map();
|
|
108
|
+
for (const repeat of repeats) {
|
|
109
|
+
for (const spec of repeat.results?.specs ?? [])
|
|
110
|
+
for (const test of spec.tests ?? []) {
|
|
111
|
+
const title = Array.isArray(test.title) ? test.title : [String(test.title)];
|
|
112
|
+
const key = JSON.stringify([spec.file, title]);
|
|
113
|
+
const record = tests.get(key) ?? { spec: spec.file, title, passed: 0, failed: 0, skipped: 0, flaky: false };
|
|
114
|
+
if (test.state === 'passed')
|
|
115
|
+
record.passed++;
|
|
116
|
+
else if (test.state === 'failed')
|
|
117
|
+
record.failed++;
|
|
118
|
+
else
|
|
119
|
+
record.skipped++;
|
|
120
|
+
record.flaky ||= test.state === 'passed' && (test.attempts ?? []).some((attempt) => attempt.state === 'failed');
|
|
121
|
+
record.flaky ||= record.passed > 0 && record.failed > 0;
|
|
122
|
+
tests.set(key, record);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
let log = '';
|
|
126
|
+
for (const repeat of repeats) {
|
|
127
|
+
const file = path.join(repeat.directory, 'cypress.log');
|
|
128
|
+
if (!fs.existsSync(file))
|
|
129
|
+
continue;
|
|
130
|
+
const size = fs.statSync(file).size;
|
|
131
|
+
const descriptor = fs.openSync(file, 'r');
|
|
132
|
+
try {
|
|
133
|
+
const bytes = Buffer.alloc(Math.min(size, 32768));
|
|
134
|
+
fs.readSync(descriptor, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
135
|
+
log += `\n--- repeat ${repeat.repeat} ---\n${bytes.toString('utf8')}`;
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
fs.closeSync(descriptor);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const result = {
|
|
142
|
+
runId: run.runId, spec: run.spec, mode: run.options.mode ?? 'run', status: run.status, startedAt: run.startedAt, finishedAt: run.finishedAt,
|
|
143
|
+
directory: run.directory, debugPort: run.debugPort, requestedRepeats: run.options.repeats, repeats,
|
|
144
|
+
error: run.error, log: log.slice(-65536),
|
|
145
|
+
flakiness: {
|
|
146
|
+
completedRepeats: repeats.filter((repeat) => repeat.exitCode !== null).length,
|
|
147
|
+
failedRepeats: repeats.filter((repeat) => repeat.exitCode !== null && repeat.exitCode !== 0).length,
|
|
148
|
+
tests: [...tests.values()],
|
|
149
|
+
note: 'Observed outcomes for these attempts only; skipped tests and incomplete runs are not passes.',
|
|
150
|
+
},
|
|
151
|
+
captures: repeats.flatMap((repeat) => {
|
|
152
|
+
const file = path.join(repeat.directory, 'events.ndjson');
|
|
153
|
+
if (!fs.existsSync(file))
|
|
154
|
+
return [];
|
|
155
|
+
// Bound evidence returned to the model; full local captures remain available by path.
|
|
156
|
+
const descriptor = fs.openSync(file, 'r');
|
|
157
|
+
try {
|
|
158
|
+
const size = fs.fstatSync(descriptor).size;
|
|
159
|
+
const bytes = Buffer.alloc(Math.min(size, 250000));
|
|
160
|
+
fs.readSync(descriptor, bytes, 0, bytes.length, Math.max(0, size - bytes.length));
|
|
161
|
+
return bytes.toString('utf8').split('\n').flatMap((line) => {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(line);
|
|
164
|
+
return (Array.isArray(parsed) ? parsed : [parsed]).map((capture) => ({ repeat: repeat.repeat, ...capture }));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}).slice(-10);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
fs.closeSync(descriptor);
|
|
173
|
+
}
|
|
174
|
+
}).slice(-10),
|
|
175
|
+
captureFiles: repeats.map((repeat) => path.join(repeat.directory, 'events.ndjson')).filter((file) => fs.existsSync(file)),
|
|
176
|
+
};
|
|
177
|
+
const captures = new Map();
|
|
178
|
+
for (const capture of result.captures) {
|
|
179
|
+
const key = capture.capturedAt
|
|
180
|
+
? JSON.stringify([capture.repeat, capture.capturedAt, capture.spec, redactValue(capture.test, this.sanitize), capture.attempt])
|
|
181
|
+
: JSON.stringify(capture);
|
|
182
|
+
if (!captures.has(key))
|
|
183
|
+
captures.set(key, capture);
|
|
184
|
+
}
|
|
185
|
+
result.captures = [...captures.values()];
|
|
186
|
+
const clean = redactValue(result, this.sanitize);
|
|
187
|
+
// These fields are generated/validated filesystem metadata, not credential payloads.
|
|
188
|
+
// A password that happens to equal a folder name must not break artifact links.
|
|
189
|
+
clean.runId = run.runId;
|
|
190
|
+
clean.status = run.status;
|
|
191
|
+
clean.directory = run.directory;
|
|
192
|
+
clean.spec = run.spec;
|
|
193
|
+
clean.captureFiles = result.captureFiles;
|
|
194
|
+
for (let index = 0; index < repeats.length; index++) {
|
|
195
|
+
const repeat = repeats[index];
|
|
196
|
+
clean.repeats[index].directory = repeat.directory;
|
|
197
|
+
const withinRepeat = (value) => {
|
|
198
|
+
if (typeof value !== 'string' || !path.isAbsolute(value))
|
|
199
|
+
return false;
|
|
200
|
+
const relative = path.relative(repeat.directory, value);
|
|
201
|
+
return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
202
|
+
};
|
|
203
|
+
for (const [specIndex, spec] of (repeat.results?.specs ?? []).entries()) {
|
|
204
|
+
const output = clean.repeats[index].results.specs[specIndex];
|
|
205
|
+
if (spec.file === run.spec)
|
|
206
|
+
output.file = spec.file;
|
|
207
|
+
if (withinRepeat(spec.video))
|
|
208
|
+
output.video = spec.video;
|
|
209
|
+
for (const [imageIndex, screenshot] of (spec.screenshots ?? []).entries()) {
|
|
210
|
+
if (withinRepeat(screenshot.path))
|
|
211
|
+
output.screenshots[imageIndex].path = screenshot.path;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const [index, test] of result.flakiness.tests.entries()) {
|
|
216
|
+
if (test.spec === run.spec)
|
|
217
|
+
clean.flakiness.tests[index].spec = run.spec;
|
|
218
|
+
}
|
|
219
|
+
for (const [index, capture] of result.captures.entries()) {
|
|
220
|
+
if (capture.spec === run.spec)
|
|
221
|
+
clean.captures[index].spec = run.spec;
|
|
222
|
+
}
|
|
223
|
+
return clean;
|
|
224
|
+
}
|
|
225
|
+
recordCapture(runId, payload) {
|
|
226
|
+
try {
|
|
227
|
+
const run = this.requireRun(runId);
|
|
228
|
+
const repeat = run.repeats.at(-1);
|
|
229
|
+
if (run.status !== 'running' || !repeat)
|
|
230
|
+
return false;
|
|
231
|
+
const clean = redactValue(payload, this.sanitize);
|
|
232
|
+
for (const [index, capture] of (Array.isArray(payload) ? payload : [payload]).entries()) {
|
|
233
|
+
if (capture && typeof capture === 'object' && 'spec' in capture && capture.spec === run.spec) {
|
|
234
|
+
(Array.isArray(clean) ? clean[index] : clean).spec = run.spec;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const json = `${JSON.stringify(clean)}\n`;
|
|
238
|
+
const size = Buffer.byteLength(json);
|
|
239
|
+
const file = path.join(repeat.directory, 'events.ndjson');
|
|
240
|
+
if (size > 250000 || (fs.existsSync(file) ? fs.statSync(file).size : 0) + size > 10000000)
|
|
241
|
+
return false;
|
|
242
|
+
fs.appendFileSync(file, json, { mode: 0o600 });
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
// Diagnostics are best effort and must not change the test outcome or lifecycle.
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
getDebugPort(runId) {
|
|
251
|
+
const run = this.requireRun(runId);
|
|
252
|
+
if (run.status !== 'running')
|
|
253
|
+
throw new Error('Browser is no longer running; use the captured failure evidence or start another run.');
|
|
254
|
+
return run.debugPort;
|
|
255
|
+
}
|
|
256
|
+
async stop(runId) {
|
|
257
|
+
const run = this.requireRun(runId);
|
|
258
|
+
if (run.status === 'running') {
|
|
259
|
+
run.status = 'stopped';
|
|
260
|
+
this.terminate(run);
|
|
261
|
+
await run.finished;
|
|
262
|
+
}
|
|
263
|
+
return this.get(runId);
|
|
264
|
+
}
|
|
265
|
+
async close() {
|
|
266
|
+
this.closed = true;
|
|
267
|
+
await Promise.all([...this.runs.values()].map(async (run) => {
|
|
268
|
+
if (run.status === 'running')
|
|
269
|
+
await this.stop(run.runId);
|
|
270
|
+
else
|
|
271
|
+
await run.finished;
|
|
272
|
+
}));
|
|
273
|
+
this.releaseLock();
|
|
274
|
+
}
|
|
275
|
+
terminate(run) {
|
|
276
|
+
run.stopping ??= stopProcessTree(run.child).catch((error) => {
|
|
277
|
+
run.error = this.sanitize(`Could not stop Cypress process tree: ${error.message}`);
|
|
278
|
+
run.status = 'error';
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
requireRun(runId) {
|
|
282
|
+
const existing = this.runs.get(runId);
|
|
283
|
+
if (existing)
|
|
284
|
+
return existing;
|
|
285
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(runId))
|
|
286
|
+
throw new Error(`Unknown Cypress run: ${runId}`);
|
|
287
|
+
const directory = path.join(this.cacheDir, 'cypress-runs', runId);
|
|
288
|
+
const stored = readJson(path.join(directory, 'run.json'));
|
|
289
|
+
if (!stored || stored.runId !== runId || stored.repoRoot !== this.root || !Array.isArray(stored.repeats))
|
|
290
|
+
throw new Error(`Unknown Cypress run: ${runId}`);
|
|
291
|
+
const run = { ...stored, directory, repeats: stored.repeats.map((repeat) => ({
|
|
292
|
+
repeat: repeat.repeat, exitCode: repeat.exitCode, directory: path.join(directory, `repeat-${integer(repeat.repeat, 1, 20, 'repeat')}`),
|
|
293
|
+
})) };
|
|
294
|
+
if (run.status === 'running') {
|
|
295
|
+
run.status = 'error';
|
|
296
|
+
run.error = 'The owning MCP session ended before recording completion; this archive is incomplete.';
|
|
297
|
+
}
|
|
298
|
+
this.runs.set(runId, run);
|
|
299
|
+
return run;
|
|
300
|
+
}
|
|
301
|
+
acquireLock() {
|
|
302
|
+
// ponytail: one run per checkout avoids shared fixture/report races; isolate projects before adding concurrency.
|
|
303
|
+
try {
|
|
304
|
+
fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
if (error.code !== 'EEXIST')
|
|
308
|
+
throw error;
|
|
309
|
+
const lock = readJson(this.lock);
|
|
310
|
+
const owner = Number(typeof lock === 'number' ? lock : lock?.pid);
|
|
311
|
+
if (!Number.isInteger(owner) || owner <= 0)
|
|
312
|
+
throw new Error(`Invalid Cypress lock; inspect ${this.lock}`);
|
|
313
|
+
try {
|
|
314
|
+
process.kill(owner, 0);
|
|
315
|
+
}
|
|
316
|
+
catch (error) {
|
|
317
|
+
if (error.code !== 'ESRCH')
|
|
318
|
+
throw error;
|
|
319
|
+
if (lock?.runnerPid) {
|
|
320
|
+
try {
|
|
321
|
+
process.kill(lock.runnerPid, 0);
|
|
322
|
+
throw new Error('A Cypress process is still active from the previous MCP session.');
|
|
323
|
+
}
|
|
324
|
+
catch (runnerError) {
|
|
325
|
+
if (runnerError.code !== 'ESRCH')
|
|
326
|
+
throw runnerError;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
fs.unlinkSync(this.lock);
|
|
330
|
+
return this.acquireLock();
|
|
331
|
+
}
|
|
332
|
+
throw new Error('A Cypress run is already active for this checkout.');
|
|
333
|
+
}
|
|
334
|
+
this.ownsLock = true;
|
|
335
|
+
}
|
|
336
|
+
releaseLock() {
|
|
337
|
+
if (this.ownsLock) {
|
|
338
|
+
this.ownsLock = false;
|
|
339
|
+
try {
|
|
340
|
+
fs.unlinkSync(this.lock);
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (error.code !== 'ENOENT')
|
|
344
|
+
throw error;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
save(run) {
|
|
349
|
+
fs.writeFileSync(path.join(run.directory, 'run.json'), JSON.stringify({ repoRoot: this.root, debugPort: run.debugPort, repeats: run.repeats.map(({ repeat, exitCode }) => ({ repeat, exitCode })), error: run.error, runId: run.runId, spec: run.spec, status: run.status, startedAt: run.startedAt, finishedAt: run.finishedAt, options: run.options }, null, 2), { mode: 0o600 });
|
|
350
|
+
}
|
|
351
|
+
async execute(run) {
|
|
352
|
+
try {
|
|
353
|
+
for (let index = 1; index <= run.options.repeats && run.status === 'running'; index++) {
|
|
354
|
+
const directory = path.join(run.directory, `repeat-${index}`);
|
|
355
|
+
fs.mkdirSync(directory, { mode: 0o700 });
|
|
356
|
+
const repeat = { repeat: index, directory, exitCode: null };
|
|
357
|
+
run.repeats.push(repeat);
|
|
358
|
+
const specPattern = run.spec.replace(/[\[\]]/g, (char) => char === '[' ? '[[]' : '[]]');
|
|
359
|
+
const env = { ...cypressEnvironment(), OVERKILL_MODE: run.options.mode, SNAPSHOT_PORT: String(await availablePort()),
|
|
360
|
+
TESTS: specPattern, NO_COLOR: '1', OVERKILL_RUN_DIR: directory, OVERKILL_DEBUG_PORT: String(run.debugPort),
|
|
361
|
+
CYPRESS_REMOTE_DEBUGGING_PORT: String(run.debugPort), OVERKILL_RETRIES: String(run.options.retries),
|
|
362
|
+
OVERKILL_DEBUG: String(run.options.debug), OVERKILL_TIMEOUT_SECONDS: String(run.options.timeoutSeconds), OVERKILL_REPEAT: String(index), API_CAPTURE_ENABLED: 'false' };
|
|
363
|
+
if (run.status !== 'running')
|
|
364
|
+
break;
|
|
365
|
+
const descriptor = fs.openSync(path.join(directory, 'cypress.log'), 'a', 0o600);
|
|
366
|
+
try {
|
|
367
|
+
repeat.exitCode = await new Promise((resolve, reject) => {
|
|
368
|
+
const { cli } = cypressInstallation(this.root, run.options.mode === 'open');
|
|
369
|
+
const args = [cli, run.options.mode, '--project', this.root, '--browser', run.options.browser,
|
|
370
|
+
'--config-file', path.join(this.root, '.codex/cypress-mcp.config.cjs')];
|
|
371
|
+
if (run.options.mode === 'open')
|
|
372
|
+
args.push('--e2e');
|
|
373
|
+
else
|
|
374
|
+
args.push('--spec', specPattern, run.options.debug ? '--headed' : '--headless');
|
|
375
|
+
const child = spawn(process.execPath, args, { cwd: this.root, env, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', descriptor, descriptor] });
|
|
376
|
+
run.child = child;
|
|
377
|
+
fs.writeFileSync(this.lock, JSON.stringify({ pid: process.pid, runnerPid: child.pid }), { mode: 0o600 });
|
|
378
|
+
this.save(run);
|
|
379
|
+
const timer = setTimeout(() => {
|
|
380
|
+
if (run.status === 'running')
|
|
381
|
+
run.status = 'timed_out';
|
|
382
|
+
this.terminate(run);
|
|
383
|
+
}, run.options.timeoutSeconds * 1000);
|
|
384
|
+
timer.unref();
|
|
385
|
+
child.once('error', reject);
|
|
386
|
+
child.once('close', (code) => { clearTimeout(timer); resolve(code); });
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
finally {
|
|
390
|
+
if (run.stopping)
|
|
391
|
+
await run.stopping;
|
|
392
|
+
else if (process.platform !== 'win32')
|
|
393
|
+
await stopProcessTree(run.child);
|
|
394
|
+
fs.closeSync(descriptor);
|
|
395
|
+
run.child = undefined;
|
|
396
|
+
run.stopping = undefined;
|
|
397
|
+
}
|
|
398
|
+
repeat.results = readJson(path.join(directory, 'results.json'));
|
|
399
|
+
if (repeat.exitCode === 124 && run.status === 'running')
|
|
400
|
+
run.status = 'timed_out';
|
|
401
|
+
if ((!repeat.results || repeat.results.complete === false) && run.status === 'running') {
|
|
402
|
+
run.status = 'error';
|
|
403
|
+
run.error = 'Cypress exited without this repeat’s result file. Inspect the captured log.';
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (run.status === 'running')
|
|
407
|
+
run.status = 'completed';
|
|
408
|
+
}
|
|
409
|
+
catch (error) {
|
|
410
|
+
run.status = 'error';
|
|
411
|
+
run.error = this.sanitize(error instanceof Error ? error.message : String(error));
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
run.finishedAt = new Date().toISOString();
|
|
415
|
+
this.releaseLock();
|
|
416
|
+
this.save(run);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AppContext } from '../appContext.js';
|
|
2
|
+
import { type BrowserInspection } from './cypressBrowser.js';
|
|
3
|
+
import { type CypressRunOptions } from './cypressRuns.js';
|
|
4
|
+
import { type CypressTapInput } from './cypressTap.js';
|
|
5
|
+
export declare function cypressRuntime(context: AppContext): CypressRuntime;
|
|
6
|
+
export declare function closeCypressRuntime(context: AppContext): Promise<void>;
|
|
7
|
+
export declare class CypressRuntime {
|
|
8
|
+
private readonly runs;
|
|
9
|
+
private readonly browsers;
|
|
10
|
+
private readonly sanitize;
|
|
11
|
+
private closed;
|
|
12
|
+
private readonly tap;
|
|
13
|
+
private readonly tapSessions;
|
|
14
|
+
constructor(repoRoot: string, cacheDir: string);
|
|
15
|
+
start(options: CypressRunOptions): Promise<any>;
|
|
16
|
+
get(runId: string): any;
|
|
17
|
+
tapCommand(input: CypressTapInput): Promise<any>;
|
|
18
|
+
inspect(runId: string, input: BrowserInspection): Promise<any>;
|
|
19
|
+
stop(runId: string): Promise<any>;
|
|
20
|
+
close(): Promise<void>;
|
|
21
|
+
private startOpenSpec;
|
|
22
|
+
private browser;
|
|
23
|
+
private observe;
|
|
24
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { CypressBrowser } from './cypressBrowser.js';
|
|
2
|
+
import { CypressRuns } from './cypressRuns.js';
|
|
3
|
+
import { CypressTap } from './cypressTap.js';
|
|
4
|
+
import { projectRedactor, redactValue } from './redact.js';
|
|
5
|
+
const runtimes = new WeakMap();
|
|
6
|
+
export function cypressRuntime(context) {
|
|
7
|
+
let runtime = runtimes.get(context);
|
|
8
|
+
if (!runtime) {
|
|
9
|
+
runtime = new CypressRuntime(context.config.repos.autotests2, context.config.cacheDir);
|
|
10
|
+
runtimes.set(context, runtime);
|
|
11
|
+
}
|
|
12
|
+
return runtime;
|
|
13
|
+
}
|
|
14
|
+
export async function closeCypressRuntime(context) {
|
|
15
|
+
await runtimes.get(context)?.close();
|
|
16
|
+
runtimes.delete(context);
|
|
17
|
+
}
|
|
18
|
+
export class CypressRuntime {
|
|
19
|
+
runs;
|
|
20
|
+
browsers = new Map();
|
|
21
|
+
sanitize;
|
|
22
|
+
closed = false;
|
|
23
|
+
tap;
|
|
24
|
+
tapSessions = new Map();
|
|
25
|
+
constructor(repoRoot, cacheDir) {
|
|
26
|
+
this.sanitize = projectRedactor(repoRoot);
|
|
27
|
+
this.runs = new CypressRuns(repoRoot, cacheDir, this.sanitize);
|
|
28
|
+
this.tap = new CypressTap(repoRoot, this.sanitize);
|
|
29
|
+
}
|
|
30
|
+
async start(options) {
|
|
31
|
+
if (this.closed)
|
|
32
|
+
throw new Error('Cypress runtime is closed');
|
|
33
|
+
if (options.mode === 'open' && (await this.tap.execute({ action: 'sessions' })).length) {
|
|
34
|
+
throw new Error('An open Cypress session already exists for this project. Use cypress_tap with its sessionId or close it before starting a managed session.');
|
|
35
|
+
}
|
|
36
|
+
const run = await this.runs.start(options);
|
|
37
|
+
this.browsers.set(run.runId, this.browser(run.runId, run.debugPort));
|
|
38
|
+
void this.observe(run.runId).catch(() => { });
|
|
39
|
+
if (options.mode === 'open') {
|
|
40
|
+
this.tapSessions.set(run.runId, { status: 'starting' });
|
|
41
|
+
void this.startOpenSpec(run.runId, options);
|
|
42
|
+
}
|
|
43
|
+
return this.get(run.runId);
|
|
44
|
+
}
|
|
45
|
+
get(runId) {
|
|
46
|
+
const run = this.runs.get(runId);
|
|
47
|
+
return { ...run, tap: this.tapSessions.get(runId), failures: run.captures.slice(-5) };
|
|
48
|
+
}
|
|
49
|
+
async tapCommand(input) {
|
|
50
|
+
if (this.closed)
|
|
51
|
+
throw new Error('Cypress runtime is closed');
|
|
52
|
+
if (['run', 'pin'].includes(input.action)) {
|
|
53
|
+
const active = this.runs.active();
|
|
54
|
+
if (active) {
|
|
55
|
+
const sessionId = this.tapSessions.get(active.runId)?.sessionId;
|
|
56
|
+
if (active.mode !== 'open' || !sessionId || (input.sessionId !== undefined && input.sessionId !== sessionId)) {
|
|
57
|
+
throw new Error('A managed Cypress run is active. Only mutate its ready, owned open session; wait for it or stop it first.');
|
|
58
|
+
}
|
|
59
|
+
input = { ...input, sessionId };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return this.tap.execute(input);
|
|
63
|
+
}
|
|
64
|
+
async inspect(runId, input) {
|
|
65
|
+
const port = this.runs.getDebugPort(runId);
|
|
66
|
+
let browser = this.browsers.get(runId);
|
|
67
|
+
if (!browser) {
|
|
68
|
+
browser = this.browser(runId, port);
|
|
69
|
+
this.browsers.set(runId, browser);
|
|
70
|
+
}
|
|
71
|
+
const result = await browser.inspect(input);
|
|
72
|
+
return input.action === 'screenshot' ? result : redactValue(result, this.sanitize);
|
|
73
|
+
}
|
|
74
|
+
async stop(runId) {
|
|
75
|
+
const result = await this.runs.stop(runId);
|
|
76
|
+
await this.browsers.get(runId)?.close();
|
|
77
|
+
this.browsers.delete(runId);
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
async close() {
|
|
81
|
+
this.closed = true;
|
|
82
|
+
this.tap.close();
|
|
83
|
+
await this.runs.close();
|
|
84
|
+
await Promise.all([...this.browsers.values()].map((browser) => browser.close()));
|
|
85
|
+
this.browsers.clear();
|
|
86
|
+
}
|
|
87
|
+
async startOpenSpec(runId, options) {
|
|
88
|
+
const deadline = Date.now() + Math.min(options.timeoutSeconds ?? 900, 120) * 1000;
|
|
89
|
+
try {
|
|
90
|
+
while (!this.closed && this.runs.get(runId).status === 'running' && Date.now() < deadline) {
|
|
91
|
+
const sessions = await this.tap.execute({ action: 'sessions', timeoutMs: 5000 });
|
|
92
|
+
if (sessions.length > 1)
|
|
93
|
+
throw new Error('Multiple open Cypress sessions appeared; cannot safely select one.');
|
|
94
|
+
if (sessions.length === 1) {
|
|
95
|
+
const sessionId = sessions[0].pid;
|
|
96
|
+
const request = await this.tap.execute({ action: 'run', sessionId, spec: options.spec, runTimeoutSeconds: options.timeoutSeconds });
|
|
97
|
+
this.tapSessions.set(runId, { status: 'requested', sessionId, requestId: request.requestId });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
101
|
+
}
|
|
102
|
+
if (!this.closed && this.runs.get(runId).status === 'running')
|
|
103
|
+
throw new Error('Timed out waiting for the managed Cypress open session.');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (!this.closed) {
|
|
107
|
+
this.tapSessions.set(runId, { status: 'error', error: this.sanitize(error instanceof Error ? error.message : String(error)) });
|
|
108
|
+
await this.stop(runId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
browser(runId, port) {
|
|
113
|
+
return new CypressBrowser(port, this.sanitize, (record) => {
|
|
114
|
+
this.runs.recordCapture(runId, record);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
async observe(runId) {
|
|
118
|
+
// Connect as soon as Chrome appears so diagnostics include events before the first inspection.
|
|
119
|
+
while (!this.closed && this.runs.get(runId).status === 'running') {
|
|
120
|
+
await this.browsers.get(runId)?.connect().catch(() => { });
|
|
121
|
+
await new Promise((resolve) => { const timer = setTimeout(resolve, 500); timer.unref(); });
|
|
122
|
+
}
|
|
123
|
+
await this.browsers.get(runId)?.close();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const cypressTapInput: z.ZodEffects<z.ZodObject<{
|
|
3
|
+
action: z.ZodEnum<["sessions", "status", "specs", "run", "reporter", "command", "pin", "dom", "aria", "inspect"]>;
|
|
4
|
+
sessionId: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
requestId: z.ZodOptional<z.ZodString>;
|
|
6
|
+
spec: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
7
|
+
testId: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
8
|
+
commandId: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
9
|
+
attempt: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
depth: z.ZodOptional<z.ZodUnion<[z.ZodNumber, z.ZodLiteral<"all">]>>;
|
|
11
|
+
selector: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
|
|
12
|
+
at: z.ZodOptional<z.ZodUnion<[z.ZodNumber, z.ZodEffects<z.ZodString, string, string>]>>;
|
|
13
|
+
clear: z.ZodOptional<z.ZodBoolean>;
|
|
14
|
+
maxChars: z.ZodOptional<z.ZodNumber>;
|
|
15
|
+
maxNodes: z.ZodOptional<z.ZodNumber>;
|
|
16
|
+
timeoutMs: z.ZodOptional<z.ZodNumber>;
|
|
17
|
+
runTimeoutSeconds: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
}, "strict", z.ZodTypeAny, {
|
|
19
|
+
action: "status" | "run" | "specs" | "command" | "sessions" | "reporter" | "pin" | "dom" | "aria" | "inspect";
|
|
20
|
+
at?: string | number | undefined;
|
|
21
|
+
spec?: string | undefined;
|
|
22
|
+
selector?: string | undefined;
|
|
23
|
+
sessionId?: number | undefined;
|
|
24
|
+
requestId?: string | undefined;
|
|
25
|
+
testId?: string | undefined;
|
|
26
|
+
commandId?: string | undefined;
|
|
27
|
+
attempt?: number | undefined;
|
|
28
|
+
depth?: number | "all" | undefined;
|
|
29
|
+
clear?: boolean | undefined;
|
|
30
|
+
maxChars?: number | undefined;
|
|
31
|
+
maxNodes?: number | undefined;
|
|
32
|
+
timeoutMs?: number | undefined;
|
|
33
|
+
runTimeoutSeconds?: number | undefined;
|
|
34
|
+
}, {
|
|
35
|
+
action: "status" | "run" | "specs" | "command" | "sessions" | "reporter" | "pin" | "dom" | "aria" | "inspect";
|
|
36
|
+
at?: string | number | undefined;
|
|
37
|
+
spec?: string | undefined;
|
|
38
|
+
selector?: string | undefined;
|
|
39
|
+
sessionId?: number | undefined;
|
|
40
|
+
requestId?: string | undefined;
|
|
41
|
+
testId?: string | undefined;
|
|
42
|
+
commandId?: string | undefined;
|
|
43
|
+
attempt?: number | undefined;
|
|
44
|
+
depth?: number | "all" | undefined;
|
|
45
|
+
clear?: boolean | undefined;
|
|
46
|
+
maxChars?: number | undefined;
|
|
47
|
+
maxNodes?: number | undefined;
|
|
48
|
+
timeoutMs?: number | undefined;
|
|
49
|
+
runTimeoutSeconds?: number | undefined;
|
|
50
|
+
}>, {
|
|
51
|
+
action: "status" | "run" | "specs" | "command" | "sessions" | "reporter" | "pin" | "dom" | "aria" | "inspect";
|
|
52
|
+
at?: string | number | undefined;
|
|
53
|
+
spec?: string | undefined;
|
|
54
|
+
selector?: string | undefined;
|
|
55
|
+
sessionId?: number | undefined;
|
|
56
|
+
requestId?: string | undefined;
|
|
57
|
+
testId?: string | undefined;
|
|
58
|
+
commandId?: string | undefined;
|
|
59
|
+
attempt?: number | undefined;
|
|
60
|
+
depth?: number | "all" | undefined;
|
|
61
|
+
clear?: boolean | undefined;
|
|
62
|
+
maxChars?: number | undefined;
|
|
63
|
+
maxNodes?: number | undefined;
|
|
64
|
+
timeoutMs?: number | undefined;
|
|
65
|
+
runTimeoutSeconds?: number | undefined;
|
|
66
|
+
}, {
|
|
67
|
+
action: "status" | "run" | "specs" | "command" | "sessions" | "reporter" | "pin" | "dom" | "aria" | "inspect";
|
|
68
|
+
at?: string | number | undefined;
|
|
69
|
+
spec?: string | undefined;
|
|
70
|
+
selector?: string | undefined;
|
|
71
|
+
sessionId?: number | undefined;
|
|
72
|
+
requestId?: string | undefined;
|
|
73
|
+
testId?: string | undefined;
|
|
74
|
+
commandId?: string | undefined;
|
|
75
|
+
attempt?: number | undefined;
|
|
76
|
+
depth?: number | "all" | undefined;
|
|
77
|
+
clear?: boolean | undefined;
|
|
78
|
+
maxChars?: number | undefined;
|
|
79
|
+
maxNodes?: number | undefined;
|
|
80
|
+
timeoutMs?: number | undefined;
|
|
81
|
+
runTimeoutSeconds?: number | undefined;
|
|
82
|
+
}>;
|
|
83
|
+
export type CypressTapInput = z.infer<typeof cypressTapInput>;
|
|
84
|
+
export declare class CypressTap {
|
|
85
|
+
private readonly sanitize;
|
|
86
|
+
private readonly requests;
|
|
87
|
+
private readonly running;
|
|
88
|
+
private readonly root;
|
|
89
|
+
private readonly controller;
|
|
90
|
+
constructor(repoRoot: string, sanitize?: (value: string) => string);
|
|
91
|
+
close(): void;
|
|
92
|
+
execute(raw: CypressTapInput): Promise<any>;
|
|
93
|
+
private sessions;
|
|
94
|
+
private call;
|
|
95
|
+
}
|