diff --git a/apps/desktop/electron/find-in-page.test.ts b/apps/desktop/electron/find-in-page.test.ts new file mode 100644 index 000000000000..7a5bd1245786 --- /dev/null +++ b/apps/desktop/electron/find-in-page.test.ts @@ -0,0 +1,239 @@ +/** + * Unit tests for the pure find-in-page helpers. The IPC handlers in + * main.ts are the only consumer — the helpers below must keep the wire + * shape stable (match counter shape, defaults, no-throw-on-destroyed). + */ + +import assert from 'node:assert/strict' +import { EventEmitter } from 'node:events' + +import { describe, test } from 'vitest' + +import { + formatFoundInPage, + installFoundInPageForwarder, + performFind, + stopFind +} from './find-in-page' + +// Minimal webContents stub. The Electron.WebContents type is huge, so we +// model just the slice the helpers touch (`isDestroyed`, `findInPage`, +// `stopFindInPage`, `on`/`off`, `send`, `destroyed`, `emit`) and cast through +// `asWC()` at call sites. +interface FakeWebContents { + calls: { + find: Array<{ query: string; options: { forward: boolean; findNext: boolean } }> + stop: Array<'clearSelection' | 'keepSelection' | 'activateSelection'> + send: Array<{ channel: string; payload: unknown }> + } + isDestroyed: () => boolean + destroy: () => void + findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void + stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void + send: (channel: string, payload: unknown) => void + on: typeof EventEmitter.prototype.on + off: typeof EventEmitter.prototype.off + emit: (event: string | symbol, ...args: unknown[]) => boolean +} + +function makeFakeWebContents(): FakeWebContents { + const emitter = new EventEmitter() + + const calls = { + find: [] as Array<{ query: string; options: { forward: boolean; findNext: boolean } }>, + stop: [] as Array<'clearSelection' | 'keepSelection' | 'activateSelection'>, + send: [] as Array<{ channel: string; payload: unknown }> + } + + let destroyed = false + + return { + calls, + isDestroyed: () => destroyed, + destroy() { + destroyed = true + emitter.emit('destroyed') + }, + findInPage(query: string, options: { forward: boolean; findNext: boolean }) { + calls.find.push({ query, options }) + }, + stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') { + calls.stop.push(action) + }, + send(channel: string, payload: unknown) { + calls.send.push({ channel, payload }) + }, + on: emitter.on.bind(emitter), + off: emitter.off.bind(emitter), + emit: emitter.emit.bind(emitter) + } +} + +function asWC(fake: FakeWebContents): Electron.WebContents { + return fake as unknown as Electron.WebContents +} + +describe('formatFoundInPage', () => { + test('maps activeMatchOrdinal + matches onto the wire payload', () => { + assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 3, matches: 12 }), { + activeMatchOrdinal: 3, + count: 12 + }) + }) + + test('coerces missing fields to zero so the renderer never sees NaN', () => { + assert.deepEqual(formatFoundInPage({}), { activeMatchOrdinal: 0, count: 0 }) + assert.deepEqual(formatFoundInPage({ activeMatchOrdinal: 0, matches: 0 }), { + activeMatchOrdinal: 0, + count: 0 + }) + }) + + test('null / undefined inputs still produce a well-formed payload', () => { + assert.deepEqual( + formatFoundInPage(null as unknown as { activeMatchOrdinal?: number; matches?: number }), + { activeMatchOrdinal: 0, count: 0 } + ) + assert.deepEqual(formatFoundInPage(undefined), { activeMatchOrdinal: 0, count: 0 }) + }) +}) + +describe('performFind', () => { + test('forwards the query and options to webContents.findInPage', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'hello', { forward: true, findNext: false }) + assert.deepEqual(wc.calls.find, [ + { query: 'hello', options: { forward: true, findNext: false } } + ]) + }) + + test('defaults forward to true when omitted', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', { findNext: true }) + assert.deepEqual(wc.calls.find, [ + { query: 'x', options: { forward: true, findNext: true } } + ]) + }) + + test('defaults findNext to false when omitted', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', { forward: false }) + assert.deepEqual(wc.calls.find, [ + { query: 'x', options: { forward: false, findNext: false } } + ]) + }) + + test('treats null / non-object options as "all defaults"', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 'x', null) + assert.deepEqual(wc.calls.find, [ + { query: 'x', options: { forward: true, findNext: false } } + ]) + }) + + test('coerces a non-string query to string (defensive against bad renderer payloads)', () => { + const wc = makeFakeWebContents() + performFind(asWC(wc), 42 as unknown as string, null) + assert.equal(wc.calls.find[0].query, '42') + }) + + test('is a no-op when webContents is null', () => { + assert.doesNotThrow(() => performFind(null, 'q', null)) + }) + + test('is a no-op when webContents is destroyed (does not throw across IPC)', () => { + const wc = makeFakeWebContents() + wc.destroy() + performFind(asWC(wc), 'q', null) + assert.equal(wc.calls.find.length, 0) + }) +}) + +describe('stopFind', () => { + test('calls stopFindInPage with the default action (clearSelection)', () => { + const wc = makeFakeWebContents() + stopFind(asWC(wc)) + assert.deepEqual(wc.calls.stop, ['clearSelection']) + }) + + test('honors an explicit action argument', () => { + const wc = makeFakeWebContents() + stopFind(asWC(wc), 'keepSelection') + assert.deepEqual(wc.calls.stop, ['keepSelection']) + }) + + test('is a no-op when webContents is null or destroyed', () => { + assert.doesNotThrow(() => stopFind(null)) + const wc = makeFakeWebContents() + wc.destroy() + stopFind(asWC(wc)) + assert.equal(wc.calls.stop.length, 0) + }) +}) + +describe('installFoundInPageForwarder', () => { + test('forwards found-in-page to the sender as a formatted payload', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + // Drive the fake's emit directly — this exercises the same code path + // as Electron's actual `webContents.emit('found-in-page', …)`. + wc.emit('found-in-page', {}, { activeMatchOrdinal: 2, matches: 5 }) + assert.deepEqual(wc.calls.send, [ + { channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 2, count: 5 } } + ]) + }) + + test('handles missing fields without throwing', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + wc.emit('found-in-page', {}, {}) + assert.deepEqual(wc.calls.send, [ + { channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 0, count: 0 } } + ]) + }) + + test('skips send when webContents is destroyed at fire time', () => { + const wc = makeFakeWebContents() + installFoundInPageForwarder(asWC(wc)) + wc.destroy() + wc.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 }) + assert.equal(wc.calls.send.length, 0, 'destroyed webContents must not be sent to') + }) + + test('returned uninstall removes the listener', () => { + const wc = makeFakeWebContents() + const uninstall = installFoundInPageForwarder(asWC(wc)) + uninstall() + wc.emit('found-in-page', {}, { activeMatchOrdinal: 9, matches: 9 }) + assert.equal(wc.calls.send.length, 0, 'uninstalled listener must not fire') + }) + + test('returned uninstall on a null webContents is a safe no-op', () => { + const uninstall = installFoundInPageForwarder(null) + assert.doesNotThrow(() => uninstall()) + }) + + test('returned uninstall on a destroyed webContents is a safe no-op', () => { + const wc = makeFakeWebContents() + wc.destroy() + const uninstall = installFoundInPageForwarder(asWC(wc)) + assert.doesNotThrow(() => uninstall()) + }) + + // Regression: the original PR scoped the forwarder to the global mainWindow, + // so Cmd+F pressed in a secondary session window routed results back to the + // primary. Pin that the helper does NOT close over any window other than the + // webContents it was given — two forwarders installed on two distinct fakes + // must each send only to their own sender. + test('two forwarders installed on distinct webContents do not cross-fire', () => { + const wcA = makeFakeWebContents() + const wcB = makeFakeWebContents() + installFoundInPageForwarder(asWC(wcA)) + installFoundInPageForwarder(asWC(wcB)) + wcA.emit('found-in-page', {}, { activeMatchOrdinal: 1, matches: 1 }) + assert.deepEqual(wcA.calls.send, [ + { channel: 'hermes:found-in-page', payload: { activeMatchOrdinal: 1, count: 1 } } + ]) + assert.equal(wcB.calls.send.length, 0, 'wcB must not receive wcA results') + }) +})