From 3d62049d9301c39728aa0a5ca7a4eac84a6a5014 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 7 Sep 2026 04:40:30 -0400 Subject: [PATCH] test: cover the native-chat page contract and the Source Control host watch Nothing checked that what the desktop sanitizer emits is readable by the page, even though a page parse failure is permanent. The new contract test pins the shapes that survive and records three that do not: an over-long text block is silently dropped, and a turn over the block limit or an over-long message id fails the whole read. The desktop caps are the released native app's, so the missing bound belongs to the mobile-web read adapter, not the sanitizer. The Source Control host subscription had only happy-path coverage from a roundtrip test. It now has its own cases for watcher failure, end-of-watch, retirement, overflow batching and an unreadable frame. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb --- ...pc-message-sanitizer-page-contract.test.ts | 144 ++++++++++++++++++ ...b-source-control-host-subscription.test.ts | 118 ++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts create mode 100644 src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts diff --git a/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts new file mode 100644 index 00000000000..5019669c4c1 --- /dev/null +++ b/src/main/runtime/rpc/methods/native-chat-rpc-message-sanitizer-page-contract.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from 'vitest' +import { + MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT, + MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS, + MOBILE_WEB_NATIVE_CHAT_READ_LIMIT, + MobileWebNativeChatReadResultSchema +} from '../../../../shared/mobile-web/native-chat-operation-contract' +import { tolerantMobileWebShellPayload } from '../../../../shared/mobile-web/shell-payload-tolerance' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { MOBILE_NATIVE_CHAT_MAX_WINDOW, windowForClient } from './native-chat-rpc-message-sanitizer' + +// The page parses the shell-relayed read with the tolerant rewrite, never the raw strict schema. +const pageContract = tolerantMobileWebShellPayload(MobileWebNativeChatReadResultSchema) +const ESC = String.fromCharCode(27) + +function asPage(messages: unknown[]) { + return pageContract.safeParse({ messages, hasMore: false }) +} + +function sanitized(messages: unknown[]) { + return windowForClient(messages as NativeChatMessage[], 'mobile') +} + +function message(id: string, blocks: unknown[]) { + return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' } +} + +describe('native chat sanitizer against the page contract', () => { + it('keeps an adversarial transcript parseable and strips what the page cannot name', () => { + const parsed = asPage( + sanitized([ + message('turn-1', [ + { + type: 'text', + text: `${ESC}]0;title${String.fromCharCode(7)}plain`, + providerFrame: { + provider: 'claude', + kind: 'raw', + payload: { head: 'h', byteLength: 4, digest: 'd', truncated: false } + } + }, + { type: 'diagram', nodes: [1, 2, 3] }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'running' }, + { type: 'tool-call', name: 'Read', input: {}, state: 'queued' }, + { type: 'tool-call', name: '', input: {} }, + { + type: 'tool-result', + output: 'o'.repeat(5000), + isError: true, + editPatch: { filePath: 'a.ts', hunks: [] } + }, + { type: 'image-ref', path: 'p'.repeat(9000), url: 'data:image/png;base64,AAA', alt: 'ok' } + ]) + ]) + ) + + expect(parsed.success).toBe(true) + expect(parsed.data?.messages[0]?.blocks).toEqual([ + { type: 'text', text: `${ESC}]0;title${String.fromCharCode(7)}plain` }, + { type: 'tool-call', name: 'Bash', input: { command: 'ls' }, state: 'running' }, + { type: 'tool-call', name: 'Read', input: {} }, + { + type: 'tool-result', + output: `${'o'.repeat(4000)}\n… (truncated)`, + isError: true + }, + { type: 'image-ref', alt: 'ok' } + ]) + }) + + it('keeps every tool-call lifecycle state the page names', () => { + const states = ['running', 'completed', 'failed'] as const + const parsed = asPage( + sanitized([ + message( + 'turn-2', + states.map((state) => ({ type: 'tool-call', name: 'Bash', input: {}, state })) + ) + ]) + ) + + expect(parsed.success).toBe(true) + expect( + parsed.data?.messages[0]?.blocks.map((block) => ('state' in block ? block.state : null)) + ).toEqual([...states]) + }) + + it('bounds a hostile tool-call input inside the page block ceiling', () => { + const deep = { a: { b: { c: { d: { e: { f: 'too deep' } } } } } } + const wide = Object.fromEntries( + Array.from({ length: 200 }, (_, index) => [`k${index}`, 'v'.repeat(200)]) + ) + const parsed = asPage( + sanitized([message('turn-3', [{ type: 'tool-call', name: 'Bash', input: { deep, wide } }])]) + ) + + expect(parsed.success).toBe(true) + const block = parsed.data?.messages[0]?.blocks[0] + expect(JSON.stringify(block).length).toBeLessThan( + MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS * 2 + ) + }) + + it('never returns more messages than the page read limit accepts', () => { + const messages = Array.from({ length: MOBILE_WEB_NATIVE_CHAT_READ_LIMIT + 500 }, (_, index) => + message(`turn-${index}`, [{ type: 'text', text: 'hi' }]) + ) + + expect(MOBILE_NATIVE_CHAT_MAX_WINDOW).toBe(MOBILE_WEB_NATIVE_CHAT_READ_LIMIT) + const parsed = asPage( + windowForClient(messages as NativeChatMessage[], 'mobile', messages.length) + ) + expect(parsed.success).toBe(true) + expect(parsed.data?.messages).toHaveLength(MOBILE_WEB_NATIVE_CHAT_READ_LIMIT) + }) + + // The three gaps below are unfixed: the sanitizer's mobile caps are the released native app's, + // and nothing between it and the page re-bounds them to the page contract. + it('gap: a text block over the page ceiling reaches the page as a dropped block', () => { + const text = 'a'.repeat(MOBILE_WEB_NATIVE_CHAT_BLOCK_TEXT_MAX_CHARACTERS + 1) + const parsed = asPage(sanitized([message('turn-4', [{ type: 'text', text }])])) + + expect(parsed.success).toBe(true) + expect(parsed.data?.messages[0]?.blocks).toEqual([]) + }) + + it('gap: a turn over the page block limit fails the whole read', () => { + const blocks = Array.from({ length: MOBILE_WEB_NATIVE_CHAT_MESSAGE_BLOCK_LIMIT + 1 }, () => ({ + type: 'text', + text: 'hi' + })) + const parsed = asPage(sanitized([message('turn-5', blocks)])) + + expect(parsed.success).toBe(false) + }) + + it('gap: a message id over the page ceiling fails the whole read', () => { + const id = 'x'.repeat(MOBILE_WEB_NATIVE_CHAT_MESSAGE_ID_MAX_CHARACTERS + 1) + const parsed = asPage(sanitized([message(id, [{ type: 'text', text: 'hi' }])])) + + expect(parsed.success).toBe(false) + }) +}) diff --git a/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts b/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts new file mode 100644 index 00000000000..d29f3f3f75a --- /dev/null +++ b/src/mobile-web/src/mobile-web-source-control-host-subscription.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MobileWebBridgeClientError } from './mobile-web-bridge-client-error' +import type { MobileWebBridgeSubscriptionClient } from './mobile-web-bridge-subscription-client' +import { subscribeHostSourceControl } from './mobile-web-source-control-host-subscription' +import type { MobileWebSourceControlStatusInvalidation } from '../../shared/mobile-web/source-control-operation-contract' + +const PAYLOAD = { workspaceId: 'page-workspace' } + +function harness() { + const unsubscribe = vi.fn() + const events: MobileWebSourceControlStatusInvalidation[] = [] + const errors: { code: string; retryable: boolean }[] = [] + let deliver: ((event: unknown) => void) | undefined + let fail: ((error: MobileWebBridgeClientError) => void) | undefined + const subscribeHost = vi.fn((_payload, onHostEvent, onHostError) => { + deliver = onHostEvent + fail = onHostError + return { ready: Promise.resolve(), unsubscribe } + }) + const subscription = subscribeHostSourceControl( + { subscribeHost } as unknown as MobileWebBridgeSubscriptionClient, + PAYLOAD, + (event) => events.push(event), + (error) => errors.push({ code: error.code, retryable: error.retryable }) + ) + return { + errors, + events, + subscribeHost, + subscription, + unsubscribe, + deliver: (event: unknown) => deliver?.(event), + fail: (error: MobileWebBridgeClientError) => fail?.(error) + } +} + +describe('host-projected Source Control subscription', () => { + it('subscribes over the generic host watch without naming the host workspace', async () => { + const h = harness() + await h.subscription.ready + + expect(h.subscribeHost).toHaveBeenCalledWith( + { method: 'mobileWeb.files.watch', workspaceId: 'page-workspace', params: {} }, + expect.any(Function), + expect.any(Function) + ) + }) + + it('reports a watcher failure once as retryable and delivers no invalidation', () => { + const h = harness() + + h.deliver({ type: 'error', message: 'watch failed', rootPath: '/private/repo' }) + + expect(h.errors).toEqual([{ code: 'unavailable', retryable: true }]) + expect(h.events).toEqual([]) + expect(JSON.stringify(h.errors)).not.toContain('/private/repo') + }) + + it('retires the host subscription and stops reporting once the page unsubscribes', () => { + const h = harness() + + h.deliver({ type: 'error', message: 'watch failed' }) + h.subscription.unsubscribe() + h.deliver({ type: 'end' }) + h.deliver({ type: 'changed', events: [] }) + h.fail({ code: 'unavailable', retryable: true } as MobileWebBridgeClientError) + + expect(h.unsubscribe).toHaveBeenCalledOnce() + expect(h.errors).toEqual([{ code: 'unavailable', retryable: true }]) + expect(h.events).toEqual([]) + }) + + it('keeps a normal end-of-watch retryable so the page subscription is not closed', () => { + const h = harness() + + h.deliver({ type: 'end' }) + + expect(h.errors).toEqual([{ code: 'unavailable', retryable: true }]) + expect(h.unsubscribe).not.toHaveBeenCalled() + }) + + it('rejects a malformed payload before it reaches the host', async () => { + const errors: { code: string; retryable: boolean }[] = [] + const subscribeHost = vi.fn() + const subscription = subscribeHostSourceControl( + { subscribeHost } as unknown as MobileWebBridgeSubscriptionClient, + { workspaceId: '' }, + vi.fn(), + (error) => errors.push({ code: error.code, retryable: error.retryable }) + ) + + await expect(subscription.ready).rejects.toMatchObject({ code: 'invalid_request' }) + await vi.waitFor(() => expect(errors).toEqual([{ code: 'invalid_request', retryable: false }])) + expect(subscribeHost).not.toHaveBeenCalled() + }) + + it('reports a changed batch the watcher could not bound as an overflow invalidation', () => { + const h = harness() + + h.deliver({ type: 'changed', events: [{ kind: 'overflow', absolutePath: '/private/repo' }] }) + h.deliver({ type: 'changed', events: [{ kind: 'update', absolutePath: '/private/repo/a.ts' }] }) + + expect(h.events).toEqual([ + { workspaceId: 'page-workspace', reason: 'overflow' }, + { workspaceId: 'page-workspace', reason: 'changed' } + ]) + expect(JSON.stringify(h.events)).not.toContain('/private/repo') + }) + + it('fails an unreadable changed frame instead of publishing an empty invalidation', () => { + const h = harness() + + h.deliver({ type: 'changed' }) + + expect(h.errors).toEqual([{ code: 'invalid_message', retryable: false }]) + expect(h.events).toEqual([]) + }) +})