mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
Semantic sweep of src/main/browser, ssh, codex and native-chat. 28 cases removed across 20 files, plus one file and the dead production wrapper behind it. The find that keeps repeating is a test whose mocks leave nothing of production between them and the assertion: - `recordBrowserClientPagePublishedUrl` ignores `revision` entirely, so "overwrites the recorded url when a newer navigation arrives" varied a parameter the code never reads; - `publish` calls `this.observeCurrentUrl?.(params)` unconditionally as its first statement, so "observes params the schema will reject rather than pre-filtering" asserted an unbranched line; - with the executor mocked away, "does not observe publishes aimed at another environment" left only a `Map.get`; - `mountPage`/`rekeyPage`/`retirePage` all delegate to one private `request()` that owns the timer, making "times out exactly once" a provider-local replay whose name promised a "once" it never asserted. Two negative controls were passing for the wrong reason. "Rejects oversized browser clipboard writes" fed a 16 MiB fixture, but `clipboardWrite` has one guard at 8 KiB, so the refusal came from that guard rather than the clipboard limit its name claims — the 8 KiB sibling stays. And "tolerates cancelling when nothing is armed" cannot fail at all: `cancel()` is `if (this.timer)`-guarded and `clearTimeout(null)` is legal, so removing the guard still passes. `browser-cookie-import-app-bound-prefix.test.ts` is deleted as a private-predicate test over dead code: `browser-cookie-import.ts:56` was a bare forwarding wrapper around `browser-cookie-decryption`'s predicate, and `browser-cookie-chromium-scan.ts:51` inlines `version === 'v20'` rather than calling either. The v20-row contract is owned by `browser-cookie-import-undecryptable.test.ts`. The wrapper and its import are removed with it, since that test was its only caller.
434 lines
12 KiB
TypeScript
434 lines
12 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const { execFileMock, webContentsFromIdMock, existsSyncMock, readFileSyncMock, stdinWrites } =
|
|
vi.hoisted(() => ({
|
|
execFileMock: vi.fn(),
|
|
webContentsFromIdMock: vi.fn(),
|
|
existsSyncMock: vi.fn(() => false),
|
|
readFileSyncMock: vi.fn(() => Buffer.from('')),
|
|
stdinWrites: [] as string[]
|
|
}))
|
|
|
|
vi.mock('child_process', () => ({ execFile: execFileMock }))
|
|
vi.mock('fs', () => ({
|
|
existsSync: existsSyncMock,
|
|
readFileSync: readFileSyncMock,
|
|
accessSync: vi.fn(),
|
|
chmodSync: vi.fn(),
|
|
constants: { X_OK: 1 }
|
|
}))
|
|
vi.mock('os', () => ({ platform: () => 'darwin', arch: () => 'arm64' }))
|
|
vi.mock('electron', () => {
|
|
return {
|
|
app: {
|
|
getPath: vi.fn(() => '/app'),
|
|
getAppPath: vi.fn(() => '/project'),
|
|
isPackaged: false
|
|
},
|
|
webContents: { fromId: webContentsFromIdMock }
|
|
}
|
|
})
|
|
const { CdpWsProxyMock } = vi.hoisted(() => {
|
|
const instances: unknown[] = []
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const MockClass = vi.fn().mockImplementation(function (this: any, _wc: unknown) {
|
|
this._wc = _wc
|
|
this.start = vi.fn(async () => 'ws://127.0.0.1:9222')
|
|
this.stop = vi.fn(async () => {})
|
|
this.getPort = vi.fn(() => 9222)
|
|
instances.push(this)
|
|
})
|
|
return { CdpWsProxyMock: Object.assign(MockClass, { instances }) }
|
|
})
|
|
|
|
vi.mock('./cdp-ws-proxy', () => ({
|
|
CdpWsProxy: CdpWsProxyMock
|
|
}))
|
|
vi.mock('./cdp-bridge', () => ({
|
|
BrowserError: class BrowserError extends Error {
|
|
code: string
|
|
constructor(code: string, message: string) {
|
|
super(message)
|
|
this.code = code
|
|
}
|
|
}
|
|
}))
|
|
|
|
import { AgentBrowserBridge } from './agent-browser-bridge'
|
|
import {
|
|
mockBrowserManager,
|
|
mockWebContents,
|
|
overrideBridgeWebContentsLookup,
|
|
resetAgentBrowserBridgeMocks
|
|
} from './agent-browser-bridge-test-harness'
|
|
|
|
overrideBridgeWebContentsLookup(AgentBrowserBridge.prototype, webContentsFromIdMock)
|
|
|
|
function recordDispatchedEvents(
|
|
wc: ReturnType<typeof mockWebContents>,
|
|
sink: Record<string, unknown>[]
|
|
): void {
|
|
wc.debugger.sendCommand.mockImplementation(async (method, params) => {
|
|
if (method === 'Input.dispatchMouseEvent' && typeof params === 'object' && params !== null) {
|
|
sink.push({ ...params })
|
|
}
|
|
return {}
|
|
})
|
|
}
|
|
|
|
describe('AgentBrowserBridge coordinate pointer input', () => {
|
|
let bridge: AgentBrowserBridge
|
|
let wc: ReturnType<typeof mockWebContents>
|
|
let dispatchedEvents: Record<string, unknown>[]
|
|
|
|
const dispatched = (): Record<string, unknown>[] => dispatchedEvents
|
|
|
|
beforeEach(() => {
|
|
resetAgentBrowserBridgeMocks({
|
|
webContentsFromIdMock,
|
|
existsSyncMock,
|
|
readFileSyncMock,
|
|
stdinWrites,
|
|
cdpWsProxyInstances: CdpWsProxyMock.instances
|
|
})
|
|
bridge = new AgentBrowserBridge(mockBrowserManager())
|
|
bridge.setActiveTab(100)
|
|
wc = mockWebContents(100)
|
|
dispatchedEvents = []
|
|
recordDispatchedEvents(wc, dispatchedEvents)
|
|
webContentsFromIdMock.mockReturnValue(wc)
|
|
})
|
|
|
|
// ── Transport ──
|
|
|
|
it('dispatches move, down, up and wheel over CDP without spawning the helper', async () => {
|
|
await expect(bridge.mouseMove(10, 20)).resolves.toEqual({ moved: true })
|
|
await expect(bridge.mouseDown('left')).resolves.toEqual({ pressed: true })
|
|
await expect(bridge.mouseUp('left')).resolves.toEqual({ released: true })
|
|
await expect(bridge.mouseWheel(120, 30)).resolves.toEqual({
|
|
scrolled: true,
|
|
deltaX: 30,
|
|
deltaY: 120
|
|
})
|
|
|
|
expect(execFileMock).not.toHaveBeenCalled()
|
|
expect(dispatched()).toHaveLength(4)
|
|
})
|
|
|
|
it('sends CDP payloads matching a real pointer press and release', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseUp('left')
|
|
|
|
expect(dispatched()).toEqual([
|
|
{ type: 'mouseMoved', x: 10, y: 20, button: 'none', buttons: 0 },
|
|
{
|
|
type: 'mousePressed',
|
|
x: 10,
|
|
y: 20,
|
|
button: 'left',
|
|
buttons: 1,
|
|
clickCount: 1
|
|
},
|
|
{
|
|
type: 'mouseReleased',
|
|
x: 10,
|
|
y: 20,
|
|
button: 'left',
|
|
buttons: 0,
|
|
clickCount: 1
|
|
}
|
|
])
|
|
})
|
|
|
|
// ── Position tracking ──
|
|
|
|
it('drags from the tracked position while the button stays held', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseMove(60, 80)
|
|
|
|
expect(dispatched()[2]).toEqual({
|
|
type: 'mouseMoved',
|
|
x: 60,
|
|
y: 80,
|
|
button: 'left',
|
|
buttons: 1
|
|
})
|
|
})
|
|
|
|
it('scrolls at the tracked pointer position, not the origin', async () => {
|
|
await bridge.mouseMove(300, 400)
|
|
await bridge.mouseWheel(120)
|
|
|
|
expect(dispatched()[1]).toEqual({
|
|
type: 'mouseWheel',
|
|
x: 300,
|
|
y: 400,
|
|
deltaX: 0,
|
|
deltaY: 120,
|
|
buttons: 0
|
|
})
|
|
})
|
|
|
|
it('keeps pointer state per tab', async () => {
|
|
const other = mockWebContents(200)
|
|
recordDispatchedEvents(other, [])
|
|
|
|
await bridge.mouseMove(10, 20)
|
|
webContentsFromIdMock.mockReturnValue(other)
|
|
await bridge.mouseMove(90, 90)
|
|
webContentsFromIdMock.mockReturnValue(wc)
|
|
await bridge.mouseDown('left')
|
|
|
|
expect(dispatched()[1]).toMatchObject({
|
|
type: 'mousePressed',
|
|
x: 10,
|
|
y: 20
|
|
})
|
|
})
|
|
|
|
// ── Click cadence ──
|
|
|
|
it('escalates clickCount for a repeat press at the same point', async () => {
|
|
// Why: real wall-clock makes this flake — a >500ms stall under load resets the cadence.
|
|
vi.useFakeTimers()
|
|
try {
|
|
await bridge.mouseMove(10, 20)
|
|
for (let i = 0; i < 4; i += 1) {
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseUp('left')
|
|
}
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
|
|
expect(
|
|
dispatched()
|
|
.filter((event) => event.type === 'mousePressed')
|
|
.map((event) => event.clickCount)
|
|
).toEqual([1, 2, 3, 1])
|
|
})
|
|
|
|
it('restarts clickCount when the second press lands elsewhere', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await bridge.mouseMove(10, 20)
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseUp('left')
|
|
await bridge.mouseMove(400, 400)
|
|
await bridge.mouseDown('left')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({
|
|
type: 'mousePressed',
|
|
clickCount: 1
|
|
})
|
|
})
|
|
|
|
it('restarts clickCount when the repeat press uses another button', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await bridge.mouseMove(10, 20)
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseUp('left')
|
|
await bridge.mouseDown('right')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({
|
|
type: 'mousePressed',
|
|
button: 'right',
|
|
clickCount: 1
|
|
})
|
|
})
|
|
|
|
it('restarts clickCount once the repeat lands outside the double-click interval', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
await bridge.mouseMove(10, 20)
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseUp('left')
|
|
vi.setSystemTime(Date.now() + 501)
|
|
await bridge.mouseDown('left')
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({
|
|
type: 'mousePressed',
|
|
clickCount: 1
|
|
})
|
|
})
|
|
|
|
// ── Buttons mask ──
|
|
|
|
it('carries back and forward through as X1 and X2 presses', async () => {
|
|
await bridge.mouseDown('back')
|
|
await bridge.mouseUp('back')
|
|
await bridge.mouseDown('forward')
|
|
|
|
expect(dispatched()).toEqual([
|
|
{
|
|
type: 'mousePressed',
|
|
x: 0,
|
|
y: 0,
|
|
button: 'back',
|
|
buttons: 8,
|
|
clickCount: 1
|
|
},
|
|
{
|
|
type: 'mouseReleased',
|
|
x: 0,
|
|
y: 0,
|
|
button: 'back',
|
|
buttons: 0,
|
|
clickCount: 1
|
|
},
|
|
{
|
|
type: 'mousePressed',
|
|
x: 0,
|
|
y: 0,
|
|
button: 'forward',
|
|
buttons: 16,
|
|
clickCount: 1
|
|
}
|
|
])
|
|
})
|
|
|
|
it('releases the held button when mouseUp names none', async () => {
|
|
await bridge.mouseDown('right')
|
|
await bridge.mouseUp()
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({
|
|
type: 'mouseReleased',
|
|
button: 'right',
|
|
buttons: 0
|
|
})
|
|
})
|
|
|
|
it('keeps the remaining held button addressable after a chorded release', async () => {
|
|
await bridge.mouseDown('left')
|
|
await bridge.mouseDown('right')
|
|
await bridge.mouseUp('right')
|
|
await bridge.mouseUp()
|
|
|
|
expect(dispatched()[2]).toMatchObject({
|
|
type: 'mouseReleased',
|
|
button: 'right',
|
|
buttons: 1
|
|
})
|
|
expect(dispatched()[3]).toMatchObject({
|
|
type: 'mouseReleased',
|
|
button: 'left',
|
|
buttons: 0
|
|
})
|
|
})
|
|
|
|
it('defaults to left when nothing is held and mouseUp names no button', async () => {
|
|
await bridge.mouseUp()
|
|
|
|
expect(dispatched()[0]).toMatchObject({
|
|
type: 'mouseReleased',
|
|
button: 'left',
|
|
buttons: 0
|
|
})
|
|
})
|
|
|
|
// ── Failures ──
|
|
|
|
it('rejects non-finite coordinates and deltas before dispatching', async () => {
|
|
await expect(bridge.mouseMove(Number.NaN, 20)).rejects.toMatchObject({
|
|
code: 'browser_error'
|
|
})
|
|
await expect(bridge.mouseWheel(Number.POSITIVE_INFINITY)).rejects.toMatchObject({
|
|
code: 'browser_error'
|
|
})
|
|
|
|
expect(dispatched()).toHaveLength(0)
|
|
})
|
|
|
|
it('reports a dispatch failure as browser_error on the call that failed', async () => {
|
|
wc.debugger.sendCommand.mockRejectedValueOnce(new Error('boom'))
|
|
|
|
await expect(bridge.mouseMove(10, 20)).rejects.toMatchObject({
|
|
code: 'browser_error'
|
|
})
|
|
})
|
|
|
|
it('reports a page that dies mid-dispatch as browser_tab_not_found', async () => {
|
|
wc.debugger.sendCommand.mockImplementation(async () => {
|
|
webContentsFromIdMock.mockReturnValue(null)
|
|
throw new Error('Debugger is not attached to the target')
|
|
})
|
|
|
|
await expect(bridge.mouseDown('left')).rejects.toMatchObject({
|
|
code: 'browser_tab_not_found'
|
|
})
|
|
})
|
|
|
|
it('leaves no phantom held button when a press fails to dispatch', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
wc.debugger.sendCommand.mockRejectedValueOnce(new Error('boom'))
|
|
await expect(bridge.mouseDown('left')).rejects.toThrow()
|
|
await bridge.mouseMove(30, 40)
|
|
|
|
expect(dispatched().at(-1)).toEqual({
|
|
type: 'mouseMoved',
|
|
x: 30,
|
|
y: 40,
|
|
button: 'none',
|
|
buttons: 0
|
|
})
|
|
})
|
|
|
|
it('rewinds the tracked position when a move fails to dispatch', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
wc.debugger.sendCommand.mockRejectedValueOnce(new Error('boom'))
|
|
await expect(bridge.mouseMove(300, 400)).rejects.toThrow()
|
|
await bridge.mouseWheel(120)
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({ type: 'mouseWheel', x: 10, y: 20 })
|
|
})
|
|
|
|
it('does not count a failed press toward the double-click cadence', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
wc.debugger.sendCommand.mockRejectedValueOnce(new Error('boom'))
|
|
await expect(bridge.mouseDown('left')).rejects.toThrow()
|
|
await bridge.mouseDown('left')
|
|
|
|
expect(dispatched().at(-1)).toMatchObject({ type: 'mousePressed', clickCount: 1 })
|
|
})
|
|
|
|
// ── Lifecycle ──
|
|
|
|
it('attaches and detaches the debugger around each dispatch', async () => {
|
|
let attached = false
|
|
wc.debugger.isAttached.mockImplementation(() => attached)
|
|
wc.debugger.attach.mockImplementation(() => {
|
|
attached = true
|
|
})
|
|
|
|
await bridge.mouseMove(10, 20)
|
|
|
|
expect(wc.debugger.attach).toHaveBeenCalledWith('1.3')
|
|
expect(wc.debugger.detach).toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves a debugger it did not attach alone', async () => {
|
|
await bridge.mouseMove(10, 20)
|
|
|
|
expect(wc.debugger.attach).not.toHaveBeenCalled()
|
|
expect(wc.debugger.detach).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('focuses the guest on press, as mouseClick does', async () => {
|
|
await bridge.mouseDown('left')
|
|
|
|
expect(wc.focus).toHaveBeenCalled()
|
|
})
|
|
})
|