diff --git a/config/scripts/ci/sharding/ci-shard-timings.json b/config/scripts/ci/sharding/ci-shard-timings.json index d993d09a724..93d4efb48b3 100644 --- a/config/scripts/ci/sharding/ci-shard-timings.json +++ b/config/scripts/ci/sharding/ci-shard-timings.json @@ -902,9 +902,7 @@ "src/main/browser/cdp/commands/cdp-bridge-integration.test.ts": 28361, "src/main/browser/cdp/commands/cdp-print-to-pdf.test.ts": 68, "src/main/browser/cdp/commands/cdp-screenshot.test.ts": 415, - "src/main/browser/cdp/transport/cdp-ws-proxy-first-paint.test.ts": 366, "src/main/browser/cdp/transport/cdp-ws-proxy-host-admission.test.ts": 576, - "src/main/browser/cdp/transport/cdp-ws-proxy-input-focus.test.ts": 360, "src/main/browser/cdp/transport/cdp-ws-proxy-origin.test.ts": 262, "src/main/browser/cdp/transport/cdp-ws-proxy-startup-lifetime.test.ts": 326, "src/main/browser/cdp/transport/cdp-ws-proxy.test.ts": 2756, diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts deleted file mode 100644 index 1db30d2be50..00000000000 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-drafts-glued-pending.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createElement } from 'react' -import { act, create } from 'react-test-renderer' -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types' - -import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import { - closeMobileNativeChatTabDraft, - useMobileNativeChatDraft -} from './mobile-native-chat-draft-store' - -import { loadMobileNativeChatDrafts } from './mobile-native-chat-draft-persistence' - -vi.mock('@react-native-async-storage/async-storage', async () => { - const { createInMemoryAsyncStorage } = - await import('../../../test-support/in-memory-async-storage') - return { default: createInMemoryAsyncStorage() } -}) - -type DraftState = ReturnType & { composerText: string } -type TestRenderer = { - unmount(): void - update(element: ReturnType): void -} - -function assistantTurn(id: string, text: string, timestamp: number): NativeChatMessage { - return { - id, - role: 'assistant', - blocks: [{ type: 'text', text }], - timestamp, - source: 'transcript' - } -} - -function imageTurn(id: string, path: string, timestamp: number): NativeChatMessage { - return { - id, - role: 'user', - blocks: [{ type: 'text', text: `[Image: source: ${path}]` }], - timestamp, - source: 'transcript' - } -} - -describe('useMobileNativeChatDrafts glued pending sends', () => { - let renderer: TestRenderer | null = null - let state: DraftState | null = null - - afterEach(async () => { - act(() => renderer?.unmount()) - await loadMobileNativeChatDrafts('host', 'worktree') - closeMobileNativeChatTabDraft('host\0worktree\0tab') - await loadMobileNativeChatDrafts('host', 'worktree') - renderer = null - state = null - }) - - function Harness({ - messages, - transcriptLoading = false, - transcriptSettled = !transcriptLoading - }: { - messages: NativeChatMessage[] - transcriptLoading?: boolean - transcriptSettled?: boolean - }): null { - const drafts = useMobileNativeChatDrafts({ - hostId: 'host', - worktreeId: 'worktree', - tabId: 'tab', - sessionId: 'session', - messages, - transcriptLoading, - transcriptSettled - }) - state = { ...drafts, composerText: useMobileNativeChatDraft(drafts.draftKey) } - return null - } - - async function mount( - messages: NativeChatMessage[], - transcriptLoading = false, - transcriptSettled = !transcriptLoading - ): Promise { - await act(async () => { - renderer = create(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) - }) - } - - async function update( - messages: NativeChatMessage[], - transcriptLoading = false, - transcriptSettled = !transcriptLoading - ): Promise { - await act(async () => - renderer?.update(createElement(Harness, { messages, transcriptLoading, transcriptSettled })) - ) - } - - // Between the older history rows and the rows that land after a send, on the host's clock. - const SENT_AT = 3000 - - // Hydrating rows give no boundary, so these sends match only rows newer than their send time. - describe('sends issued while the transcript is still hydrating', () => { - // An image retires only by preview binding; excluding its own echo would strand both the photo and later glued sends. - it('retires a photo whose image echo arrives with the read', async () => { - await mount([], true) - act(() => { - const origin = state?.captureSendOrigin('', SENT_AT) - if (origin) { - state?.acceptSend(origin, '', ['file:///new.png']) - } - }) - - const withEcho = [assistantTurn('m1', 'ready', 1000), imageTurn('m2', '/tmp/new.png', 5000)] - await update(withEcho) - expect(state?.pending).toEqual([]) - expect(state?.imagePreviewsByMessageId).toEqual({ m2: ['file:///new.png'] }) - }) - }) - - // Failed-read emptiness is unknown history, not an empty conversation; old rows must not claim sends against it. -}) diff --git a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts deleted file mode 100644 index 79badaa3ec9..00000000000 --- a/mobile/src/session/native-chat/composer/use-mobile-native-chat-image-attachments.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { closeMobileNativeChatEditInputs } from './mobile-native-chat-draft-inputs' -import { createElement } from 'react' -import { act, create, type ReactTestRenderer } from 'react-test-renderer' -import '../../../../../src/shared/agent-launch/startup/agent-tui-input-clear' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../../../transport/rpc/rpc-client-contract' -import type { RpcResponse, RpcSuccess } from '../../../transport/rpc/rpc-wire-types' -import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' -import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' -import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' - -// Stub the media seam so direct Expo imports would expose unsupported Flow syntax and fail the transform. -const pick = vi.hoisted(() => vi.fn()) -const ids = vi.hoisted(() => ({ next: 0 })) -vi.mock('../../../../../src/shared/non-secure-context-uuid', () => ({ - createNonSecureContextUuid: () => `img-${++ids.next}` -})) -vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => ({ pickImages: pick }) })) -// The canvas fit needs a WebView; this test is about the attaching counter. -vi.mock('../../../files/attachments/mobile-agent-image-fit', () => ({ - fitMobileAgentImage: async (image: unknown) => image -})) - -function ok(id: string, result: unknown): RpcSuccess { - return { id, ok: true, result, _meta: { runtimeId: 'r' } } -} -function methodNotFound(id: string): RpcResponse { - return { - id, - ok: false, - error: { code: 'method_not_found', message: 'no' }, - _meta: { runtimeId: 'r' } - } -} - -function makeClient(responses: (RpcResponse | Promise)[]): Pick< - RpcClient, - 'sendRequest' -> & { - calls: { method: string; params: Record }[] -} { - const calls: { method: string; params: Record }[] = [] - return { - calls, - sendRequest: vi.fn(async (method: string, params?: unknown) => { - if (method === 'terminal.read') { - return ok('read', {}) - } - calls.push({ method, params: params as Record }) - const response = responses.shift() - if (!response) { - throw new Error(`unexpected request: ${method}`) - } - return response - }) - } -} - -type HookArgs = Parameters[0] -type Hook = ReturnType - -const SCOPE_A = 'h\0w\0tab-a' - -function baseArgs(overrides: Partial & Pick): HookArgs { - return { - agent: 'claude', - activeHandleRef: { current: 'term-1' }, - deviceTokenRef: { current: null }, - getActiveWorktreeConnectionId: async () => null, - connState: 'connected', - scopeKey: SCOPE_A, - enabled: true, - structuredNativeChat: false, - showToast: vi.fn(), - onSendError: vi.fn(), - baseSend: vi.fn().mockResolvedValue('accepted'), - readSeededLaunchDraft: () => null, - sleep: async () => {}, - ...overrides - } -} - -describe('useMobileNativeChatImageAttachments', () => { - let renderer: ReactTestRenderer | null = null - let hook: Hook | null = null - let releaseConnection: ((id: string | null) => void) | null = null - let firstAttach: Promise | null = null - - function Harness({ args }: { args: HookArgs }): null { - hook = useMobileNativeChatImageAttachments(args) - return null - } - - beforeEach(() => { - ids.next = 0 - pick.mockReset() - resetMobileNativeChatStaleInputForTests() - resetMobileNativeChatTerminalWritesForTests() - }) - afterEach(async () => { - act(() => renderer?.unmount()) - closeMobileNativeChatEditInputs(SCOPE_A) - await act(async () => { - releaseConnection?.(null) - await firstAttach - }) - releaseConnection = null - firstAttach = null - renderer = null - hook = null - }) - - function mount(args: HookArgs): void { - act(() => { - renderer = create(createElement(Harness, { args })) - }) - } - - it('keeps isAttaching true when a cancelled pick overlaps a genuine in-flight upload', async () => { - // Hold a real upload after its counter increment; a concurrent cancelled pick must not decrement work it never started. - const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) - const args = baseArgs({ - client: client as unknown as RpcClient, - getActiveWorktreeConnectionId: () => - new Promise((resolve) => { - releaseConnection = resolve - }) - }) - mount(args) - - pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) - await act(async () => { - firstAttach = hook!.attachImage('library') - for (let i = 0; i < 50 && !releaseConnection; i++) { - await Promise.resolve() - } - }) - expect(releaseConnection).not.toBeNull() - expect(hook!.isAttaching).toBe(true) - - pick.mockResolvedValue([]) - await act(async () => { - await hook!.attachImage('library') - }) - expect(hook!.isAttaching).toBe(true) - - await act(async () => { - releaseConnection!('conn-1') - await firstAttach - }) - expect(hook!.isAttaching).toBe(false) - expect(hook!.attachments).toHaveLength(1) - }) -}) diff --git a/src/main/browser/cdp/transport/cdp-ws-proxy-first-paint.test.ts b/src/main/browser/cdp/transport/cdp-ws-proxy-first-paint.test.ts deleted file mode 100644 index 5644db0ed29..00000000000 --- a/src/main/browser/cdp/transport/cdp-ws-proxy-first-paint.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { CdpWsProxy } from './cdp-ws-proxy' -import { - connect, - createMockWebContents, - getSendCommandMethods, - mockAsWebContents, - sendAndReceive -} from './cdp-ws-proxy-test-harness' - -vi.mock('electron', () => ({ - webContents: { fromId: vi.fn() } -})) - -describe('CdpWsProxy first-paint gate', () => { - it('lets an unpainted page paint before input reaches it, once per document', async () => { - const guest = createMockWebContents() - guest.webContents.getType.mockReturnValue('webview') - let paintFrame = (): void => {} - // A blank document: frames run, but no paint entry is ever recorded. - guest.webContents.debugger.sendCommand.mockImplementation( - async (method?: string, params?: Record) => { - if (method !== 'Runtime.evaluate') { - return {} - } - if (params?.returnByValue) { - return { result: { value: [false, 1234.5] } } - } - await new Promise((resolve) => { - paintFrame = resolve - }) - return {} - } - ) - const releasePaint = vi.fn() - const holdPaint = vi.fn(() => releasePaint) - const guestProxy = new CdpWsProxy(mockAsWebContents(guest), holdPaint) - const client = await connect(await guestProxy.start()) - const move = (id: number): Promise> => - sendAndReceive(client, { - id, - method: 'Input.dispatchMouseEvent', - params: { type: 'mouseMoved', x: 1, y: 1 } - }) - - const first = move(33) - await vi.waitFor(() => expect(holdPaint).toHaveBeenCalledTimes(1)) - expect(getSendCommandMethods(guest)).not.toContain('Input.dispatchMouseEvent') - paintFrame() - await first - await move(34) - - expect(getSendCommandMethods(guest).filter((m) => m.startsWith('Input.'))).toHaveLength(2) - expect(holdPaint).toHaveBeenCalledTimes(1) - expect(releasePaint).toHaveBeenCalledTimes(1) - client.close() - await guestProxy.stop() - }) -}) diff --git a/src/main/browser/cdp/transport/cdp-ws-proxy-input-focus.test.ts b/src/main/browser/cdp/transport/cdp-ws-proxy-input-focus.test.ts deleted file mode 100644 index a1db6a0d123..00000000000 --- a/src/main/browser/cdp/transport/cdp-ws-proxy-input-focus.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect, vi } from 'vitest' -import { CdpWsProxy } from './cdp-ws-proxy' -import { - connect, - createMockWebContents, - getSendCommandMethods, - mockAsWebContents, - sendAndReceive -} from './cdp-ws-proxy-test-harness' - -vi.mock('electron', () => ({ - webContents: { fromId: vi.fn() } -})) - -describe('CdpWsProxy agent input focus guard', () => { - it('refuses a press when the user’s focus cannot be guarded', async () => { - const mock = createMockWebContents() - mock.webContents.getType.mockReturnValue('webview') - const proxy = new CdpWsProxy( - mockAsWebContents(mock), - () => () => {}, - async () => { - throw new Error('focus guard unavailable') - } - ) - const client = await connect(await proxy.start()) - - const response = await sendAndReceive(client, { - id: 81, - method: 'Input.dispatchMouseEvent', - params: { type: 'mousePressed', x: 1, y: 1 } - }) - - expect(response.error).toMatchObject({ message: 'focus guard unavailable' }) - expect(getSendCommandMethods(mock)).not.toContain('Input.dispatchMouseEvent') - client.close() - await proxy.stop() - }) -}) diff --git a/src/main/browser/cdp/transport/cdp-ws-proxy-startup-lifetime.test.ts b/src/main/browser/cdp/transport/cdp-ws-proxy-startup-lifetime.test.ts index dfb9f7a18af..9e38e6c5e22 100644 --- a/src/main/browser/cdp/transport/cdp-ws-proxy-startup-lifetime.test.ts +++ b/src/main/browser/cdp/transport/cdp-ws-proxy-startup-lifetime.test.ts @@ -1,7 +1,6 @@ import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CdpWsProxy } from './cdp-ws-proxy' -import { acquireElectronDebugger } from '../debugger/electron-debugger-lease' import { createMockWebContents, mockAsWebContents } from './cdp-ws-proxy-test-harness' const { createServerMock, webSocketServerMock } = vi.hoisted(() => ({ @@ -53,15 +52,6 @@ function pendingEnable(mock: ReturnType) { return { finish, fail } } -function invalidateNativeHandle(mock: ReturnType): void { - mock.destroy() - Object.defineProperty(mock.webContents, 'debugger', { - get() { - throw new Error('Object has been destroyed') - } - }) -} - describe('CdpWsProxy startup ownership', () => { let servers: ProxyServer[] let socketServers: ProxyWebSocketServer[] @@ -100,72 +90,6 @@ describe('CdpWsProxy startup ownership', () => { expect(mock.listeners.get('detach') ?? []).toHaveLength(0) } - it('tolerates Page.enable rejection on a live shared attachment', async () => { - const mock = createMockWebContents() - const debuggerHandle = mock.webContents.debugger - const lease = acquireElectronDebugger(mockAsWebContents(mock)) - debuggerHandle.sendCommand.mockRejectedValueOnce(new Error('Page domain is not ready')) - const proxy = makeProxy(mock) - - await expect(proxy.start()).resolves.toMatch(/^ws:\/\/127\.0\.0\.1:9222\//) - await proxy.stop() - - expectNoListeners(mock) - expect(debuggerHandle.isAttached()).toBe(true) - expect(debuggerHandle.detach).not.toHaveBeenCalled() - lease.release() - expect(debuggerHandle.attach).toHaveBeenCalledTimes(1) - expect(debuggerHandle.detach).toHaveBeenCalledTimes(1) - }) - - it.each([false, true])( - 'refuses server admission after native detach during Page.enable (destroyed: %s)', - async (destroyed) => { - const mock = createMockWebContents() - const debuggerHandle = mock.webContents.debugger - const enable = pendingEnable(mock) - const proxy = makeProxy(mock) - const starting = proxy.start() - debuggerHandle.isAttached.mockReturnValue(false) - if (destroyed) { - invalidateNativeHandle(mock) - } - mock.emit('detach', {}, 'target closed') - enable.fail(new Error('target closed while handling command')) - - await expect(starting).rejects.toThrow('detached during startup') - expect(proxy.getPort()).toBe(0) - expect(servers).toHaveLength(0) - expect(socketServers).toHaveLength(0) - expectNoListeners(mock) - } - ) - - it('cleans a destroyed startup even when no native detach event arrives', async () => { - const mock = createMockWebContents() - const enable = pendingEnable(mock) - const proxy = makeProxy(mock) - const starting = proxy.start() - invalidateNativeHandle(mock) - enable.fail(new Error('target closed while handling command')) - - await expect(starting).rejects.toThrow('no longer available') - expect(servers).toHaveLength(0) - expectNoListeners(mock) - }) - - it('refuses server admission when stopped after attachment completes', async () => { - const mock = createMockWebContents() - const proxy = makeProxy(mock) - const starting = proxy.start() - await Promise.resolve() - await proxy.stop() - - await expect(starting).rejects.toThrow('stopped') - expect(servers).toHaveLength(0) - expectNoListeners(mock) - }) - it('does not detach a successor when stopped Page.enable completes late', async () => { const mock = createMockWebContents() const debuggerHandle = mock.webContents.debugger @@ -187,30 +111,4 @@ describe('CdpWsProxy startup ownership', () => { expect(debuggerHandle.detach).toHaveBeenCalledTimes(2) expectNoListeners(mock) }) - - it('retires an established proxy without reading a destroyed native handle', async () => { - const mock = createMockWebContents() - const proxy = makeProxy(mock) - await proxy.start() - invalidateNativeHandle(mock) - - await expect(proxy.stop()).resolves.toBeUndefined() - expectNoListeners(mock) - expect(servers[0].listening).toBe(false) - expect(socketServers[0].closed).toBe(true) - }) - - it('closes server resources even if debugger cleanup throws', async () => { - const mock = createMockWebContents() - const proxy = makeProxy(mock) - await proxy.start() - mock.webContents.debugger.removeListener.mockImplementationOnce(() => { - throw new Error('debugger cleanup failed') - }) - - await expect(proxy.stop()).rejects.toThrow('debugger cleanup failed') - expect(servers[0].listening).toBe(false) - expect(socketServers[0].closed).toBe(true) - expect(mock.webContents.debugger.detach).toHaveBeenCalledTimes(1) - }) })