Skip to content

Commit baa977e

Browse files
committed
Add IPC contract tests for file viewer commands
Pins the wire format for viewer_open, viewer_get_lines, viewer_search_start, viewer_search_poll, viewer_search_cancel, viewer_close. The viewer runs in a separate Tauri window with its own capability file, and the coverage report flagged it as having zero IPC-layer test (9/9 untested). mockIPC bypasses the real permission gate, so this catches serde-shape drift only — not capability config drift, which is a Rust-side concern.
1 parent 3a538b4 commit baa977e

1 file changed

Lines changed: 171 additions & 0 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* IPC contract tests for the file viewer command surface (`viewer_open`,
3+
* `viewer_get_lines`, `viewer_search_start`, `viewer_search_poll`,
4+
* `viewer_search_cancel`, `viewer_close`).
5+
*
6+
* The viewer runs in a **separate Tauri window** (`viewer-*` label) with its own
7+
* capability file (`src-tauri/capabilities/viewer.json`). The coverage report flagged
8+
* this group as entirely untested at the IPC layer (9/9 untested) and noted that
9+
* permission drift in the capability file would be invisible until a user opens the
10+
* viewer. mockIPC can't simulate Tauri's permission gate (the gate is on the Rust
11+
* side; the mock patches `__TAURI_INTERNALS__.invoke` *before* it gets there), but
12+
* we can pin the wire format — that's the contract that drift can break independently
13+
* of the permission system.
14+
*/
15+
16+
import { afterEach, describe, expect, it } from 'vitest'
17+
18+
import { commands } from '$lib/ipc/bindings'
19+
import type { LineChunk, SearchPollResult, ViewerOpenResult } from '$lib/ipc/bindings'
20+
import { clearIpcMocks, installIpcMock } from '$lib/ipc/test-helpers'
21+
22+
afterEach(() => {
23+
clearIpcMocks()
24+
})
25+
26+
const initialLines: LineChunk = {
27+
lines: ['line 0', 'line 1', 'line 2'],
28+
firstLineNumber: 0,
29+
byteOffset: 0,
30+
totalLines: 3,
31+
totalBytes: 21,
32+
}
33+
34+
const openResult: ViewerOpenResult = {
35+
sessionId: 'sess-1',
36+
fileName: 'README.md',
37+
totalBytes: 21,
38+
totalLines: 3,
39+
estimatedTotalLines: 3,
40+
backendType: 'fullLoad',
41+
capabilities: {
42+
supportsLineSeek: true,
43+
supportsByteSeek: false,
44+
supportsFractionSeek: false,
45+
knowsTotalLines: true,
46+
},
47+
initialLines,
48+
isIndexing: false,
49+
}
50+
51+
describe('commands.viewerOpen', () => {
52+
it('invokes viewer_open with the path positional arg', async () => {
53+
const ipc = installIpcMock()
54+
ipc.mock('viewer_open', () => openResult)
55+
56+
const result = await commands.viewerOpen('/path/to/README.md')
57+
58+
expect(result).toEqual({ status: 'ok', data: openResult })
59+
expect(ipc.lastCall('viewer_open')?.payload).toEqual({ path: '/path/to/README.md' })
60+
})
61+
62+
it('surfaces IpcError on the error branch (timedOut: false for non-blocking errors)', async () => {
63+
const ipc = installIpcMock()
64+
ipc.mock('viewer_open', () => {
65+
throw { message: 'File not found', timedOut: false }
66+
})
67+
68+
const result = await commands.viewerOpen('/nope.txt')
69+
70+
expect(result.status).toBe('error')
71+
if (result.status === 'error') {
72+
expect(result.error).toEqual({ message: 'File not found', timedOut: false })
73+
}
74+
})
75+
})
76+
77+
describe('commands.viewerGetLines', () => {
78+
it('forwards sessionId, targetType, targetValue, count as camelCase payload keys', async () => {
79+
const ipc = installIpcMock()
80+
const chunk: LineChunk = {
81+
lines: ['x'],
82+
firstLineNumber: 100,
83+
byteOffset: 500,
84+
totalLines: null,
85+
totalBytes: 1000,
86+
}
87+
ipc.mock('viewer_get_lines', () => chunk)
88+
89+
const targetType = 'byte'
90+
const targetValue = 500
91+
const count = 1
92+
await commands.viewerGetLines('sess-1', targetType, targetValue, count)
93+
94+
expect(ipc.lastCall('viewer_get_lines')?.payload).toEqual({
95+
sessionId: 'sess-1',
96+
targetType,
97+
targetValue,
98+
count,
99+
})
100+
})
101+
})
102+
103+
describe('commands.viewerSearchStart and viewerSearchPoll', () => {
104+
it('search_start sends sessionId + query', async () => {
105+
const ipc = installIpcMock()
106+
ipc.mock('viewer_search_start', () => null)
107+
108+
await commands.viewerSearchStart('sess-2', 'TODO')
109+
110+
expect(ipc.lastCall('viewer_search_start')?.payload).toEqual({
111+
sessionId: 'sess-2',
112+
query: 'TODO',
113+
})
114+
})
115+
116+
it('search_poll delta protocol: sinceIndex on the wire matches the FE-tracked offset', async () => {
117+
const ipc = installIpcMock()
118+
const pollResult: SearchPollResult = {
119+
status: 'running',
120+
newMatches: [{ line: 5, column: 0, length: 4, byteOffset: 80 }],
121+
totalMatchCount: 6,
122+
totalBytes: 1000,
123+
bytesScanned: 800,
124+
matchLimitReached: false,
125+
}
126+
ipc.mock('viewer_search_poll', () => pollResult)
127+
128+
const result = await commands.viewerSearchPoll('sess-2', 5)
129+
130+
expect(result).toEqual({ status: 'ok', data: pollResult })
131+
expect(ipc.lastCall('viewer_search_poll')?.payload).toEqual({
132+
sessionId: 'sess-2',
133+
sinceIndex: 5,
134+
})
135+
})
136+
137+
it('search_cancel takes only sessionId', async () => {
138+
const ipc = installIpcMock()
139+
ipc.mock('viewer_search_cancel', () => null)
140+
141+
await commands.viewerSearchCancel('sess-2')
142+
143+
expect(ipc.lastCall('viewer_search_cancel')?.payload).toEqual({ sessionId: 'sess-2' })
144+
})
145+
})
146+
147+
describe('commands.viewerClose', () => {
148+
it('takes only sessionId and resolves to data: null on success', async () => {
149+
const ipc = installIpcMock()
150+
ipc.mock('viewer_close', () => null)
151+
152+
const result = await commands.viewerClose('sess-3')
153+
154+
expect(result).toEqual({ status: 'ok', data: null })
155+
expect(ipc.lastCall('viewer_close')?.payload).toEqual({ sessionId: 'sess-3' })
156+
})
157+
158+
it('surfaces a string error on the error branch (viewer_close uses Result<_, String>)', async () => {
159+
const ipc = installIpcMock()
160+
ipc.mock('viewer_close', () => {
161+
throw 'session not found'
162+
})
163+
164+
const result = await commands.viewerClose('bogus')
165+
166+
expect(result.status).toBe('error')
167+
if (result.status === 'error') {
168+
expect(result.error).toBe('session not found')
169+
}
170+
})
171+
})

0 commit comments

Comments
 (0)