Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "find-in-page-native-fixture",
"private": true,
"type": "module",
"main": "../find-in-page-native.test.mjs"
}
104 changes: 104 additions & 0 deletions apps/desktop/electron/find-in-page-native.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict'
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { app, BrowserWindow } from 'electron'

const runtimeDir = mkdtempSync(join(tmpdir(), 'hermes-find-in-page-'))
app.setPath('userData', runtimeDir)
app.setPath('sessionData', runtimeDir)

async function findCount(window, query, afterFirstResult) {
return await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`findInPage timed out for ${query}`)), 5000)
let requestId
let firstResult = true

const onResult = (_event, result) => {
if (result.requestId !== requestId) return
if (firstResult) {
firstResult = false
afterFirstResult?.()
}
if (!result.finalUpdate) return
clearTimeout(timeout)
window.webContents.off('found-in-page', onResult)
resolve(result.matches)
}

window.webContents.on('found-in-page', onResult)
requestId = window.webContents.findInPage(query)
})
}

async function run() {
const window = new BrowserWindow({
show: true,
x: 0,
y: 0,
width: 320,
height: 240,
skipTaskbar: true,
opacity: 0,
webPreferences: { backgroundThrottling: false }
})

try {
const html = ['<input id="query" type="search" aria-label="Find in page" value="needle">', '<p>needle</p>'].join('')
const fixturePath = join(runtimeDir, 'fixture.html')
writeFileSync(fixturePath, html)
await window.loadFile(fixturePath)
await window.webContents.executeJavaScript('document.body.innerText')

assert.equal(await findCount(window, 'needle'), 2, 'control: search input is indexed')
window.webContents.stopFindInPage('clearSelection')

await window.webContents.executeJavaScript('query.focus(); query.setSelectionRange(6, 6); query.inert = true')
const count = await findCount(window, 'needle', () => {
void window.webContents.executeJavaScript('query.inert = false; query.focus(); query.setSelectionRange(6, 6)')
})

assert.equal(count, 1, 'transient inert excludes the visible query from Chromium indexing')

const state = await window.webContents.executeJavaScript(`JSON.stringify({
type: query.type,
explicitRole: query.getAttribute('role'),
inert: query.inert,
focused: document.activeElement === query,
selectionStart: query.selectionStart,
value: query.value
})`)
assert.deepEqual(JSON.parse(state), {
type: 'search',
explicitRole: null,
inert: false,
focused: true,
selectionStart: 6,
value: 'needle'
})

window.webContents.debugger.attach('1.3')
try {
await window.webContents.debugger.sendCommand('Accessibility.enable')
const { nodes } = await window.webContents.debugger.sendCommand('Accessibility.getFullAXTree')
assert.ok(
nodes.some(node => node.role?.value === 'searchbox' && node.name?.value === 'Find in page'),
'Chromium accessibility tree exposes a truthful searchbox'
)
} finally {
window.webContents.debugger.detach()
}
} finally {
window.destroy()
}
}

app
.whenReady()
.then(run)
.then(() => app.exit(0))
.catch(error => {
console.error(error)
app.exit(1)
})
48 changes: 46 additions & 2 deletions apps/desktop/electron/find-in-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
import type { BrowserWindow } from 'electron'
import { describe, test } from 'vitest'

