@mknrt/autotests-overkill 1.2.5 → 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.
Files changed (42) hide show
  1. package/README.md +28 -0
  2. package/assets/overkill-logo.svg +0 -0
  3. package/assets/overkill-small.svg +0 -0
  4. package/bin/autotests-overkill.js +0 -0
  5. package/dist/src/mcp/cypressTools.d.ts +2 -0
  6. package/dist/src/mcp/cypressTools.js +66 -0
  7. package/dist/src/mcp/registerTools.js +2 -0
  8. package/dist/src/mcp/server.js +37 -3
  9. package/dist/src/runtime/cypressBrowser.d.ts +32 -0
  10. package/dist/src/runtime/cypressBrowser.js +280 -0
  11. package/dist/src/runtime/cypressProcess.d.ts +8 -0
  12. package/dist/src/runtime/cypressProcess.js +83 -0
  13. package/dist/src/runtime/cypressRuns.d.ts +36 -0
  14. package/dist/src/runtime/cypressRuns.js +419 -0
  15. package/dist/src/runtime/cypressRuntime.d.ts +24 -0
  16. package/dist/src/runtime/cypressRuntime.js +125 -0
  17. package/dist/src/runtime/cypressTap.d.ts +95 -0
  18. package/dist/src/runtime/cypressTap.js +175 -0
  19. package/dist/src/runtime/redact.d.ts +3 -0
  20. package/dist/src/runtime/redact.js +40 -0
  21. package/docs/architecture/overview.md +0 -0
  22. package/docs/consumer-integration.md +0 -0
  23. package/docs/operator-cookbook.md +0 -0
  24. package/docs/plugin-packaging.md +0 -0
  25. package/docs/superpowers/plans/2026-09-07-cypress-runtime.md +42 -0
  26. package/docs/tool-catalog.md +0 -0
  27. package/overkill.config.example.json +0 -0
  28. package/package.json +1 -1
  29. package/skills/overkill-debug-failure/SKILL.md +10 -5
  30. package/skills/overkill-find-gaps/SKILL.md +0 -0
  31. package/skills/overkill-generate-setup/SKILL.md +0 -0
  32. package/skills/overkill-generate-test/SKILL.md +0 -0
  33. package/skills/overkill-impact-analysis/SKILL.md +0 -0
  34. package/skills/overkill-onboard/SKILL.md +0 -0
  35. package/skills/overkill-prompt-builder/SKILL.md +0 -0
  36. package/skills/overkill-reuse-project-patterns/SKILL.md +0 -0
  37. package/skills/overkill-review-test-draft/SKILL.md +0 -0
  38. package/templates/prompts/debug-failure.md +0 -0
  39. package/templates/prompts/find-gaps.md +0 -0
  40. package/templates/prompts/generate-test.md +0 -0
  41. package/templates/prompts/impact-analysis.md +0 -0
  42. package/templates/spec-blueprints/default.md +0 -0
package/README.md CHANGED
@@ -47,6 +47,34 @@ Repository-local development:
47
47
 
48
48
  ## Daily workflow
49
49
 
