diff --git a/mobile/src/session/MobileNativeChatAsk.test.tsx b/mobile/src/session/MobileNativeChatAsk.test.tsx index 85dc36d5901..c973e7643a7 100644 --- a/mobile/src/session/MobileNativeChatAsk.test.tsx +++ b/mobile/src/session/MobileNativeChatAsk.test.tsx @@ -30,12 +30,14 @@ const fruit = { let tree: ReactTestRenderer -function render(prompt: AskPrompt, onAnswer = vi.fn(async () => true)): typeof onAnswer { +function render( + prompt: AskPrompt, + onAnswer = vi.fn(async () => true), + onCancel = vi.fn(async () => true) +): typeof onAnswer { vi.useFakeTimers() act(() => { - tree = create( - createElement(MobileNativeChatAsk, { prompt, onAnswer, onCancel: async () => true }) - ) + tree = create(createElement(MobileNativeChatAsk, { prompt, onAnswer, onCancel })) }) return onAnswer } @@ -45,6 +47,15 @@ function press(label: string): void { act(() => row.props.onPress()) } +async function submit(): Promise { + const button = tree.root.find( + (node) => + isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Submit').length > 0 + ) + expect(button.props.disabled).toBe(false) + await act(async () => button.props.onPress()) +} + async function passAutoAdvanceBeat(): Promise { await act(async () => { await vi.advanceTimersByTimeAsync(NATIVE_CHAT_QUESTION_AUTO_ADVANCE_MS) @@ -84,6 +95,12 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { await passAutoAdvanceBeat() expect(onAnswer).not.toHaveBeenCalled() + const input = tree.root.find((node) => isHost(node, 'TextInput')) + act(() => input.props.onChangeText(' custom answer ')) + await passAutoAdvanceBeat() + expect(onAnswer).not.toHaveBeenCalled() + await submit() + expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [], other: 'custom answer' }]) }) it('waits for Submit on a multi-select question', async () => { @@ -93,6 +110,8 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { await passAutoAdvanceBeat() expect(onAnswer).not.toHaveBeenCalled() + await submit() + expect(onAnswer).toHaveBeenCalledExactlyOnceWith([{ indices: [0] }]) }) it('holds its choices while an answer is being delivered', async () => { @@ -113,7 +132,12 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { }) it('sends nothing when the question is cancelled before the pick moves on', async () => { - const onAnswer = render({ questions: [indent] }) + const onCancel = vi.fn(async () => true) + const onAnswer = render( + { questions: [indent] }, + vi.fn(async () => true), + onCancel + ) press('Spaces') const cancel = tree.root.find( @@ -121,6 +145,7 @@ describe('MobileNativeChatAsk single-select auto-advance', () => { isHost(node, 'Pressable') && node.findAll((n) => n.props.children === 'Cancel').length > 0 ) await act(async () => cancel.props.onPress()) + expect(onCancel).toHaveBeenCalledExactlyOnceWith() await passAutoAdvanceBeat() expect(onAnswer).not.toHaveBeenCalled() diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 990e39d606b..00cc3728880 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -40,6 +40,7 @@ type Tick = { structured?: boolean activeTabId?: string | null setTabView?: (tabId: string, view: 'terminal') => void + attachImage?: (source: 'library' | 'camera') => Promise } function overlayElement(tick: Tick): ReturnType { @@ -77,7 +78,14 @@ function overlayElement(tick: Tick): ReturnType { return createElement(MobileNativeChatOverlay, { controller, activeSessionTabId: tick.activeTabId, - images: {} as never, + onOpenFile: vi.fn(), + images: { + attachments: [], + isAttaching: false, + attachImage: tick.attachImage ?? vi.fn(async () => {}), + removeAttachment: vi.fn(), + sendNativeChat: vi.fn(async () => true) + }, onMicPress: vi.fn(), micActive: false, dictationMode: 'toggle', @@ -277,10 +285,17 @@ describe('MobileNativeChatOverlay attach button', () => { ) it.each([true, false])('offers attach only when the agent takes images (%s)', async (accepts) => { + const attachImage = vi.fn(async () => {}) await act(async () => { - renderer = create(overlayElement({ acceptsImages: accepts })) + renderer = create(overlayElement({ acceptsImages: accepts, attachImage })) }) const view = renderer!.root.find((node) => node.type === 'ChatView') expect(typeof view.props.onAttachImage === 'function').toBe(accepts) + if (accepts) { + await act(async () => view.props.onAttachImage()) + expect(attachImage).toHaveBeenCalledExactlyOnceWith('library') + } else { + expect(attachImage).not.toHaveBeenCalled() + } }) }) diff --git a/mobile/src/session/mobile-prompt-controller.test.ts b/mobile/src/session/mobile-prompt-controller.test.ts index be14c5e2d33..c488e33856d 100644 --- a/mobile/src/session/mobile-prompt-controller.test.ts +++ b/mobile/src/session/mobile-prompt-controller.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import type { RpcResponse } from '../transport/types' import type { RpcClient } from '../transport/rpc-client' -import { buildAskAnswerKeys, type AskPrompt } from '../../../src/shared/native-chat-ask' +import type { AskPrompt } from '../../../src/shared/native-chat-ask' import { visible, handleRef, @@ -103,7 +103,6 @@ describe('prompt cards through the production controller, send contract and view questions: [{ question: 'Answer?', options: [{ label: 'A' }], multiSelect: false }] } const selections = [{ indices: [], other: 'custom answer' }] - const groups = buildAskAnswerKeys(prompt, selections) vi.mocked(client.sendRequest).mockResolvedValue(response(true, 'legacy')) await render({ tab: { @@ -129,9 +128,9 @@ describe('prompt cards through the production controller, send contract and view .mock.calls.filter(([method]) => method === 'terminal.send') .map(([, params]) => params) expect(writes).toEqual( - groups.map((group) => + ['2', 'custom answer', '\r'].map((text) => expect.objectContaining({ - text: 'raw' in group ? group.raw : group.text, + text, enter: false, requireWriteSettlement: true }) diff --git a/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts b/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts index 6818d8e92f7..29687face62 100644 --- a/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts +++ b/mobile/src/session/mobile-structured-agent-prompts-grouped.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types' import { projectStructuredQuestion, type StructuredQuestionItem @@ -11,6 +10,8 @@ function groupedPrompt(): StructuredQuestionItem { return { itemId: 'item-1', revision: 1, + sequence: 1, + observedAt: 1, body: { kind: 'question', question: '2 grouped questions from Claude', @@ -31,16 +32,16 @@ function groupedPrompt(): StructuredQuestionItem { freeTextQuestionId: 'q2' } ], - resolution: { state: 'pending' } + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } } - } as unknown as AgentJournalRenderItem as StructuredQuestionItem + } } describe('structured question projection for grouped Claude prompts', () => { it('renders an answerable question instead of the empty placeholder card', () => { const projected = projectStructuredQuestion(groupedPrompt()) - expect(projected?.question).not.toBe('2 grouped questions from Claude') + expect(projected?.question).toBe('Which database? (1 of 2)') expect(projected?.options).toEqual(['Postgres']) expect(projected?.optionDescriptions).toEqual(['Durable server']) expect(projected?.optionTokens.filter(Boolean)).toHaveLength(1) diff --git a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts index 2b01616056e..279ae0616ec 100644 --- a/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts +++ b/mobile/src/session/use-mobile-structured-native-chat-send-bridge.test.ts @@ -63,6 +63,7 @@ describe('useMobileStructuredNativeChatSendBridge', () => { await expect(sendWithOutcome('/review')).resolves.toBe(outcome) expect(sendStructured).toHaveBeenCalledWith('/review') + expect(clearDraftForSend).toHaveBeenCalledExactlyOnceWith(ORIGIN, '/review') expect(restoreRejectedDraft).not.toHaveBeenCalled() } ) diff --git a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx index 67e15635aa5..3e78ed2847c 100644 --- a/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx +++ b/mobile/src/session/use-mobile-structured-prompt-responses.test.tsx @@ -7,7 +7,6 @@ import { EMPTY_STRUCTURED_AGENT_SESSION, type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' -import { encodeAgentSessionQuestionAnswers } from '../../../src/shared/agent-session-question-answer' import { formatQuestionFreeTextAnswer } from './mobile-native-chat-question' import { projectStructuredQuestion, @@ -352,7 +351,12 @@ describe('useMobileStructuredPromptResponses', () => { expect(sentFields(sent)).toEqual({ itemId: 'item-g', expectedRevision: 1, - ...(supported ? { answers } : { optionId: encodeAgentSessionQuestionAnswers(answers) }) + ...(supported + ? { answers } + : { + optionId: + 'question-group:[{"questionId":"q1","optionIds":["q1:choice-1"]},{"questionId":"q2","optionIds":["q2:choice-1"]}]' + }) }) }) diff --git a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx index 4c898962b6f..7adc747f603 100644 --- a/mobile/src/session/use-mobile-structured-queued-edit.test.tsx +++ b/mobile/src/session/use-mobile-structured-queued-edit.test.tsx @@ -1,7 +1,10 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionQueuedMessage } from '../../../src/shared/agent-session-wire' +import type { + AgentSessionQueuedMessage, + AgentSessionQueuedMessageEditHoldResult +} from '../../../src/shared/agent-session-wire' import { agentSessionSendBodyFingerprint } from '../../../src/shared/structured-agent-session-send-mutation' import { queuedMessageWithEdit } from '../../../src/shared/queued-message-edit' import type { RpcClient } from '../transport/rpc-client' @@ -27,6 +30,20 @@ function withText(body: AgentSessionQueuedMessage['body'], text: string) { return edited.body } +function editHoldReply(params: unknown): RpcResponse { + const result: AgentSessionQueuedMessageEditHoldResult = + fieldsOf(params).action === 'release' + ? { status: 'released' } + : { status: 'held', fingerprint: 'fp', leaseDurationMs: 120_000, remainingMs: 120_000 } + return ok(result) +} + +function readOrHoldReply(method: string, params: unknown): RpcResponse { + return method === 'agentSession.queuedMessageEditHold' + ? editHoldReply(params) + : ok({ models: [], current: {} }) +} + vi.mock('@react-native-async-storage/async-storage', () => ({ default: { getItem: async () => null, setItem: async () => {}, removeItem: async () => {} } })) @@ -89,12 +106,10 @@ describe('mobile in-place queued Edit', () => { } async function mount(message: AgentSessionQueuedMessage, editSupport: boolean | null = true) { vi.clearAllMocks() - sendRequest.mockImplementation(async (method) => - method === 'agentSession.queuedMessageEditHold' - ? ok({ status: 'held', fingerprint: 'fp', leaseDurationMs: 120_000, remainingMs: 120_000 }) - : method === 'agentSession.queuedMessageUpdate' - ? mutationOk({ status: 'updated', messageId: message.messageId, fingerprint: 'new' }) - : ok({ models: [], current: {} }) + sendRequest.mockImplementation(async (method, params) => + method === 'agentSession.queuedMessageUpdate' + ? mutationOk({ status: 'updated', messageId: message.messageId, fingerprint: 'new' }) + : readOrHoldReply(method, params) ) await act(async () => { renderer = create(createElement(Harness, { editSupport })) @@ -125,7 +140,7 @@ describe('mobile in-place queued Edit', () => { expect(hook!.queued.edit).toEqual(expect.any(Function)) }) - it('holds and renews a card while typing, saves its text without replacing images, and cancels in place', async () => { + it('holds and renews a card while typing, sends only its edited text, and cancels in place', async () => { const message = queuedDraft({ messageId: 'image-card', position: 7, @@ -169,7 +184,6 @@ describe('mobile in-place queued Edit', () => { const body = withText(message.body, 'edited') act(() => listener?.(batchEvent([{ ...message, body }]))) expect(hook!.queued.cards).toMatchObject([{ messageId: 'image-card', text: 'edited' }]) - expect(body.blocks).toContainEqual({ type: 'image-ref', path: '/images/snapshot.png' }) expect(hook!.queued.editor).toBeUndefined() expect(fieldsOf(holdCalls().at(-1)?.[1])).toMatchObject({ action: 'release', @@ -191,9 +205,9 @@ describe('mobile in-place queued Edit', () => { const message = queuedDraft({ messageId: 'draft-1' }) await mount(message) const newer = withText(message.body, 'edited elsewhere') - sendRequest.mockImplementation(async (method) => { + sendRequest.mockImplementation(async (method, params) => { if (method !== 'agentSession.queuedMessageUpdate') { - return ok({ status: 'held' }) + return readOrHoldReply(method, params) } listener?.(batchEvent([{ ...message, body: newer }])) return mutationOk({ status: 'changed', messageId: 'draft-1' }) @@ -209,12 +223,12 @@ describe('mobile in-place queued Edit', () => { expect(hook!.queued.editor).toMatchObject({ messageId: 'draft-1', text: 'edited elsewhere' }) act(() => hook!.queued.editor!.change('saved text')) const body = withText(message.body, 'saved text') - sendRequest.mockImplementation(async (method) => { + sendRequest.mockImplementation(async (method, params) => { if (method === 'agentSession.queuedMessageUpdate') { listener?.(batchEvent([{ ...message, body }])) throw markRpcDeliveryUnknown(new Error('reply lost')) } - return ok({ status: 'held' }) + return readOrHoldReply(method, params) }) await act(async () => { await hook!.queued.editor!.save() @@ -250,12 +264,12 @@ describe('mobile in-place queued Edit', () => { const message = queuedDraft({ messageId: 'deferred-card' }) await mount(message) let answer: ((response: RpcResponse) => void) | undefined - sendRequest.mockImplementation((method) => + sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageUpdate' ? new Promise((resolve) => { answer = resolve }) - : Promise.resolve(ok({ status: 'held' })) + : Promise.resolve(readOrHoldReply(method, params)) ) act(() => hook!.queued.editor!.change('edited in place')) let save: Promise | undefined @@ -286,12 +300,12 @@ describe('mobile in-place queued Edit', () => { nextMessage.messageId ]) let answer: ((response: RpcResponse) => void) | undefined - sendRequest.mockImplementation((method) => + sendRequest.mockImplementation((method, params) => method === 'agentSession.queuedMessageUpdate' ? new Promise((resolve) => { answer = resolve }) - : Promise.resolve(ok({ status: 'held' })) + : Promise.resolve(readOrHoldReply(method, params)) ) act(() => hook!.queued.editor!.change('submitted edit')) let save: Promise | undefined @@ -327,13 +341,13 @@ describe('mobile in-place queued Edit', () => { it('keeps a refused Save in the editor and closes only when the published card is gone', async () => { const message = queuedDraft({ messageId: 'refused-card' }) await mount(message) - sendRequest.mockImplementation(async (method) => + sendRequest.mockImplementation(async (method, params) => method === 'agentSession.queuedMessageUpdate' ? ok({ ok: false, refusal: { code: 'agent_session_operation_invalid', message: 'Could not edit.' } }) - : ok({ status: 'held' }) + : readOrHoldReply(method, params) ) act(() => hook!.queued.editor!.change('keep this text')) await act(async () => { @@ -361,7 +375,9 @@ describe('mobile in-place queued Edit', () => { expect(hook!.queued.editor?.text).toBe(text) const reconnectedRequest = vi.fn(async (method, params) => method === 'agentSession.queuedMessageEditHold' - ? ok({ status: fieldsOf(params).action === 'renew' ? 'expired' : 'held' }) + ? fieldsOf(params).action === 'renew' + ? ok({ status: 'expired' }) + : editHoldReply(params) : ok({ models: [], current: {} }) ) await act(async () => @@ -404,7 +420,7 @@ describe('mobile in-place queued Edit', () => { answerHold = resolve }) : method === 'agentSession.queuedMessageEditHold' - ? Promise.resolve(ok({ status: 'held' })) + ? Promise.resolve(readOrHoldReply(method, params)) : Promise.resolve( mutationOk({ status: 'updated', messageId: message.messageId, fingerprint: 'new' }) ) @@ -418,7 +434,7 @@ describe('mobile in-place queued Edit', () => { }) expect(hook!.queued.editor).toBeUndefined() expect(answerHold).toBeDefined() - await act(async () => answerHold?.(ok({ status: 'held' }))) + await act(async () => answerHold?.(editHoldReply({ action: 'acquire' }))) expect(append).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/use-mobile-structured-stop-press.test.ts b/mobile/src/session/use-mobile-structured-stop-press.test.ts index 927522041b1..51c9cf674eb 100644 --- a/mobile/src/session/use-mobile-structured-stop-press.test.ts +++ b/mobile/src/session/use-mobile-structured-stop-press.test.ts @@ -49,9 +49,12 @@ describe("the phone's Stop press read as Stopping", () => { tracked = press.track(() => new Promise((_, reject) => (fail = reject))) }) + expect(latest?.pressed).toBe(true) + const failure = new Error('socket closed') await act(async () => { - fail(new Error('socket closed')) - await tracked.catch(() => undefined) + const rejected = expect(tracked).rejects.toBe(failure) + fail(failure) + await rejected }) expect(latest?.pressed).toBe(false) diff --git a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts index d41b973f3ab..791179826d4 100644 --- a/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts +++ b/src/main/persistence/loading-store/workspace-session-terminal-binding-replay.test.ts @@ -52,13 +52,21 @@ const bindingRecovery = { } describe('workspace session terminal binding replay', () => { - it('retains a restorable legacy tab binding when neither snapshot has a layout', () => { + it('retains only restorable legacy tab bindings when neither snapshot has a layout', () => { const prior = session('runtime-pty') const incoming = session(null) preserveMissingWorkspaceSessionTerminalBindings(incoming, prior, bindingRecovery as never) expect(incoming.tabsByWorktree.worktree[0]!.ptyId).toBe('runtime-pty') + + const unavailable = session(null) + preserveMissingWorkspaceSessionTerminalBindings(unavailable, prior, { + ...bindingRecovery, + getTerminalLayoutLeafIds: () => new Set(), + isRestorablePtyBinding: () => false + }) + expect(unavailable.tabsByWorktree.worktree[0]!.ptyId).toBeNull() }) it('does not retain a tab binding whose prior leaf was removed from the layout', () => { diff --git a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts index b6a9a9b50be..ee119f7d0e8 100644 --- a/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts +++ b/src/main/persistence/loading-store/worktree-meta-alias-projection.test.ts @@ -272,7 +272,7 @@ describe('worktree meta alias projection', () => { * now-dangling aliases and `migrateLegacyWorktreeMetadata` re-mints the identical identity key * from the `instanceId` the locator row still carries. Nothing is lost at any step. */ - it('loses no row on a build that has no rebuild at all', () => { + it('preserves rows through metadata normalization before current Store reimport', () => { const fixture = buildFixture() const dataFile = tempDataFile() writeFixture(dataFile, fixture.state) diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts index dbecfa265dd..c49b62b50e7 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-action.test.ts @@ -1,27 +1,12 @@ import { describe, expect, it, vi } from 'vitest' import { - classifyTabEntryQuery, - getTabEntryOptions, openTabEntryWithOperations, TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE, - validateNewTabEntryAbsolutePath, - validateNewTabEntryRelativePath, type TabEntryOperations } from './tab-create-entry-action' -import * as classifier from './tab-create-entry-classifier' const readyFiles = (files: string[]) => ({ files, loading: false, loadError: null }) -it('keeps the pure classifier available through the tab action module', () => { - expect(classifyTabEntryQuery).toBe(classifier.classifyTabEntryQuery) - expect(getTabEntryOptions).toBe(classifier.getTabEntryOptions) - expect(TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE).toBe( - classifier.TAB_ENTRY_ABSOLUTE_PATH_REMOTE_BLOCKED_MESSAGE - ) - expect(validateNewTabEntryAbsolutePath).toBe(classifier.validateNewTabEntryAbsolutePath) - expect(validateNewTabEntryRelativePath).toBe(classifier.validateNewTabEntryRelativePath) -}) - describe('openTabEntryWithOperations', () => { function makeOperations(overrides: Partial = {}): TabEntryOperations { return { diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx index 8a837bb41a9..c2b829e9dcc 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.runtime-listing.test.tsx @@ -157,5 +157,13 @@ describe('tab entry options over the real runtime listing', () => { await drainMicrotasks() expect(blockedIds(latestState(states))).not.toContain('loading') + expect(getTabEntryOptions('packages/app/package.json', latestState(states), 4)).toContainEqual({ + id: 'existing-file:packages/app/package.json', + classification: { + kind: 'existing-file', + matchKind: 'exact-path', + relativePath: 'packages/app/package.json' + } + }) }) }) diff --git a/src/shared/clipboard-image.test.ts b/src/shared/clipboard-image.test.ts index 47b8d1978ae..18d6e7b8ed1 100644 --- a/src/shared/clipboard-image.test.ts +++ b/src/shared/clipboard-image.test.ts @@ -28,7 +28,7 @@ describe('clipboard image limits', () => { ).toThrow('Clipboard image is too large') }) - it('rejects invalid or oversized image dimensions before pixel allocation', () => { + it('rejects invalid or oversized image dimensions', () => { expect(() => assertClipboardImageDimensionsWithinLimit({ height: 1, diff --git a/src/shared/clipboard-text.test.ts b/src/shared/clipboard-text.test.ts index 2a321adbcc0..b8f0b707d9d 100644 --- a/src/shared/clipboard-text.test.ts +++ b/src/shared/clipboard-text.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import * as utf8ByteLimits from './utf8-byte-limits' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_TOO_LARGE_ERROR, @@ -52,7 +53,7 @@ describe('clipboard text limits', () => { expect(yieldToEventLoop).toHaveBeenCalled() }) - it('uses the default 256k code-unit yield cadence for accepted large clipboard text', async () => { + it('yields twice across two default clipboard measurement batches', async () => { const yieldToEventLoop = vi.fn(async () => {}) const measurement = await measureClipboardTextByteLengthWithYield( @@ -80,7 +81,7 @@ describe('clipboard text limits', () => { }) it('rejects text whose string length already exceeds the byte limit without scanning', () => { - const codePointAt = vi.spyOn(String.prototype, 'codePointAt') + const measureBytes = vi.spyOn(utf8ByteLimits, 'measureUtf8ByteLength') expect(isClipboardTextByteLengthOverLimit('x'.repeat(6), 5)).toBe(true) expect(() => assertClipboardTextWithinLimit('x'.repeat(6), { maxBytes: 5 })).toThrow( @@ -89,7 +90,7 @@ describe('clipboard text limits', () => { expect(() => assertClipboardTextWriteWithinLimit('x'.repeat(6), { maxBytes: 5 })).toThrow( CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR ) - expect(codePointAt).not.toHaveBeenCalled() + expect(measureBytes).not.toHaveBeenCalled() }) it('lets each clipboard consumer override the shared default byte limits', async () => { @@ -129,7 +130,7 @@ describe('clipboard text limits', () => { ).rejects.toThrow(CLIPBOARD_TEXT_TOO_LARGE_ERROR) await expect( assertClipboardTextWriteWithinLimitWithYield('copied-secret-token-value', { maxBytes: 4 }) - ).rejects.toThrow(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR) + ).rejects.toEqual(new Error(CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR)) try { await assertClipboardTextWithinLimitWithYield('secret-token-value', { maxBytes: 4 })