From 9ae674e61603dbbeddce958552e64ee81f69675c Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:33:46 +0000 Subject: [PATCH 01/82] fix(mcp): preserve project tool model output and error status (#894) Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- .changeset/mcp-model-output.md | 5 ++++ docs/reference/mcp.mdx | 5 ++++ packages/e2e/src/mcp/tools.ts | 9 +++++--- .../e2e/tests/integration/mcp-server.test.ts | 23 +++++++++++++++++++ packages/e2e/tests/unit/mcp-tools.test.ts | 15 ++++++++++++ skills/e2e/references/mcp.md | 4 ++++ 6 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .changeset/mcp-model-output.md diff --git a/.changeset/mcp-model-output.md b/.changeset/mcp-model-output.md new file mode 100644 index 000000000..b85eb44dd --- /dev/null +++ b/.changeset/mcp-model-output.md @@ -0,0 +1,5 @@ +--- +e2e: patch +--- + +Honor project tools' toModelOutput renderers for string results and preserve rendered error outputs as MCP error results. diff --git a/docs/reference/mcp.mdx b/docs/reference/mcp.mdx index 805e0b4a6..fa002f40b 100644 --- a/docs/reference/mcp.mdx +++ b/docs/reference/mcp.mdx @@ -86,6 +86,11 @@ this. | `call` | `tool`, `args` (optional), `session` (required while several are open) | Runs one catalog tool. Arguments are validated against the tool's closed schema first: a wrong type or an argument the tool does not declare is `INVALID_ARGUMENT`, and nothing runs. An action that fails is an error result that still carries the screen it re-observed, and leads with the tool, its target, and the code: `navigate file:///etc/passwd failed: POLICY_DENIED: forbidden URL scheme: file:`. | | `close_session` | `session` (required while several are open) | Saves a recording still running, ends the attempt, disposes the engine, stops the app processes the session started once no other session uses them. | +Project tools honor their AI SDK `toModelOutput` renderer, including when +`execute` returns a string. Rendered `error-text` and `error-json` outputs +are MCP error results (`isError: true`) so the coding agent can distinguish +a failed tool call from a successful one. + ## Several sessions Several sessions can be open at once, up to `--max-sessions`, each on its diff --git a/packages/e2e/src/mcp/tools.ts b/packages/e2e/src/mcp/tools.ts index 5a2c701f9..b387f57b8 100644 --- a/packages/e2e/src/mcp/tools.ts +++ b/packages/e2e/src/mcp/tools.ts @@ -255,11 +255,14 @@ type ModelOutput = Awaited>>; * else is JSON, so a structured output is never lost. */ export async function resultFromOutput(tool: ToolSet[string], output: unknown, input: unknown = undefined): Promise { - if (typeof output === 'string') return textResult(output); if (tool.toModelOutput !== undefined) { - const content = contentFromModelOutput(await tool.toModelOutput({ toolCallId: 'mcp', input, output })); - if (content !== undefined) return { content }; + const rendered = await tool.toModelOutput({ toolCallId: 'mcp', input, output }); + const content = contentFromModelOutput(rendered); + if (content !== undefined) { + return { content, ...(rendered.type === 'error-text' || rendered.type === 'error-json' ? { isError: true } : {}) }; + } } + if (typeof output === 'string') return textResult(output); return textResult(output === undefined ? 'Done.' : JSON.stringify(output, null, 2)); } diff --git a/packages/e2e/tests/integration/mcp-server.test.ts b/packages/e2e/tests/integration/mcp-server.test.ts index a47becb24..6aca9ed93 100644 --- a/packages/e2e/tests/integration/mcp-server.test.ts +++ b/packages/e2e/tests/integration/mcp-server.test.ts @@ -93,6 +93,14 @@ export default { { description: 'Reset the kiosk.', inputSchema: z.object({}), execute: async () => 'kiosk reset' }, { mutates: true, platforms: ['kiosk'] }, ), + render_message: defineTool( + { description: 'Render a message for the model.', inputSchema: z.object({}), execute: async () => 'raw string', toModelOutput: async ({ output }) => ({ type: 'text', value: 'formatted: ' + output }) }, + { mutates: false }, + ), + render_failure: defineTool( + { description: 'Render a failed result for the model.', inputSchema: z.object({ format: z.enum(['text', 'json']) }), execute: async () => ({ reason: 'not available' }), toModelOutput: ({ input, output }) => input.format === 'text' ? { type: 'error-text', value: output.reason } : { type: 'error-json', value: output } }, + { mutates: false }, + ), }, } }, } satisfies E2EConfig; @@ -437,6 +445,21 @@ describe('e2e mcp', { timeout: 120_000 }, () => { expect(result.text).toContain('web, kiosk'); }); + it('renders project tool model output and preserves failed results over MCP', async () => { + const opened = await invoke('open_session', { config: 'custom.config.ts', target: 'web' }); + expect(opened.isError, opened.text).toBe(false); + const message = await call('render_message'); + expect(message.isError, message.text).toBe(false); + expect(message.text).toBe('formatted: raw string'); + const failureText = await call('render_failure', { format: 'text' }); + expect(failureText.isError).toBe(true); + expect(failureText.text).toBe('not available'); + const failureJson = await call('render_failure', { format: 'json' }); + expect(failureJson.isError).toBe(true); + expect(JSON.parse(failureJson.text)).toEqual({ reason: 'not available' }); + await invoke('close_session'); + }); + it('catalogs and runs the project tools of a custom engine target, with only the verbs it declares', async () => { const opened = await invoke('open_session', { config: 'custom.config.ts', target: 'kiosk' }); expect(opened.isError, opened.text).toBe(false); diff --git a/packages/e2e/tests/unit/mcp-tools.test.ts b/packages/e2e/tests/unit/mcp-tools.test.ts index 6f775e6b8..4da038455 100644 --- a/packages/e2e/tests/unit/mcp-tools.test.ts +++ b/packages/e2e/tests/unit/mcp-tools.test.ts @@ -154,6 +154,21 @@ describe('resultFromOutput', () => { }; expect(await resultFromOutput(echo, 42, { label: 'answer' })).toEqual({ content: [{ type: 'text', text: 'answer: 42' }] }); }); + + it('renders string outputs with the custom renderer before passing them through', async () => { + const rendered: Tool = { + ...plain, + toModelOutput: async ({ output }) => ({ type: 'text', value: `formatted: ${String(output)}` }), + }; + expect(await resultFromOutput(rendered, 'raw string')).toEqual({ content: [{ type: 'text', text: 'formatted: raw string' }] }); + }); + + it('preserves model error outputs as MCP errors', async () => { + const errorText: Tool = { ...plain, toModelOutput: () => ({ type: 'error-text', value: 'not available' }) }; + const errorJson: Tool = { ...plain, toModelOutput: () => ({ type: 'error-json', value: { reason: 'not available' } }) }; + expect(await resultFromOutput(errorText, 'raw string')).toEqual({ content: [{ type: 'text', text: 'not available' }], isError: true }); + expect(await resultFromOutput(errorJson, { reason: 'not available' })).toEqual({ content: [{ type: 'text', text: '{\n "reason": "not available"\n}' }], isError: true }); + }); }); describe('errorResult', () => { diff --git a/skills/e2e/references/mcp.md b/skills/e2e/references/mcp.md index 84a9c9bd3..402804c0a 100644 --- a/skills/e2e/references/mcp.md +++ b/skills/e2e/references/mcp.md @@ -58,6 +58,10 @@ The catalog, per session: | `stop_recording` | Stops it and returns the absolute path of each video file, under `/videos//` (`.e2e` by default), or the URL of a provider's own recording. | | Project tools | Every `defineTool` in the agent's `tools` that applies to the target's platform, under its own name; an engine pack such as `mobileTools` adds `open_app`, `swipe`, `alert`. | +Project tools use their `toModelOutput` renderer for strings as well as +structured results. Its `error-text` and `error-json` outputs set the MCP +result's `isError: true`; treat them as failed calls. + Once a secret is filled in the session, `screenshot` and the point tools (`tap_at`, `hover_at`, `type_at`, `press_at`, `select_at`) answer with a `PIXEL_TAINTED` line for the rest of it; act on listed nodes by id (topic From fe9b0e8bf3dde5ebe75e101f5157e9d755d29612 Mon Sep 17 00:00:00 2001 From: Feco Linhares <963774+fecolinhares@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:35:28 +0000 Subject: [PATCH 02/82] test: remove the temp dirs six files leave behind (#909) Six test files created a temp root and never deleted it, so a run left the whole set in tmpdir: e2e-step-cache, e2e-flush, e2e-flush-derived, e2e-rekeyed, e2e-trace-cache, e2e-sessions, e2e-globs and crosscheck, up to 2 GB of orphans on this machine before anyone noticed. last-run.test.ts had the subtler leak: one module-level `dir` that `reportFile` overwrote, so a case calling it twice dropped the first root on the floor and no afterEach could see it. It now keeps the roots in a list. Measured with the delta of `os.tmpdir()` entries before and after the suites, which is the only gate that sees this: `pnpm --filter e2e run test:unit` and `pnpm --filter @e2e-dev/web run test:unit` both leave 0 behind, and each file leaks its own count when its cleanup is reverted. --- packages/e2e/tests/unit/globs.test.ts | 12 ++++++++-- packages/e2e/tests/unit/last-run.test.ts | 13 +++++++---- .../e2e/tests/unit/session-envelope.test.ts | 11 ++++++++- packages/e2e/tests/unit/step-cache.test.ts | 23 ++++++++++++++----- packages/e2e/tests/unit/trace-store.test.ts | 12 ++++++++-- .../unit/crosscheck-expectations.test.ts | 15 +++++++++--- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/packages/e2e/tests/unit/globs.test.ts b/packages/e2e/tests/unit/globs.test.ts index c4368c2b7..14051f3fb 100644 --- a/packages/e2e/tests/unit/globs.test.ts +++ b/packages/e2e/tests/unit/globs.test.ts @@ -1,7 +1,7 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { compareCodePoints, compileGlob, @@ -107,8 +107,16 @@ describe('glob grammar', () => { }); describe('discoverFiles', () => { + /** Temp roots the cases here create, removed after each so a run leaves nothing behind. */ + const tempRoots: string[] = []; + + afterEach(() => { + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + it('matches, unions, de-duplicates, and sorts by code point', () => { const root = path.join(tmpdir(), `e2e-globs-${Date.now()}`); + tempRoots.push(root); mkdirSync(path.join(root, 'tests', 'nested'), { recursive: true }); writeFileSync(path.join(root, 'tests', 'b.e2e.ts'), ''); writeFileSync(path.join(root, 'tests', 'a.e2e.ts'), ''); diff --git a/packages/e2e/tests/unit/last-run.test.ts b/packages/e2e/tests/unit/last-run.test.ts index 1b5d99ab6..324afd1b5 100644 --- a/packages/e2e/tests/unit/last-run.test.ts +++ b/packages/e2e/tests/unit/last-run.test.ts @@ -6,15 +6,19 @@ import { resultId } from '../../src/internal/ids.ts'; import type { Report1Document } from '../../src/report/build.ts'; import { carryForward, lastFailedIds, readLastRun, reportArtifactPaths } from '../../src/run/last-run.ts'; -let dir: string; +/** Temp roots this file creates. A list, not one `dir`: a case may make several, and + * `reportFile` is called more than once inside a case, so a single variable drops the + * earlier roots on the floor. */ +const tempRoots: string[] = []; afterEach(() => { - if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }); }); /** Writes `content` as the report file of a fresh project directory and returns its path. */ function reportFile(content: string): string { - dir = mkdtempSync(path.join(os.tmpdir(), 'e2e-last-run-')); + const dir = mkdtempSync(path.join(os.tmpdir(), 'e2e-last-run-')); + tempRoots.push(dir); const file = path.join(dir, 'report.json'); writeFileSync(file, content, 'utf8'); return file; @@ -134,7 +138,8 @@ describe('readLastRun and lastFailedIds', () => { }); it('is NO_LAST_RUN when no report exists, with the path and the way out', async () => { - dir = mkdtempSync(path.join(os.tmpdir(), 'e2e-last-run-')); + const dir = mkdtempSync(path.join(os.tmpdir(), 'e2e-last-run-')); + tempRoots.push(dir); const missing = path.join(dir, 'report.json'); await expect(readLastRun(missing)).rejects.toMatchObject({ code: 'NO_LAST_RUN', diff --git a/packages/e2e/tests/unit/session-envelope.test.ts b/packages/e2e/tests/unit/session-envelope.test.ts index 3c708ecdd..7542fc3ea 100644 --- a/packages/e2e/tests/unit/session-envelope.test.ts +++ b/packages/e2e/tests/unit/session-envelope.test.ts @@ -1,6 +1,6 @@ /** Session envelope wire format. */ -import { mkdtemp, readdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -20,11 +20,19 @@ const identity: SessionIdentity = { const SECRET_VALUE = 'provider-minted-token-7391'; +/** Temp dirs the cases in this file create, removed after each so a run leaves nothing behind. */ +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + /** Saves one state and returns the parsed envelope, its raw text, its file, and its store. */ async function saveEnvelope( secrecy: SavedSecrecy = { secrets: [], tainted: false }, ): Promise<{ store: SessionStore; raw: string; file: string; envelope: Record }> { const root = await mkdtemp(path.join(tmpdir(), 'e2e-sessions-')); + tempDirs.push(root); const runId = uuidv7(); const store = SessionStore.create(runId, root); await store.save('member', identity, { @@ -159,6 +167,7 @@ describe('session file names', () => { /** Opens a store on a fresh temporary root. */ async function openStore(): Promise<{ store: SessionStore; root: string; runId: string }> { const root = await mkdtemp(path.join(tmpdir(), 'e2e-sessions-')); + tempDirs.push(root); const runId = uuidv7(); return { store: SessionStore.create(runId, root), root, runId }; } diff --git a/packages/e2e/tests/unit/step-cache.test.ts b/packages/e2e/tests/unit/step-cache.test.ts index e5626f9cd..e83686394 100644 --- a/packages/e2e/tests/unit/step-cache.test.ts +++ b/packages/e2e/tests/unit/step-cache.test.ts @@ -1,6 +1,6 @@ /** StepTraceSession: store resilience, staging anchors, replay postconditions, the write-side decision. */ -import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -135,8 +135,19 @@ function stagedTrace(context: AgentCacheContext, index = 0): ActionTrace { return staged.trace; } -afterEach(() => { +/** Temp dirs the cases below create, removed after each so a run leaves nothing behind. */ +const tempDirs: string[] = []; + +/** A temp directory for one case, registered for removal after it. */ +async function tempDir(prefix: string): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(directory); + return directory; +} + +afterEach(async () => { vi.useRealTimers(); + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); describe('recordedVerdictOf', () => { @@ -811,7 +822,7 @@ describe('StepTraceSession', () => { }); it('leaves the entry file untouched across replays and rewrites it after a hand-off the executor healed', async () => { - const directory = await mkdtemp(join(tmpdir(), 'e2e-step-cache-')); + const directory = await tempDir('e2e-step-cache-'); const store = new FileCacheStore({ directory, maxBytes: MAX_CACHE_WIRE_BYTES, writable: true }); const context = (): AgentCacheContext => ({ mode: 'read-write', @@ -951,7 +962,7 @@ describe('destination path settling', () => { describe('flushStagedTraces and a re-recorded flow', () => { it('leaves an entry the same flow re-recorded untouched, and replaces it when the actions change', async () => { - const directory = await mkdtemp(join(tmpdir(), 'e2e-flush-')); + const directory = await tempDir('e2e-flush-'); const store = new FileCacheStore({ directory, maxBytes: MAX_CACHE_WIRE_BYTES, writable: true }); const context = (): AgentCacheContext => ({ mode: 'read-write', @@ -1000,7 +1011,7 @@ describe('flushStagedTraces and a re-recorded flow', () => { }); it('leaves an entry untouched when only the rule that flagged a typed value differs', async () => { - const directory = await mkdtemp(join(tmpdir(), 'e2e-flush-derived-')); + const directory = await tempDir('e2e-flush-derived-'); const store = new FileCacheStore({ directory, maxBytes: MAX_CACHE_WIRE_BYTES, writable: true }); const file = join(directory, `${'d'.repeat(64)}.json`); const trace = (derived?: DerivedReason): ActionTrace => ({ @@ -1104,7 +1115,7 @@ describe('cache.strict and a step whose key changed under its recording', () => /** A file store holding one entry under `OLD_KEY`, and a context whose own key finds nothing in it. */ async function rekeyedContext(payload: ActionTrace, strict = true): Promise { - const directory = await mkdtemp(join(tmpdir(), 'e2e-rekeyed-')); + const directory = await tempDir('e2e-rekeyed-'); const store = new FileCacheStore({ directory, maxBytes: MAX_CACHE_WIRE_BYTES, writable: true }); await store.write(OLD_KEY, payload); return { diff --git a/packages/e2e/tests/unit/trace-store.test.ts b/packages/e2e/tests/unit/trace-store.test.ts index d820cc8c3..c4069c9d2 100644 --- a/packages/e2e/tests/unit/trace-store.test.ts +++ b/packages/e2e/tests/unit/trace-store.test.ts @@ -1,9 +1,9 @@ /** trace-1 file store: atomic writes, fail-to-miss reads. */ -import { mkdtemp, readdir, writeFile } from 'node:fs/promises'; +import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { FileCacheStore, MAX_CACHE_WIRE_BYTES } from '../../src/cache/store.ts'; import type { ActionTrace } from '../../src/cache/trace.ts'; @@ -16,8 +16,16 @@ const payload: ActionTrace = { startPath: '/settings', }; +/** Temp dirs the cases here create, removed after each so a run leaves nothing behind. */ +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + async function makeStore(options: { maxBytes?: number; writable?: boolean } = {}) { const directory = await mkdtemp(path.join(tmpdir(), 'e2e-trace-cache-')); + tempDirs.push(directory); return { directory, store: new FileCacheStore({ diff --git a/packages/web/tests/unit/crosscheck-expectations.test.ts b/packages/web/tests/unit/crosscheck-expectations.test.ts index e42070418..aad7d806f 100644 --- a/packages/web/tests/unit/crosscheck-expectations.test.ts +++ b/packages/web/tests/unit/crosscheck-expectations.test.ts @@ -1,16 +1,25 @@ /** The cross-check's committed expectations: keys, the diff, and the rewrite that keeps reasons. */ -import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { diffExpectations, keyOf, readExpectations, TODO_REASON, writeExpectations } from '../crosscheck/expectations.ts'; const fileRole = { oracle: 'chrome', field: 'role', node: 'textbox "Attachments"', ours: 'textbox', theirs: 'button' } as const; const inert = { oracle: 'chrome', field: 'unknown', node: 'button "Inert"', ours: 'button', theirs: 'no AX node' } as const; +/** Temp dirs the cases here create, removed after each so a run leaves nothing behind. */ +const tempDirs: string[] = []; + +afterEach(() => { + for (const directory of tempDirs.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + function file(content: string): string { - const target = path.join(mkdtempSync(path.join(os.tmpdir(), 'crosscheck-')), 'expected.txt'); + const root = mkdtempSync(path.join(os.tmpdir(), 'crosscheck-')); + tempDirs.push(root); + const target = path.join(root, 'expected.txt'); writeFileSync(target, content); return target; } From 032825ffe11921c08555df19256c8e466377fd7e Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:36:22 +0000 Subject: [PATCH 03/82] fix(init): retain MCP server environment and client settings (#902) * fix(init): retain MCP server environment and client settings Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> * test(init): preserve top-level settings during stale repair Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --------- Signed-off-by: Rudy Celekli <47457359+rudycelekli@users.noreply.github.com> --- .../preserve-mcp-registration-settings.md | 5 +++++ docs/reference/cli.mdx | 5 ++++- packages/e2e/src/cli/init/mcp-config.ts | 11 ++++++++--- packages/e2e/tests/integration/cli-init.test.ts | 10 ++++++++++ packages/e2e/tests/unit/mcp-config.test.ts | 17 +++++++++++++++++ skills/e2e/references/setup.md | 5 +++++ 6 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .changeset/preserve-mcp-registration-settings.md diff --git a/.changeset/preserve-mcp-registration-settings.md b/.changeset/preserve-mcp-registration-settings.md new file mode 100644 index 000000000..1db7d22df --- /dev/null +++ b/.changeset/preserve-mcp-registration-settings.md @@ -0,0 +1,5 @@ +--- +"e2e": patch +--- + +Preserve environment and client settings on existing e2e MCP registrations when rerunning init. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 66995abf2..7c928c670 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -141,7 +141,10 @@ bunx e2e init --yes ``` -Re-running `init` keeps an existing config and adds no packages for it. Once +Re-running `init` keeps an existing config and adds no packages for it. MCP +registration keeps extra settings on the e2e server, such as environment +variables and client timeouts, even when repairing its launch command. A +current registration is left byte-for-byte unchanged. Once a skill directory exists, later runs refresh copies that differ from the installed version and never add a directory the project did not choose. A `.claude/skills/e2e/` copy from an earlier version becomes the symlink when diff --git a/packages/e2e/src/cli/init/mcp-config.ts b/packages/e2e/src/cli/init/mcp-config.ts index 1d6fe9782..3a81778dd 100644 --- a/packages/e2e/src/cli/init/mcp-config.ts +++ b/packages/e2e/src/cli/init/mcp-config.ts @@ -70,7 +70,8 @@ export function findRegisteredMcpFiles(cwd: string): string[] { /** * Plans the writes for the given files. A file without the entry gets it * merged in; one whose entry already matches needs nothing and is left out. - * Other servers, other keys, and the file's indentation are preserved. + * Extra settings on the e2e entry, other servers, other keys, and the file's + * indentation are preserved. */ export function planMcpRegistration(cwd: string, files: readonly string[]): McpRegistration[] { const registrations: McpRegistration[] = []; @@ -81,8 +82,12 @@ export function planMcpRegistration(cwd: string, files: readonly string[]): McpR typeof document.mcpServers === 'object' && document.mcpServers !== null && !Array.isArray(document.mcpServers) ? document.mcpServers : {}; - if (JSON.stringify(servers[MCP_SERVER_NAME]) === JSON.stringify(SERVER_ENTRY)) continue; - const merged: McpConfigDocument = { ...document, mcpServers: { ...servers, [MCP_SERVER_NAME]: SERVER_ENTRY } }; + const existing = servers[MCP_SERVER_NAME]; + const entry = typeof existing === 'object' && existing !== null && !Array.isArray(existing) + ? { ...existing, ...SERVER_ENTRY } + : SERVER_ENTRY; + if (JSON.stringify(existing) === JSON.stringify(entry)) continue; + const merged: McpConfigDocument = { ...document, mcpServers: { ...servers, [MCP_SERVER_NAME]: entry } }; const original = existsSync(absolute) ? readFileSync(absolute, 'utf8') : undefined; const indent = original?.match(/\n([\t ]+)"/)?.[1] ?? ' '; registrations.push({ diff --git a/packages/e2e/tests/integration/cli-init.test.ts b/packages/e2e/tests/integration/cli-init.test.ts index ddcbe75e5..8873b6725 100644 --- a/packages/e2e/tests/integration/cli-init.test.ts +++ b/packages/e2e/tests/integration/cli-init.test.ts @@ -54,6 +54,16 @@ afterEach(() => { }); describe('initializing standalone projects', () => { + it('preserves configured MCP server settings when reinitializing a project', async () => { + const original = JSON.stringify({ + mcpServers: { e2e: { command: 'npx', args: ['e2e', 'mcp'], env: { APP_ENV: 'staging' }, timeout: 30_000 }, other: { command: 'other' } }, + clientSetting: true, + }, null, 4) + '\n'; + writeFileSync(path.join(dir, '.mcp.json'), original); + await execFileAsync(process.execPath, [CLI, 'init', '--yes'], { cwd: dir }); + expect(readFileSync(path.join(dir, '.mcp.json'), 'utf8')).toBe(original); + }); + it.each([undefined, 'http://localhost:4173'])( 'loads the generated config and collects its example with APP_URL=%s', async (appUrl) => { diff --git a/packages/e2e/tests/unit/mcp-config.test.ts b/packages/e2e/tests/unit/mcp-config.test.ts index 1601525b8..42af054ca 100644 --- a/packages/e2e/tests/unit/mcp-config.test.ts +++ b/packages/e2e/tests/unit/mcp-config.test.ts @@ -47,6 +47,23 @@ describe('planMcpRegistration', () => { expect(findRegisteredMcpFiles(dir)).toEqual(['.mcp.json', '.cursor/mcp.json']); }); + it('keeps extra settings on an already current server without rewriting the file', () => { + const entry = { ...ENTRY, env: { APP_ENV: 'staging' }, timeout: 30_000 }; + const original = JSON.stringify({ mcpServers: { e2e: entry } }, null, 4) + '\n'; + writeFileSync(path.join(dir, '.mcp.json'), original); + expect(planMcpRegistration(dir, ['.mcp.json'])).toEqual([]); + expect(readFileSync(path.join(dir, '.mcp.json'), 'utf8')).toBe(original); + }); + + it('repairs a stale launch command without dropping environment or client settings', () => { + writeFileSync(path.join(dir, '.mcp.json'), JSON.stringify({ + mcpServers: { e2e: { command: 'e2e', args: ['mcp'], env: { APP_ENV: 'staging' }, timeout: 30_000 } }, + other: true, + })); + const [plan] = planMcpRegistration(dir, ['.mcp.json']); + expect(JSON.parse(plan!.content)).toEqual({ mcpServers: { e2e: { ...ENTRY, env: { APP_ENV: 'staging' }, timeout: 30_000 } }, other: true }); + }); + it('refuses to overwrite a file that is not JSON', () => { writeFileSync(path.join(dir, '.mcp.json'), '{ not json'); expect(() => planMcpRegistration(dir, ['.mcp.json'])).toThrow(/is not valid JSON/); diff --git a/skills/e2e/references/setup.md b/skills/e2e/references/setup.md index 25d77b15b..3b9127288 100644 --- a/skills/e2e/references/setup.md +++ b/skills/e2e/references/setup.md @@ -357,3 +357,8 @@ export default { - `.gitignore` lists the `.e2e/` outputs (init adds them); drop the `.e2e/cache/` line to commit replays. - CI runs the whole suite, agent steps included, on PRs; see `running`. + +### Existing MCP registration + +Re-running `init` preserves the e2e MCP server's environment and client settings +while repairing its launch command. A current registration is left unchanged. From a0e5142f619916a3d9648653f295600ab2f8b5dc Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:01:09 +0000 Subject: [PATCH 04/82] fix(web): infer row headers beside table data cells (#891) * fix(web): infer row headers beside table data cells Signed-off-by: Rudy Celekli * docs(screen): qualify the scope-less header exception Signed-off-by: Rudy Celekli --------- Signed-off-by: Rudy Celekli --- .changeset/table-row-headers.md | 5 +++++ docs/reference/screen.mdx | 6 ++++- packages/web/src/in-page/read-semantics.ts | 14 +++++++++++- packages/web/tests/conformance/expected.txt | 7 ------ packages/web/tests/crosscheck/fixtures.ts | 10 +++++++++ .../tests/integration/read-node-roles.test.ts | 22 +++++++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 .changeset/table-row-headers.md diff --git a/.changeset/table-row-headers.md b/.changeset/table-row-headers.md new file mode 100644 index 000000000..0596e2cf2 --- /dev/null +++ b/.changeset/table-row-headers.md @@ -0,0 +1,5 @@ +--- +"@e2e-dev/web": patch +--- + +Infer scope-less table headers from neighboring data cells, matching native row headers in semantic observations. diff --git a/docs/reference/screen.mdx b/docs/reference/screen.mdx index d90d99368..0aff47dbc 100644 --- a/docs/reference/screen.mdx +++ b/docs/reference/screen.mdx @@ -669,7 +669,11 @@ and `
` are `group`, `` is `spinbutton`, `` naming a `` is a `combobox`, a `` of a `role="grid"` table is a `gridcell`, -`` and `` are `rowgroup`, `` is `rowheader`, +`` and `` are `rowgroup`, `` is `rowheader`. +A scope-less `` beside a data cell with text or children is also a +`rowheader`; header-only rows keep `columnheader`. A lone scope-less header in a one-row +table has no header role, while `scope="col"` and `scope="colgroup"` explicitly +keep `columnheader`. `
` is `figure`, `
` is `article`, `
` and `