mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
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
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user