|
| 1 | +// contracts/fixtures/dispatch-disclosure.json, web-agent-browser rows: each drives the real |
| 2 | +// provider entry point over a scripted agent-browser CLI and asserts the `details.dispatched` |
| 3 | +// (and `details.dispatchedSteps`) the failure leaves behind. The fixtures carry the envelopes the |
| 4 | +// managed backend really answers, including its typed CLI-refusal `type`. |
| 5 | +import assert from 'node:assert/strict'; |
| 6 | +import fs from 'node:fs'; |
| 7 | +import { test, vi } from 'vitest'; |
| 8 | +import { |
| 9 | + assertDispatchDisclosureDriversMatchRows, |
| 10 | + DISPATCH_DISCLOSURE_TABLE_PATH, |
| 11 | + dispatchDisclosureRowsOwnedBy, |
| 12 | +} from '@agent-device/contracts/dispatch-disclosure-fixtures'; |
| 13 | +import { AppError } from '@agent-device/kernel/errors'; |
| 14 | +import { withCommandExecutorOverride, type ExecResult } from '@agent-device/host-kit/command'; |
| 15 | +import { |
| 16 | + agentBrowserJsonResult, |
| 17 | + mkdtempForTestSync, |
| 18 | + withManagedAgentBrowserProvider, |
| 19 | +} from './test-utils.ts'; |
| 20 | + |
| 21 | +const { providerStartupCleanupMock } = vi.hoisted(() => ({ |
| 22 | + providerStartupCleanupMock: vi.fn(async () => undefined), |
| 23 | +})); |
| 24 | + |
| 25 | +vi.mock('../agent-browser-lifecycle.ts', async (importOriginal) => { |
| 26 | + const actual = await importOriginal<typeof import('../agent-browser-lifecycle.ts')>(); |
| 27 | + return { |
| 28 | + ...actual, |
| 29 | + cleanupManagedAgentBrowserOrphansForProviderStartup: providerStartupCleanupMock, |
| 30 | + }; |
| 31 | +}); |
| 32 | + |
| 33 | +import { createAgentBrowserWebProvider } from '../agent-browser-provider.ts'; |
| 34 | +import type { WebProvider } from '../provider.ts'; |
| 35 | + |
| 36 | +/** The managed install exists, but every CLI call is answered by `answer`. */ |
| 37 | +async function withScriptedBackend( |
| 38 | + answer: (cliArgs: readonly string[]) => ExecResult, |
| 39 | + runProviderCall: (provider: WebProvider) => Promise<unknown>, |
| 40 | +): Promise<void> { |
| 41 | + await withManagedAgentBrowserProvider({ session: 'disclosure' }, async (provider) => { |
| 42 | + await withCommandExecutorOverride( |
| 43 | + async (_cmd: string, args: readonly string[]) => answer(args.slice(1)), |
| 44 | + async () => await runProviderCall(provider), |
| 45 | + ); |
| 46 | + }); |
| 47 | +} |
| 48 | + |
| 49 | +/** Agent-browser answers a command it does not implement before it reaches the browser daemon. */ |
| 50 | +function refuseUnknownCommand(): ExecResult { |
| 51 | + return agentBrowserJsonResult({ |
| 52 | + error: 'Unknown command: --bogus', |
| 53 | + success: false, |
| 54 | + type: 'unknown_command', |
| 55 | + }); |
| 56 | +} |
| 57 | + |
| 58 | +/** Agent-browser answers a command with argv it cannot run the same way. */ |
| 59 | +function refuseMissingArguments(): ExecResult { |
| 60 | + return agentBrowserJsonResult({ |
| 61 | + error: 'Missing arguments for: click\nUsage: agent-browser click <selector>', |
| 62 | + success: false, |
| 63 | + type: 'missing_arguments', |
| 64 | + }); |
| 65 | +} |
| 66 | + |
| 67 | +/** The envelope the backend answers once it has received and acted on a command. */ |
| 68 | +function backendRefused(error: string): ExecResult { |
| 69 | + return agentBrowserJsonResult({ success: false, data: null, error }); |
| 70 | +} |
| 71 | + |
| 72 | +/** Answers the pointer sends the given way, and refuses everything else as the backend would. */ |
| 73 | +function answerPointerSends( |
| 74 | + failOn: 'down' | 'up' | 'press', |
| 75 | +): (args: readonly string[]) => ExecResult { |
| 76 | + return (cliArgs) => { |
| 77 | + const command = cliArgs.slice(0, 2).join(' '); |
| 78 | + const pointerStepLanded = |
| 79 | + command === 'mouse move' || |
| 80 | + (failOn !== 'down' && command === 'mouse down') || |
| 81 | + (failOn !== 'down' && failOn !== 'up' && command === 'mouse up'); |
| 82 | + if (pointerStepLanded) return agentBrowserJsonResult({ success: true, data: {} }, 0); |
| 83 | + if (failOn === 'down' || failOn === 'up') return backendRefused('Element not found.'); |
| 84 | + return backendRefused('Target node detached.'); |
| 85 | + }; |
| 86 | +} |
| 87 | + |
| 88 | +/** Coordinate `click`: the move landed, the press was refused. */ |
| 89 | +async function clickRefusedOnPointerDown(): Promise<void> { |
| 90 | + let moves = 0; |
| 91 | + await withScriptedBackend( |
| 92 | + (cliArgs) => { |
| 93 | + if (cliArgs.slice(0, 2).join(' ') === 'mouse move') moves += 1; |
| 94 | + return answerPointerSends('down')(cliArgs); |
| 95 | + }, |
| 96 | + async (provider) => { |
| 97 | + await provider.click(20, 30); |
| 98 | + assert.equal(moves, 1); |
| 99 | + }, |
| 100 | + ); |
| 101 | +} |
| 102 | + |
| 103 | +/** Coordinate `fill`: the whole click landed, the select-all press was refused. */ |
| 104 | +async function fillRefusedAfterClick(): Promise<void> { |
| 105 | + let pointerSends = 0; |
| 106 | + await withScriptedBackend( |
| 107 | + (cliArgs) => { |
| 108 | + if (cliArgs[0] === 'mouse') pointerSends += 1; |
| 109 | + return answerPointerSends('press')(cliArgs); |
| 110 | + }, |
| 111 | + async (provider) => { |
| 112 | + await provider.fill(20, 30, 'Ada'); |
| 113 | + assert.equal(pointerSends, 3); |
| 114 | + }, |
| 115 | + ); |
| 116 | +} |
| 117 | + |
| 118 | +/** `scroll --duration` long enough to pace into steps: two landed, the third was refused. */ |
| 119 | +async function scrollRefusedOnThirdStep(): Promise<void> { |
| 120 | + let scrollSteps = 0; |
| 121 | + await withScriptedBackend( |
| 122 | + (cliArgs) => { |
| 123 | + if (cliArgs[0] !== 'scroll') return backendRefused('Unknown ref: e3'); |
| 124 | + scrollSteps += 1; |
| 125 | + return scrollSteps <= 2 |
| 126 | + ? agentBrowserJsonResult({ success: true, data: {} }, 0) |
| 127 | + : backendRefused('Scroll target detached.'); |
| 128 | + }, |
| 129 | + async (provider) => { |
| 130 | + await provider.scroll('down', { pixels: 300, durationMs: 120 }); |
| 131 | + assert.equal(scrollSteps, 3); |
| 132 | + }, |
| 133 | + ); |
| 134 | +} |
| 135 | + |
| 136 | +/** The managed backend is not installed, so the CLI is never spawned. */ |
| 137 | +async function openWithMissingBackend(): Promise<void> { |
| 138 | + const provider = createAgentBrowserWebProvider({ |
| 139 | + stateDir: mkdtempForTestSync('agent-device-web-disclosure-missing-'), |
| 140 | + }); |
| 141 | + await provider.open('https://example.test'); |
| 142 | +} |
| 143 | + |
| 144 | +/** One row's drive plus the `details.dispatchedSteps` it additionally demands. */ |
| 145 | +const DRIVERS: Record<string, { drive: () => Promise<unknown>; dispatchedSteps?: number }> = { |
| 146 | + 'web-agent-browser.backend-missing': { drive: openWithMissingBackend }, |
| 147 | + 'web-agent-browser.cli-unknown-command': { |
| 148 | + drive: () => |
| 149 | + withScriptedBackend(refuseUnknownCommand, async (provider) => { |
| 150 | + await provider.clickRef?.('@e3'); |
| 151 | + }), |
| 152 | + }, |
| 153 | + 'web-agent-browser.cli-missing-arguments': { |
| 154 | + drive: () => |
| 155 | + withScriptedBackend(refuseMissingArguments, async (provider) => { |
| 156 | + await provider.clickRef?.('@e3'); |
| 157 | + }), |
| 158 | + }, |
| 159 | + 'web-agent-browser.click-pointer-down-refused': { |
| 160 | + drive: clickRefusedOnPointerDown, |
| 161 | + dispatchedSteps: 1, |
| 162 | + }, |
| 163 | + 'web-agent-browser.scroll-third-step-refused': { |
| 164 | + drive: scrollRefusedOnThirdStep, |
| 165 | + dispatchedSteps: 2, |
| 166 | + }, |
| 167 | + 'web-agent-browser.fill-selection-press-refused': { |
| 168 | + drive: fillRefusedAfterClick, |
| 169 | + dispatchedSteps: 3, |
| 170 | + }, |
| 171 | +}; |
| 172 | + |
| 173 | +const ROWS = dispatchDisclosureRowsOwnedBy( |
| 174 | + import.meta.url, |
| 175 | + fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), |
| 176 | +); |
| 177 | + |
| 178 | +test('every web-agent-browser dispatch-disclosure row has exactly one driver', () => { |
| 179 | + assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); |
| 180 | +}); |
| 181 | + |
| 182 | +for (const row of ROWS) { |
| 183 | + test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { |
| 184 | + const driver = DRIVERS[row.id]; |
| 185 | + assert.ok(driver, `no driver for ${row.id}`); |
| 186 | + await assert.rejects( |
| 187 | + async () => await driver.drive(), |
| 188 | + (error: unknown) => { |
| 189 | + assert.ok(error instanceof AppError); |
| 190 | + assert.equal(error.details?.dispatched, row.dispatched); |
| 191 | + assert.equal(error.details?.dispatchedSteps, driver.dispatchedSteps); |
| 192 | + return true; |
| 193 | + }, |
| 194 | + ); |
| 195 | + }); |
| 196 | +} |
| 197 | + |
| 198 | +test('a backend-answered failure carries no provider verdict for the router to override', async () => { |
| 199 | + // The rows above prove what the provider stamps; this pins the complement, because a provider |
| 200 | + // that guessed `no` from the message text here would make a blind retry unsafe: the backend |
| 201 | + // received the click, and only the request router may default the verdict. |
| 202 | + await withScriptedBackend( |
| 203 | + () => backendRefused('Unknown ref: e3'), |
| 204 | + async (provider) => { |
| 205 | + await provider.clickRef?.('@e3'); |
| 206 | + }, |
| 207 | + ).then( |
| 208 | + () => assert.fail('the refusal must reject'), |
| 209 | + (error: unknown) => { |
| 210 | + assert.ok(error instanceof AppError); |
| 211 | + assert.equal(error.details?.dispatched, undefined); |
| 212 | + }, |
| 213 | + ); |
| 214 | +}); |
0 commit comments