50
+ ### Integrated local Cypress runtime
51
+
52
+ For the `autotests2` checkout, run `npm run mcp:setup` there once and restart Codex. The same Overkill connection now includes its own browser debugger; no HTTP service, separate browser MCP, browser port configuration, or manual Cypress launch is required. The setup uses the installed npm package; a local Overkill checkout can be selected explicitly during development. It verifies the MCP handshake before registering it with Codex. The published package contains the runtime and does not require a sibling source checkout.
53
+
54
+ Ask Codex to run a spec, diagnose its failure, inspect the page, fix the test, and rerun it. The existing asset/selector/blueprint tools remain available for authoring. Codex edits files using its normal editor; the MCP provides execution and evidence.
55
+
56
+ | Tool | Behavior |
57
+ | --- | --- |
58
+ | `start_cypress_run` | Starts one existing spec; returns `runId` immediately. Optional `mode` (`run` or `open`), `debug`, `repeats`, `retries`, `browser`, `timeoutSeconds`. |
59
+ | `get_cypress_run` | Reads status, log, errors and attempts, screenshots/videos, captured failure DOM, and observed flakiness. Also reads completed archives after reconnecting. |
60
+ | `inspect_cypress_browser` | `snapshot`, `screenshot`, `evaluate`, `pause`, `resume` in the run's actual Cypress browser. Select live AUT or runner, or an explicit execution context. |
61
+ | `stop_cypress_run` | Stops the run and its browser while retaining evidence. |
62
+ | `cypress_tap` | Open-mode sessions, status, specs, reruns, Command Log/reporter, command snapshots, pin/clear, DOM, ARIA, and element inspection. |
63
+
64
+ `debug: true` pauses before each test; `resume` continues the command queue. CDP pause/resume is also supported. JavaScript evaluation can change page state. A normal completed run may contain failed tests: check the outcomes, not only process status. Repeats and retries report observed mixed outcomes, not a statistical guarantee of stability.
65
+
66
+ Managed runs launch the project's installed Cypress Node CLI directly on Windows or Linux/WSL using the opt-in companion config/support bridge. No Bash, PowerShell wrapper, or shell interpolation is required. Shutdown terminates the Windows process tree or POSIX process group. The runner removes inherited `ELECTRON_RUN_AS_NODE`, disables guest telemetry in child processes, escapes bracketed spec names, and allocates local debug/snapshot ports. Ordinary Cypress runs keep their behavior. One managed run per checkout avoids shared fixture/report races. A disconnected MCP session stops its active run. Evidence lives in `.overkill-cache/cypress-runs/<runId>/repeat-N/`; failed attempts keep separate errors and pre-cleanup DOM. DOM and console/network output are bounded and redact credentials; raw network bodies and headers are not collected by default. Local diagnostic runs do not publish dashboard launches.
67
+
68
+ Requirements: Node.js 22.5+ for Overkill (Cypress 16 requires Node.js 22.12+ on the 22.x line), existing project dependencies, Windows or Linux/WSL, and an installed Chromium browser. On this checkout `chrome-for-testing` is the default. Headed debugging requires the desktop display available to Cypress. No dependency installation is performed by setup. `npm run mcp:check` in `autotests2` runs the full deterministic debug/failure/retry/corrected-rerun check against a temporary local page; afterward its generated spec is archived in `.overkill-cache/selfcheck-specs/` outside ordinary Cypress discovery.
69
+
70
+ For completed-run command logs and time travel, start with `start_cypress_run({ spec: "cypress/e2e/example.cy.js", mode: "open" })`. Poll `get_cypress_run` until `tap.status` is `requested`, then call `cypress_tap({ action: "status", sessionId, requestId })` with the returned IDs. The managed UI stays alive after the spec completes, until stopped, timed out, or disconnected. Its `get_cypress_run.status` describes the process; TAP describes the selected spec. Open mode permits one session, with `cypress_tap` `run` for reruns; use run mode for independent `repeats`.
71
+
72
+ TAP status adds `verdict`, `stale`, `timedOut`, and `testsExecuted`. Trust `passed` only when `verdict` is `passed`; `no_tests` means nothing executed. Reruns remember the previous `startedAt` and suppress its verdict until the requested spec starts. A build stuck in loading is bounded by `runTimeoutSeconds` (default 900); `timeoutMs` bounds each CLI request separately. Each new run supersedes the earlier `requestId`.
73
+
74
+ Use `reporter` for test IDs and command logs, `command` with `testId`/`commandId` for snapshots, then `pin` with the same IDs and optional `at: "before"` or `"after"`. `dom`, `aria`, and `inspect` read the completed or pinned page. Release with `pin` and `clear: true`. Attempts and pin indices start at 1; element-match indices start at 0. Existing project sessions can be discovered using `sessions`; ambiguous sessions require an explicit `sessionId`. During a managed run, TAP mutations are restricted to its owned open session.
75
+
76
+ [TAP requires Cypress 15.21+ and a Chromium open-mode session](https://docs.cypress.io/app/tooling/cypress-tap). It does not inspect `cypress run`, and retained snapshots depend on `numTestsKeptInMemory`. Use CDP inspection for live paused state. On Windows, the TAP child preserves Cypress exit codes and lets pending asynchronous work finish before exiting, avoiding the Node forced-exit shutdown race; its timeout still applies. TAP CLI/session version mismatches are reported as errors; raw network bodies and headers are omitted from TAP console-property output. Avoid manually changing the open runner's pin while TAP is inspecting it.
77
+
50
78
  1. Refresh local knowledge with `npm run index`.
51
79
  2. Start the MCP server through the consumer repository's `.codex/config.toml`, or run `npx autotests-overkill mcp` directly for manual checks.
52
80
  3. Use `find_frontend_contract`, `find_existing_test_assets`, and `generate_spec_blueprint` for a new feature draft.
File without changes
File without changes
File without changes
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from './registerTools.js';
2
+ export declare function cypressTools(): ToolDefinition[];
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod';
2
+ import { buildToolOutput, toolOutputSchema } from '../contracts/toolOutput.js';
3
+ import { cypressTapInput } from '../runtime/cypressTap.js';
4
+ import { cypressRuntime } from '../runtime/cypressRuntime.js';
5
+ function report(summary, data, actions = []) {
6
+ return buildToolOutput({
7
+ summary, evidence: [{ type: 'report', label: 'Managed Cypress runtime', metadata: { runtime: data } }],
8
+ recommended_actions: actions,
9
+ });
10
+ }
11
+ export function cypressTools() {
12
+ return [
13
+ {
14
+ name: 'cypress_tap',
15
+ description: 'Drive a Cypress 15.21+ open-mode Chromium session in the configured project using JSON TAP. sessions discovers sessionId; run requests a spec and status guards stale startedAt verdicts and zero executed tests. reporter gives test IDs/Command Log, command gives snapshots, pin restores historical DOM, dom/aria/inspect read completed or pinned state. at is 1-based for pin, 0-based for element matches; attempt is 1-based. Run mode has no TAP; use inspect_cypress_browser for live paused state. Pins and run change the open session.',
16
+ inputSchema: cypressTapInput, outputSchema: toolOutputSchema,
17
+ execute: async (input, context) => report(`Cypress TAP ${input.action}`, await cypressRuntime(context).tapCommand(input)),
18
+ },
19
+ {
20
+ name: 'start_cypress_run',
21
+ description: 'Launch one project spec using its installed Cypress CLI on Windows or Linux. Returns runId immediately. mode run supports bounded repeats; mode open keeps the session alive after completion for TAP command logs and time travel (Cypress 15.21+). debug pauses before each test; resume through inspect_cypress_browser. repeats compares independent runs; retries records Cypress attempts. Uses existing project credentials, keeps diagnostics local, and does not publish runs.',
22
+ inputSchema: z.object({
23
+ spec: z.string().min(1), mode: z.enum(['run', 'open']).optional(), browser: z.enum(['chrome-for-testing', 'chrome', 'chromium', 'edge', 'yandex']).optional(),
24
+ repeats: z.number().int().min(1).max(20).optional(), retries: z.number().int().min(0).max(5).optional(),
25
+ timeoutSeconds: z.number().int().min(1).max(7200).optional(), debug: z.boolean().optional(),
26
+ }),
27
+ outputSchema: toolOutputSchema,
28
+ execute: async (input, context) => {
29
+ const run = await cypressRuntime(context).start(input);
30
+ return report(`Cypress run ${run.runId}: ${run.status}`, run, [run.mode === 'open' ? 'Poll get_cypress_run for tap.sessionId and requestId, then cypress_tap status for a fresh verdict. Stop the session when finished.' : 'Poll get_cypress_run; use inspect_cypress_browser for live debugging.']);
31
+ },
32
+ },
33
+ {
34
+ name: 'get_cypress_run',
35
+ description: 'Read managed run status, captured log, per-test errors/retry attempts, failure DOM evidence, artifact paths and observed flakiness. A completed process is not necessarily a passing test run; inspect results. No new run is started.',
36
+ inputSchema: z.object({ runId: z.string().uuid() }), outputSchema: toolOutputSchema,
37
+ execute: async ({ runId }, context) => {
38
+ const run = cypressRuntime(context).get(runId);
39
+ return report(`Cypress run ${runId}: ${run.status}; ${run.flakiness.completedRepeats} completed repeats, ${run.flakiness.failedRepeats} failed repeats`, run);
40
+ },
41
+ },
42
+ {
43
+ name: 'stop_cypress_run', description: 'Stop a managed Cypress run and its browser; keep diagnostic artifacts for analysis.',
44
+ inputSchema: z.object({ runId: z.string().uuid() }), outputSchema: toolOutputSchema,
45
+ execute: async ({ runId }, context) => report(`Stopped Cypress run ${runId}`, await cypressRuntime(context).stop(runId)),
46
+ },
47
+ {
48
+ name: 'inspect_cypress_browser',
49
+ description: 'Inspect the live browser of a managed run: snapshot DOM plus console/network, screenshot, evaluate JavaScript in AUT or runner, pause or resume. evaluate can change page state; use deliberately. The live DOM is current state; get_cypress_run contains captured failure state. For ambiguous frames pass contextId from diagnostics.',
50
+ inputSchema: z.object({
51
+ runId: z.string().uuid(), action: z.enum(['snapshot', 'evaluate', 'screenshot', 'pause', 'resume']),
52
+ target: z.enum(['aut', 'runner']).optional(), contextId: z.number().int().positive().optional(),
53
+ selector: z.string().max(2000).optional(), expression: z.string().max(20000).optional(),
54
+ }), outputSchema: toolOutputSchema,
55
+ execute: async ({ runId, ...input }, context) => {
56
+ const result = await cypressRuntime(context).inspect(runId, input);
57
+ if (input.action === 'screenshot')
58
+ return {
59
+ ...report(`Cypress browser screenshot for ${runId}`, { runId }),
60
+ image: { type: 'image', data: result.data, mimeType: result.mimeType },
61
+ };
62
+ return report(`Cypress browser ${input.action} for ${runId}`, result);
63
+ },
64
+ },
65
+ ];
66
+ }
@@ -15,8 +15,10 @@ import { reviewTestDraft } from '../domain/reviewTestDraft.js';
15
15
  import { summarizeCiContext } from '../domain/summarizeCiContext.js';
16
16
  import { triageFailedRun } from '../domain/triageFailedRun.js';
17
17
  import { toolOutputSchema } from '../contracts/toolOutput.js';
18
+ import { cypressTools } from './cypressTools.js';
18
19
  export function buildToolRegistry() {
19
20
  return [
21
+ ...cypressTools(),
20
22
  {
21
23
  name: 'find_existing_test_assets',
22
24
  description: 'Finds reusable specs, commands, helpers, and selectors in autotests2.',
@@ -1,11 +1,17 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ErrorCode, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, McpError, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
2
3
  import { FramingAwareStdioServerTransport } from './framingAwareStdioTransport.js';
3
4
  import { buildToolRegistry } from './registerTools.js';
5
+ import { closeCypressRuntime } from '../runtime/cypressRuntime.js';
4
6
  export async function createMcpServer(context) {
5
7
  const server = new McpServer({
6
8
  name: 'autotests-ultimate-testing-overkill',
7
- version: '1.1.0',
9
+ version: '1.3.0',
10
+ }, {
11
+ instructions: 'For local Cypress work use start_cypress_run, then poll get_cypress_run. debug=true pauses before each test; inspect_cypress_browser action=resume continues. Use snapshot/evaluate/screenshot for browser evidence, and captured failures for pre-cleanup DOM. Use repeats/retries to measure observed flakiness. Use mode=open for completed Command Log and time travel through cypress_tap; poll its fresh verdict with sessionId/requestId and do not count no_tests as a pass. Reuse project assets before editing specs, then rerun the affected spec. Stop unused runs. A completed run may contain failed tests. Never expose credentials or weaken assertions just to make a test pass.',
8
12
  });
13
+ server.server.onclose = () => { void closeCypressRuntime(context); };
14
+ registerEmptyResourceHandlers(server);
9
15
  for (const tool of buildToolRegistry()) {
10
16
  server.registerTool(tool.name, {
11
17
  title: tool.name,
@@ -13,17 +19,45 @@ export async function createMcpServer(context) {
13
19
  inputSchema: tool.inputSchema,
14
20
  outputSchema: tool.outputSchema,
15
21
  }, async (args) => {
16
- const structuredContent = await tool.execute(args, context);
22
+ const { image, ...structuredContent } = await tool.execute(args, context);
17
23
  return {
18
- content: [{ type: 'text', text: structuredContent.summary }],
24
+ content: [{ type: 'text', text: structuredContent.summary }, ...(image ? [image] : [])],
19
25
  structuredContent,
20
26
  };
21
27
  });
22
28
  }
23
29
  return server;
24
30
  }
31
+ function registerEmptyResourceHandlers(server) {
32
+ server.server.registerCapabilities({
33
+ resources: {
34
+ listChanged: true,
35
+ },
36
+ });
37
+ server.server.setRequestHandler(ListResourcesRequestSchema, () => ({
38
+ resources: [],
39
+ }));
40
+ server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({
41
+ resourceTemplates: [],
42
+ }));
43
+ server.server.setRequestHandler(ReadResourceRequestSchema, (request) => {
44
+ throw new McpError(ErrorCode.InvalidParams, `Resource ${request.params.uri} not found`);
45
+ });
46
+ }
25
47
  export async function runMcpServer(context) {
26
48
  const server = await createMcpServer(context);
27
49
  const transport = new FramingAwareStdioServerTransport();
28
50
  await server.connect(transport);
51
+ let closing = false;
52
+ const close = async () => {
53
+ if (closing)
54
+ return;
55
+ closing = true;
56
+ await closeCypressRuntime(context);
57
+ await server.close();
58
+ context.database.close();
59
+ };
60
+ process.stdin.once('end', () => { void close(); });
61
+ process.once('SIGTERM', () => { void close(); });
62
+ process.once('SIGINT', () => { void close(); });
29
63
  }
@@ -0,0 +1,32 @@
1
+ export type BrowserInspection = {
2
+ action: 'snapshot' | 'evaluate' | 'screenshot' | 'pause' | 'resume';
3
+ expression?: string;
4
+ target?: 'aut' | 'runner';
5
+ contextId?: number;
6
+ selector?: string;
7
+ };
8
+ export declare function redactBrowserText(value: string): string;
9
+ /** CDP access is limited to the Chrome instance owned by the managed Cypress run. */
10
+ export declare class CypressBrowser {
11
+ private readonly port;
12
+ private readonly recordFailure?;
13
+ private socket?;
14
+ private connecting?;
15
+ private generation;
16
+ private nextId;
17
+ private pending;
18
+ private contexts;
19
+ private consoleEntries;
20
+ private networkEntries;
21
+ private paused;
22
+ private readonly sanitize;
23
+ constructor(port: number, sanitize?: (text: string) => string, recordFailure?: ((record: unknown) => void) | undefined);
24
+ connect(): Promise<void>;
25
+ private open;
26
+ private message;
27
+ private call;
28
+ private frameContexts;
29
+ inspect(input: BrowserInspection): Promise<any>;
30
+ private failPending;
31
+ close(): Promise<void>;
32
+ }
@@ -0,0 +1,280 @@
1
+ function safeUrl(value) {
2
+ try {
3
+ const url = new URL(value);
4
+ return `${url.protocol}//${url.host}${url.pathname}`;
5
+ }
6
+ catch {
7
+ return '[unavailable URL]';
8
+ }
9
+ }
10
+ export function redactBrowserText(value) {
11
+ return value.replace(/\bBearer\s+[^\s"']+/gi, 'Bearer [REDACTED]')
12
+ .replace(/((?:password|passwd|secret|token|authorization|cookie|api[_-]?key)["']?\s*[:=]\s*["']?)[^\s"',;}]+/gi, '$1[REDACTED]')
13
+ .replace(/https?:\/\/[^\s<>"']+/gi, (url) => safeUrl(url));
14
+ }
15
+ /** CDP access is limited to the Chrome instance owned by the managed Cypress run. */
16
+ export class CypressBrowser {
17
+ port;
18
+ recordFailure;
19
+ socket;
20
+ connecting;
21
+ generation = 0;
22
+ nextId = 0;
23
+ pending = new Map();
24
+ contexts = new Map();
25
+ consoleEntries = [];
26
+ networkEntries = [];
27
+ paused = false;
28
+ sanitize;
29
+ constructor(port, sanitize = (text) => text, recordFailure) {
30
+ this.port = port;
31
+ this.recordFailure = recordFailure;
32
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
33
+ throw new Error('Invalid browser debugging port');
34
+ this.sanitize = (text) => redactBrowserText(sanitize(text));
35
+ }
36
+ async connect() {
37
+ if (this.socket?.readyState === 1)
38
+ return;
39
+ if (this.connecting)
40
+ return this.connecting;
41
+ this.connecting = this.open(this.generation);
42
+ try {
43
+ await this.connecting;
44
+ }
45
+ finally {
46
+ this.connecting = undefined;
47
+ }
48
+ }
49
+ async open(generation) {
50
+ if (typeof WebSocket === 'undefined')
51
+ throw new Error('Browser debugging requires Node.js 22.5+ with native WebSocket');
52
+ let targets;
53
+ try {
54
+ const response = await fetch(`http://127.0.0.1:${this.port}/json/list`, { signal: AbortSignal.timeout(3000), redirect: 'error' });
55
+ if (!response.ok)
56
+ throw new Error(`HTTP ${response.status}`);
57
+ targets = await response.json();
58
+ }
59
+ catch {
60
+ throw new Error('Cypress browser is not available yet. Poll the run, then retry browser inspection.');
61
+ }
62
+ if (generation !== this.generation)
63
+ throw new Error('Cypress browser inspection closed during connection');
64
+ const pages = targets.filter((target) => target.type === 'page' && target.webSocketDebuggerUrl);
65
+ const page = pages.find((target) => /\/__\/|\/__cypress\/|cypress\/runner/.test(target.url))
66
+ ?? (pages.length === 1 ? pages[0] : undefined);
67
+ if (!page?.webSocketDebuggerUrl)
68
+ throw new Error('No unique Cypress runner page is available on the managed browser port');
69
+ const endpoint = new URL(page.webSocketDebuggerUrl);
70
+ if (endpoint.protocol !== 'ws:' || !['127.0.0.1', 'localhost', '[::1]'].includes(endpoint.hostname)
71
+ || Number(endpoint.port) !== this.port || endpoint.username || endpoint.password) {
72
+ throw new Error('Browser debugger endpoint must remain on the managed loopback port');
73
+ }
74
+ const socket = new WebSocket(endpoint.href);
75
+ this.socket = socket;
76
+ socket.addEventListener('message', (event) => this.message(String(event.data)));
77
+ socket.addEventListener('close', () => {
78
+ this.contexts.clear();
79
+ this.failPending(new Error('Cypress browser connection closed; retry if the run launches another browser'));
80
+ });
81
+ try {
82
+ await new Promise((resolve, reject) => {
83
+ const timer = setTimeout(() => { socket.close(); reject(new Error('Browser connection timed out')); }, 5000);
84
+ socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
85
+ socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('Browser connection failed')); }, { once: true });
86
+ socket.addEventListener('close', () => { clearTimeout(timer); reject(new Error('Browser connection closed')); }, { once: true });
87
+ });
88
+ if (generation !== this.generation)
89
+ throw new Error('Cypress browser inspection closed during connection');
90
+ await Promise.all([
91
+ ...['Runtime.enable', 'Page.enable', 'Network.enable', 'Debugger.enable'].map((method) => this.call(method)),
92
+ this.call('Runtime.addBinding', { name: '__overkillRecord' }),
93
+ ]);
94
+ }
95
+ catch (error) {
96
+ await this.close();
97
+ throw error;
98
+ }
99
+ }
100
+ message(raw) {
101
+ let message;
102
+ try {
103
+ message = JSON.parse(raw);
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ if (message.id) {
109
+ const pending = this.pending.get(message.id);
110
+ if (!pending)
111
+ return;
112
+ clearTimeout(pending.timer);
113
+ this.pending.delete(message.id);
114
+ if (message.error)
115
+ pending.reject(new Error(this.sanitize(message.error.message)));
116
+ else
117
+ pending.resolve(message.result);
118
+ return;
119
+ }
120
+ const params = message.params ?? {};
121
+ if (message.method === 'Runtime.bindingCalled' && params.name === '__overkillRecord'
122
+ && typeof params.payload === 'string' && Buffer.byteLength(params.payload, 'utf8') <= 250000) {
123
+ try {
124
+ const record = JSON.parse(params.payload);
125
+ if (record && record.type === 'failure')
126
+ this.recordFailure?.(record);
127
+ }
128
+ catch {
129
+ // Malformed page telemetry or a failed evidence sink must not crash the MCP connection.
130
+ }
131
+ }
132
+ if (message.method === 'Runtime.executionContextCreated')
133
+ this.contexts.set(params.context.id, params.context);
134
+ if (message.method === 'Runtime.executionContextDestroyed')
135
+ this.contexts.delete(params.executionContextId);
136
+ if (message.method === 'Runtime.executionContextsCleared')
137
+ this.contexts.clear();
138
+ if (message.method === 'Debugger.paused')
139
+ this.paused = true;
140
+ if (message.method === 'Debugger.resumed')
141
+ this.paused = false;
142
+ if (message.method === 'Runtime.consoleAPICalled' || message.method === 'Runtime.exceptionThrown') {
143
+ const text = params.exceptionDetails?.exception?.description ?? params.exceptionDetails?.text
144
+ ?? (params.args ?? []).map((arg) => typeof arg.value === 'string' ? arg.value : arg.description ?? String(arg.value ?? arg.type)).join(' ');
145
+ this.consoleEntries.push({ type: params.type ?? 'exception', text: this.sanitize(text).slice(0, 2000), timestamp: new Date().toISOString() });
146
+ this.consoleEntries = this.consoleEntries.slice(-200);
147
+ }
148
+ if (message.method === 'Network.requestWillBeSent') {
149
+ this.networkEntries.push({ requestId: params.requestId, url: safeUrl(params.request.url), method: params.request.method });
150
+ }
151
+ if (message.method === 'Network.responseReceived') {
152
+ this.networkEntries.push({ requestId: params.requestId, url: safeUrl(params.response.url), status: params.response.status, type: params.type });
153
+ }
154
+ if (message.method === 'Network.loadingFailed') {
155
+ this.networkEntries.push({ requestId: params.requestId, error: this.sanitize(params.errorText ?? ''), canceled: params.canceled });
156
+ }
157
+ this.networkEntries = this.networkEntries.slice(-200);
158
+ }
159
+ call(method, params = {}, timeoutMs = 10000) {
160
+ if (this.socket?.readyState !== 1)
161
+ return Promise.reject(new Error('Cypress browser is disconnected'));
162
+ const id = ++this.nextId;
163
+ return new Promise((resolve, reject) => {
164
+ const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Browser command ${method} timed out`)); }, timeoutMs);
165
+ this.pending.set(id, { resolve, reject, timer });
166
+ try {
167
+ this.socket.send(JSON.stringify({ id, method, params }));
168
+ }
169
+ catch (error) {
170
+ clearTimeout(timer);
171
+ this.pending.delete(id);
172
+ reject(error);
173
+ }
174
+ });
175
+ }
176
+ async frameContexts() {
177
+ const frames = [];
178
+ const walk = (tree) => { frames.push(tree.frame); (tree.childFrames ?? []).forEach(walk); };
179
+ walk((await this.call('Page.getFrameTree')).frameTree);
180
+ return [...this.contexts.values()].filter((context) => context.auxData?.isDefault).map((context) => ({
181
+ contextId: context.id,
182
+ frame: frames.find((frame) => frame.id === context.auxData?.frameId),
183
+ }));
184
+ }
185
+ async inspect(input) {
186
+ await this.connect();
187
+ if (input.action === 'pause' || input.action === 'resume') {
188
+ if (input.action === 'pause') {
189
+ await this.call('Debugger.pause');
190
+ return { pauseRequested: true, paused: this.paused, note: 'Pauses at the next JavaScript execution. Cypress timeouts continue; resume promptly.' };
191
+ }
192
+ // Disabling also cancels a pause armed on an idle page; resume alone does not.
193
+ await this.call('Debugger.disable');
194
+ await this.call('Debugger.enable');
195
+ this.paused = false;
196
+ const contexts = await this.frameContexts();
197
+ const runner = contexts.find((item) => /your spec/i.test(item.frame?.name ?? ''))
198
+ ?? contexts.find((item) => item.frame && !item.frame.parentId);
199
+ if (runner)
200
+ await this.call('Runtime.evaluate', { expression: 'globalThis.__overkillResume?.()', contextId: runner.contextId, returnByValue: true, timeout: 5000 });
201
+ return { paused: false, resumed: true };
202
+ }
203
+ if (input.action === 'screenshot') {
204
+ const { data } = await this.call('Page.captureScreenshot', { format: 'png' });
205
+ return { data, mimeType: 'image/png' };
206
+ }
207
+ const contexts = await this.frameContexts();
208
+ const candidates = input.contextId !== undefined ? contexts.filter((item) => item.contextId === input.contextId)
209
+ : input.target === 'runner' ? contexts.filter((item) => item.frame && !item.frame.parentId)
210
+ : contexts.filter((item) => item.frame?.parentId && /^Your (?:App|project)(?::|$)/i.test(item.frame.name ?? ''));
211
+ const selected = candidates.length === 1 ? candidates[0] : undefined;
212
+ if (!selected)
213
+ throw new Error(`No unique ${input.target ?? 'aut'} execution context. Available contexts: ${JSON.stringify(contexts.map((item) => ({ contextId: item.contextId, name: item.frame?.name, url: safeUrl(item.frame?.url ?? '') })))}. Use contextId or target=runner.`);
214
+ if (input.action === 'evaluate' && !input.expression?.trim())
215
+ throw new Error('Browser evaluate requires an expression');
216
+ const expression = input.action === 'evaluate' ? input.expression : `(() => {
217
+ const root = document.querySelector(${JSON.stringify(input.selector ?? 'body')});
218
+ if (!root) return { url: location.origin + location.pathname, html: null, matched: false };
219
+ if (root.matches('script,style,noscript,input[type="hidden"],meta')) return { html: '[REDACTED]', matched: true };
220
+ const clone = root.cloneNode(true);
221
+ clone.querySelectorAll('script,style,noscript,input[type="hidden"],meta').forEach(node => node.remove());
222
+ for (const node of [clone, ...clone.querySelectorAll('*')]) {
223
+ for (const attr of [...node.attributes]) {
224
+ if (!['testguid','data-testid','id','class','role','aria-label','aria-expanded','aria-disabled','type','name','title','disabled','checked'].includes(attr.name)) node.removeAttribute(attr.name);
225
+ }
226
+ if (node.matches('input,textarea,[contenteditable], [autocomplete*="password"]')) { node.removeAttribute('value'); node.textContent = ''; }
227
+ }
228
+ return { url: location.origin + location.pathname, html: clone.outerHTML.slice(0, 40000), matched: true, truncated: clone.outerHTML.length > 40000,
229
+ debug: globalThis.__overkillDebug ?? null,
230
+ spec: globalThis.Cypress?.spec?.relative ?? null,
231
+ currentTest: globalThis.Cypress?.currentTest ?? null };
232
+ })()`;
233
+ const result = await this.call('Runtime.evaluate', { expression, contextId: selected.contextId, returnByValue: true, awaitPromise: input.action === 'evaluate' && !this.paused, timeout: 5000, disableBreaks: true });
234
+ if (result.exceptionDetails)
235
+ throw new Error(this.sanitize(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text));
236
+ const value = result.result?.value ?? result.result?.description ?? null;
237
+ const clean = (entry) => typeof entry === 'string' ? this.sanitize(entry)
238
+ : Array.isArray(entry) ? entry.map(clean)
239
+ : entry && typeof entry === 'object' ? Object.fromEntries(Object.entries(entry).map(([key, item]) => [key, /password|passwd|secret|token|authorization|cookie|api[_-]?key/i.test(key) ? '[REDACTED]' : clean(item)])) : entry;
240
+ const cleaned = clean(value);
241
+ const serialized = JSON.stringify(cleaned);
242
+ const dom = serialized.length <= 50000 ? cleaned : { truncated: true, text: serialized.slice(0, 50000) };
243
+ let runnerState;
244
+ if (input.action === 'snapshot') {
245
+ const spec = contexts.find((item) => /your spec/i.test(item.frame?.name ?? ''))
246
+ ?? contexts.find((item) => item.frame && !item.frame.parentId);
247
+ if (spec) {
248
+ const state = await this.call('Runtime.evaluate', {
249
+ expression: '({debug: globalThis.__overkillDebug ?? null, spec: globalThis.Cypress?.spec?.relative ?? null, currentTest: globalThis.Cypress?.currentTest ?? null})',
250
+ contextId: spec.contextId, returnByValue: true, awaitPromise: false, timeout: 5000, disableBreaks: true,
251
+ });
252
+ const value = clean(state.result?.value ?? null);
253
+ const json = JSON.stringify(value);
254
+ runnerState = json.length <= 50000 ? value : { truncated: true, text: json.slice(0, 50000) };
255
+ }
256
+ }
257
+ return input.action === 'evaluate' ? { value: dom, contextId: selected.contextId } : {
258
+ dom, runnerState, contextId: selected.contextId, paused: this.paused,
259
+ contexts: contexts.map((item) => ({ contextId: item.contextId, name: item.frame?.name, url: safeUrl(item.frame?.url ?? '') })),
260
+ console: this.consoleEntries, network: this.networkEntries,
261
+ };
262
+ }
263
+ failPending(error) {
264
+ for (const pending of this.pending.values()) {
265
+ clearTimeout(pending.timer);
266
+ pending.reject(error);
267
+ }
268
+ this.pending.clear();
269
+ }
270
+ async close() {
271
+ this.generation++;
272
+ // Detaching must not leave the user's Cypress browser paused.
273
+ if (this.socket?.readyState === 1)
274
+ await this.call('Debugger.disable', {}, 1000).catch(() => { });
275
+ this.socket?.close();
276
+ this.socket = undefined;
277
+ this.contexts.clear();
278
+ this.failPending(new Error('Cypress browser inspection closed'));
279
+ }
280
+ }
@@ -0,0 +1,8 @@
1
+ import { type ChildProcess } from 'node:child_process';
2
+ export declare function cypressInstallation(repoRoot: string, requireTap?: boolean): {
3
+ cli: string;
4
+ version: string;
5
+ };
6
+ export declare function cypressEnvironment(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
7
+ export declare function cypressTapArguments(cli: string, args: string[], platform?: NodeJS.Platform): string[];
8
+ export declare function stopProcessTree(child?: ChildProcess, platform?: NodeJS.Platform): Promise<void>;
@@ -0,0 +1,83 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ export function cypressInstallation(repoRoot, requireTap = false) {
6
+ const require = createRequire(path.join(repoRoot, 'package.json'));
7
+ let manifest;
8
+ try {
9
+ manifest = require.resolve('cypress/package.json');
10
+ }
11
+ catch {
12
+ throw new Error('Install Cypress in the configured test project before using the runtime.');
13
+ }
14
+ const { version, bin } = JSON.parse(fs.readFileSync(manifest, 'utf8'));
15
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:\+.*)?$/.exec(version);
16
+ if (requireTap && (!match || Number(match[1]) < 15 || (Number(match[1]) === 15 && Number(match[2]) < 21))) {
17
+ throw new Error(`Cypress TAP requires Cypress 15.21.0+ (installed: ${version}); use managed run mode with older versions.`);
18
+ }
19
+ const cli = path.resolve(path.dirname(manifest), typeof bin === 'string' ? bin : bin.cypress);
20
+ return { cli, version: String(version) };
21
+ }
22
+ export function cypressEnvironment(env = process.env) {
23
+ // Keep local diagnostics free of guest telemetry; TAP's local requests also require graceful exit below.
24
+ return {
25
+ ...Object.fromEntries(Object.entries(env).filter(([key]) => !['ELECTRON_RUN_AS_NODE', 'CYPRESS_DISABLE_GUEST_TELEMETRY'].includes(key.toUpperCase()))),
26
+ CYPRESS_DISABLE_GUEST_TELEMETRY: '1',
27
+ };
28
+ }
29
+ export function cypressTapArguments(cli, args, platform = process.platform) {
30
+ const command = [cli, 'tap', ...args];
31
+ // TAP exits after its async dispatch. Let pending fetch/CDP work drain on Windows (nodejs/node#56645).
32
+ // Only this short-lived TAP child is changed; exit codes and the caller's timeout still apply.
33
+ return platform === 'win32'
34
+ ? ['--input-type=commonjs', '--eval', 'process.exit = (code) => { if (code !== undefined) process.exitCode = code; }; require(process.argv[1]);', ...command]
35
+ : command;
36
+ }
37
+ export async function stopProcessTree(child, platform = process.platform) {
38
+ if (!child?.pid)
39
+ return;
40
+ const pid = child.pid;
41
+ if (platform === 'win32') {
42
+ if (child.exitCode !== null || child.signalCode !== null)
43
+ return;
44
+ await new Promise((resolve, reject) => {
45
+ execFile('taskkill.exe', ['/pid', String(pid), '/T', '/F'], { windowsHide: true }, (error) => {
46
+ if (error) {
47
+ try {
48
+ process.kill(pid, 0);
49
+ }
50
+ catch (gone) {
51
+ if (gone.code === 'ESRCH') {
52
+ resolve();
53
+ return;
54
+ }
55
+ }
56
+ reject(error);
57
+ }
58
+ else
59
+ resolve();
60
+ });
61
+ });
62
+ return;
63
+ }
64
+ const signal = (value) => {
65
+ try {
66
+ process.kill(-pid, value);
67
+ return true;
68
+ }
69
+ catch (error) {
70
+ if (error.code === 'ESRCH')
71
+ return false;
72
+ throw error;
73
+ }
74
+ };
75
+ // A detached POSIX child's group can outlive its leader. Always signal the group.
76
+ if (!signal('SIGTERM'))
77
+ return;
78
+ const deadline = Date.now() + 4000;
79
+ while (signal(0) && Date.now() < deadline)
80
+ await new Promise((resolve) => setTimeout(resolve, 100));
81
+ if (signal(0))
82
+ signal('SIGKILL');
83
+ }
@@ -0,0 +1,36 @@
1
+ export type CypressRunOptions = {
2
+ spec: string;
3
+ mode?: 'run' | 'open';
4
+ browser?: string;
5
+ repeats?: number;
6
+ retries?: number;
7
+ timeoutSeconds?: number;
8
+ debug?: boolean;
9
+ };
10
+ export declare class CypressRuns {
11
+ private readonly cacheDir;
12
+ private readonly sanitize;
13
+ private readonly runs;
14
+ private readonly root;
15
+ private readonly lock;
16
+ private ownsLock;
17
+ private starting;
18
+ private closed;
19
+ constructor(repoRoot: string, cacheDir: string, sanitize?: (text: string) => string);
20
+ active(): {
21
+ runId: string;
22
+ mode: "run" | "open";
23
+ } | undefined;
24
+ start(input: CypressRunOptions): Promise<any>;
25
+ get(runId: string): any;
26
+ recordCapture(runId: string, payload: unknown): boolean;
27
+ getDebugPort(runId: string): number;
28
+ stop(runId: string): Promise<any>;
29
+ close(): Promise<void>;
30
+ private terminate;
31
+ private requireRun;
32
+ private acquireLock;
33
+ private releaseLock;
34
+ private save;
35
+ private execute;
36
+ }