import { formatFoundInPage, installFindShortcut, installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
import {
formatFoundInPage,
installFindShortcut,
installFoundInPageForwarder,
performFind,
performFindAfterIndexingStarted,
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`,
Expand All @@ -24,10 +31,11 @@
}
isDestroyed: () => boolean
destroy: () => void
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => void
findInPage: (query: string, options: { forward: boolean; findNext: boolean }) => number
stopFindInPage: (action: 'clearSelection' | 'keepSelection' | 'activateSelection') => void
send: (channel: string, payload: unknown) => void
on: typeof EventEmitter.prototype.on
once: typeof EventEmitter.prototype.once
off: typeof EventEmitter.prototype.off
emit: (event: string | symbol, ...args: unknown[]) => boolean
}
Expand All @@ -52,6 +60,8 @@
},
findInPage(query: string, options: { forward: boolean; findNext: boolean }) {
calls.find.push({ query, options })

return 17
},
stopFindInPage(action: 'clearSelection' | 'keepSelection' | 'activateSelection') {
calls.stop.push(action)
Expand All @@ -60,6 +70,7 @@
calls.send.push({ channel, payload })
},
on: emitter.on.bind(emitter),
once: emitter.once.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter)
}
Expand Down Expand Up @@ -137,6 +148,39 @@
})
})

describe('performFindAfterIndexingStarted', () => {
test('resolves only after the matching request emits its first result', async () => {
const wc = makeFakeWebContents()
let resolved = false

const pending = performFindAfterIndexingStarted(asWC(wc), 'needle', {
forward: true,
findNext: false
}).then(() => {
resolved = true
})

await Promise.resolve()
assert.equal(resolved, false)

wc.emit('found-in-page', {}, { requestId: 9, matches: 1 })
await Promise.resolve()
assert.equal(resolved, false)

wc.emit('found-in-page', {}, { requestId: 17, matches: 1 })
await pending
assert.equal(resolved, true)
})

test('resolves safely if the webContents is destroyed before a result', async () => {
const wc = makeFakeWebContents()
const pending = performFindAfterIndexingStarted(asWC(wc), 'needle', null)

wc.destroy()
await pending
})
})

describe('stopFind', () => {
test('calls stopFindInPage with the default action (clearSelection)', () => {
const wc = makeFakeWebContents()
Expand Down Expand Up @@ -244,7 +288,7 @@
})
// The listener calls preventDefault on the event; the fake's emit returns
// truthy because the event fired — what matters is the side effects.
void result

Check warning on line 291 in apps/desktop/electron/find-in-page.test.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement

assert.deepEqual(wc.calls.send, [
{ channel: 'hermes:open-find-bar', payload: undefined }
Expand Down
40 changes: 40 additions & 0 deletions apps/desktop/electron/find-in-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@
})
}

/**
* Start a find request and resolve after Chromium emits its first matching
* result. This acknowledgment lets the renderer remove a temporary `inert`
* boundary only after the query field has been excluded from the index.
*/
export function performFindAfterIndexingStarted(
webContents: Electron.WebContents | null | undefined,
query: string,
options: FindInPageOptions | null | undefined
): Promise<void> {
if (!webContents || webContents.isDestroyed()) {
return Promise.resolve()
}

return new Promise(resolve => {
let requestId: number | undefined

const finish = () => {
webContents.off('found-in-page', onFound)
webContents.off('destroyed', finish)
resolve()
}

const onFound = (_event: Electron.Event, result: { requestId?: number }) => {
if (requestId !== undefined && result?.requestId === requestId) {
finish()
}
}

webContents.on('found-in-page', onFound)
webContents.once('destroyed', finish)

const opts = options && typeof options === 'object' ? options : {}
requestId = webContents.findInPage(String(query ?? ''), {
forward: opts.forward !== false,
findNext: Boolean(opts.findNext)
})
})
}

/**
* Stop the current find and clear highlights. The default `action` matches
* what the renderer sends on Escape / close.
Expand Down Expand Up @@ -150,7 +190,7 @@

export function installFindShortcut(window: Electron.BrowserWindow, isMac: () => boolean = IS_MAC): () => void {
const { webContents } = window
if (!webContents || webContents.isDestroyed()) {

Check warning on line 193 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
return () => {}
}

Expand All @@ -158,25 +198,25 @@
if (!webContents || webContents.isDestroyed()) {
return
}
const key = String(input.key || '').toLowerCase()

Check warning on line 201 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
// Accept the platform's primary accelerator (Cmd on macOS, Ctrl elsewhere)
// AND literal Ctrl on macOS so the chord still reaches us when the user
// is on a non-macOS layout. On Pop!_OS / GNOME the GTK compositor owns
// Ctrl+F before the renderer's keydown fires — this main-process handler
// runs strictly before that (#81727).
const hasMod = isMac() ? input.meta || input.control : input.control
const isFindChord =

Check warning on line 208 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
key === 'f' &&
hasMod &&
!input.alt &&
!input.shift
if (!isFindChord) {

Check warning on line 213 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
return
}
if (typeof event.preventDefault === 'function') {

Check warning on line 216 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
event.preventDefault()
}
webContents.send('hermes:open-find-bar')

Check warning on line 219 in apps/desktop/electron/find-in-page.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
}

webContents.on('before-input-event', handler)
Expand Down
11 changes: 5 additions & 6 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
tuiResumeArgs
} from './external-terminal'
import { findGitBash as _findGitBash } from './find-git-bash'
import { installFindShortcut, installFoundInPageForwarder, performFind, stopFind } from './find-in-page'
import { installFindShortcut, installFoundInPageForwarder, performFindAfterIndexingStarted, stopFind } from './find-in-page'
import { createFirstRunSetupGate } from './first-run-setup-gate'
import { readDirForIpc } from './fs-read-dir'
import {
Expand Down Expand Up @@ -10021,7 +10021,7 @@
// intent at the earliest observable point. macOS / Windows keep the
// renderer's own rebindable keybind, so the hook is Linux-only: installing
// it elsewhere would make Ctrl/Cmd+F un-rebindable and double-open.
if (process.platform === 'linux') {

Check warning on line 10024 in apps/desktop/electron/main.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected blank line before this statement
installFindShortcut(win)
}

Expand Down Expand Up @@ -12908,19 +12908,18 @@
})
}

ipcMain.handle('hermes:find-in-page', (event, query, options) => {
ipcMain.handle('hermes:find-in-page', async (event, query, options) => {
const win = BrowserWindow.fromWebContents(event.sender)

if (!win || win.isDestroyed()) {
return { count: 0 }
}

ensureFoundInPageForwarder(event.sender)
performFind(win.webContents, query, options)
await performFindAfterIndexingStarted(win.webContents, query, options)

// The match count arrives asynchronously via `found-in-page`; the
// synchronous return value is intentionally `{ count: 0 }` to mirror
// Electron's own `findInPage` return semantics (an opaque request id).
// The match count still arrives asynchronously via `found-in-page`; this
// reply only acknowledges that Chromium has begun returning this request.
return { count: 0 }
})

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"fix": "npm run lint:fix && npm run fmt",
"test:ui": "vitest run --project ui",
"test:desktop:platforms": "vitest run --project electron",
"test:find-in-page-native": "electron electron/find-in-page-native-fixture",
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check:test:desktop:platforms": "npm run test:desktop:platforms",
Expand Down
43 changes: 38 additions & 5 deletions apps/desktop/src/components/find-bar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,11 @@ describe('FindBar', () => {
openFindBar()
renderFindBar()

const input = await screen.findByRole('textbox', { name: /find in page/i })
const input = await screen.findByRole('searchbox', { name: /find in page/i })
expect(input).toBeTruthy()
expect(input.getAttribute('type')).toBe('search')
expect(input.getAttribute('role')).toBeNull()
expect(screen.getByRole('search').className).toContain('top-[calc(var(--titlebar-height,34px)+0.5rem)]')
expect(screen.getByRole('button', { name: /close/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /next match/i })).toBeTruthy()
expect(screen.getByRole('button', { name: /previous match/i })).toBeTruthy()
Expand All @@ -422,7 +425,7 @@ describe('FindBar', () => {
openFindBar()
renderFindBar()

const input = await screen.findByRole('textbox', { name: /find in page/i })
const input = await screen.findByRole('searchbox', { name: /find in page/i })
// eslint-disable-next-line no-restricted-globals -- asserting real focus requires the live document
await waitFor(() => expect(document.activeElement).toBe(input))
})
Expand All @@ -434,7 +437,7 @@ describe('FindBar', () => {
openFindBar()
renderFindBar()

const input = screen.getByRole('textbox', { name: /find in page/i })
const input = screen.getByRole('searchbox', { name: /find in page/i })
fireEvent.change(input, { target: { value: 'n' } })
fireEvent.change(input, { target: { value: 'ne' } })
fireEvent.change(input, { target: { value: 'nee' } })
Expand All @@ -453,14 +456,44 @@ describe('FindBar', () => {
}
})

it('excludes the semantic searchbox only while Chromium indexes the page', async () => {
vi.useFakeTimers()
let resolveFind: (() => void) | undefined
bridge.findInPage.mockReturnValueOnce(new Promise(resolve => (resolveFind = () => resolve({ count: 0 }))))

try {
openFindBar()
renderFindBar()

const input = screen.getByRole('searchbox', { name: /find in page/i }) as HTMLInputElement
input.focus()
fireEvent.change(input, { target: { value: 'needle' } })
input.setSelectionRange(6, 6)

act(() => vi.advanceTimersByTime(200))

expect(input.inert).toBe(true)
expect(input.type).toBe('search')
expect(input.getAttribute('role')).toBeNull()

await act(async () => resolveFind?.())
expect(input.inert).toBe(false)
// eslint-disable-next-line no-restricted-globals -- the exclusion cycle must restore real focus
expect(document.activeElement).toBe(input)
expect(input.selectionStart).toBe(6)
} finally {
vi.useRealTimers()
}
})

it('does not fire a pending search after the bar closes', async () => {
vi.useFakeTimers()

try {
openFindBar()
renderFindBar()

fireEvent.change(screen.getByRole('textbox', { name: /find in page/i }), {
fireEvent.change(screen.getByRole('searchbox', { name: /find in page/i }), {
target: { value: 'needle' }
})

Expand All @@ -477,7 +510,7 @@ describe('FindBar', () => {
$findInPage.set({ active: true, query: 'needle', matchOrdinal: 1, matchCount: 4 })
renderFindBar()

const input = screen.getByRole('textbox', { name: /find in page/i })
const input = screen.getByRole('searchbox', { name: /find in page/i })

fireEvent.keyDown(input, { key: 'Enter' })
expect(bridge.findInPage).toHaveBeenLastCalledWith('needle', { forward: true, findNext: true })
Expand Down
Loading
Loading