|
1 | 1 | import { BROWSER_TOOL_QUEUE_WAIT_TIMEOUT_MS } from '@sim/browser-protocol' |
| 2 | +import { toRecord } from '@sim/utils/object' |
2 | 3 | import type { MenuItemConstructorOptions, WebContents } from 'electron' |
3 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
4 | 5 |
|
@@ -2097,13 +2098,20 @@ describe('credential protection', () => { |
2097 | 2098 | for (const [fnName, value] of Object.entries(replies)) { |
2098 | 2099 | if (isPageCall(expression, fnName)) return Promise.resolve(value) |
2099 | 2100 | } |
2100 | | - if (isPageCall(expression, 'clickElement')) { |
2101 | | - return Promise.resolve({ dispatched: false, x: 24, y: 48, element: 'Test' }) |
2102 | | - } |
| 2101 | + if (isPageCall(expression, 'clickElement')) return Promise.resolve(CLICK_TARGET) |
2103 | 2102 | return Promise.resolve(undefined) |
2104 | 2103 | }) |
2105 | 2104 | } |
2106 | 2105 |
|
| 2106 | + function mousePresses(contents: Awaited<ReturnType<typeof openPage>>): number { |
| 2107 | + return cdpCalls(contents, 'Input.dispatchMouseEvent').filter( |
| 2108 | + ([, params]) => toRecord(params).type === 'mousePressed' |
| 2109 | + ).length |
| 2110 | + } |
| 2111 | + |
| 2112 | + /** What the page reports for an ordinary click target before native dispatch. */ |
| 2113 | + const CLICK_TARGET = { dispatched: false, x: 24, y: 48, element: 'Test' } |
| 2114 | + |
2107 | 2115 | function cdpCalls(contents: Awaited<ReturnType<typeof openPage>>, method: string): unknown[][] { |
2108 | 2116 | return vi |
2109 | 2117 | .mocked(contents.debugger.sendCommand) |
@@ -3425,6 +3433,217 @@ describe('credential protection', () => { |
3425 | 3433 | expect(second).not.toMatchObject({ result: { notices: expect.anything() } }) |
3426 | 3434 | }) |
3427 | 3435 |
|
| 3436 | + it('runs batched actions in order and returns each result', async () => { |
| 3437 | + const contents = await openPage() |
| 3438 | + respondWith(contents, {}) |
| 3439 | + |
| 3440 | + const result = await driver.executeTool('chat-test', 'browser_batch', { |
| 3441 | + actions: [ |
| 3442 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3443 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3444 | + ], |
| 3445 | + }) |
| 3446 | + |
| 3447 | + expect(result).toMatchObject({ |
| 3448 | + ok: true, |
| 3449 | + result: { |
| 3450 | + completed: true, |
| 3451 | + completedCount: 2, |
| 3452 | + results: [ |
| 3453 | + { index: 0, tool: 'browser_click', result: { dispatched: true } }, |
| 3454 | + { index: 1, tool: 'browser_click', result: { dispatched: true } }, |
| 3455 | + ], |
| 3456 | + }, |
| 3457 | + }) |
| 3458 | + }) |
| 3459 | + |
| 3460 | + it('stops a batch at the first failed action and keeps earlier results', async () => { |
| 3461 | + const contents = await openPage() |
| 3462 | + vi.mocked(contents.executeJavaScript).mockImplementation((expression: string) => { |
| 3463 | + if (!isPageCall(expression, 'clickElement')) return Promise.resolve(undefined) |
| 3464 | + return Promise.resolve( |
| 3465 | + mousePresses(contents) > 0 ? { error: 'obstructed', blocker: 'IMG' } : CLICK_TARGET |
| 3466 | + ) |
| 3467 | + }) |
| 3468 | + |
| 3469 | + const result = await driver.executeTool('chat-test', 'browser_batch', { |
| 3470 | + actions: [ |
| 3471 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3472 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3473 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3474 | + ], |
| 3475 | + }) |
| 3476 | + |
| 3477 | + expect(mousePresses(contents)).toBe(1) |
| 3478 | + expect(result).toMatchObject({ |
| 3479 | + ok: true, |
| 3480 | + result: { |
| 3481 | + completed: false, |
| 3482 | + completedCount: 1, |
| 3483 | + stoppedIndex: 1, |
| 3484 | + stoppedBy: 'failure', |
| 3485 | + error: expect.stringContaining('covered by IMG'), |
| 3486 | + }, |
| 3487 | + }) |
| 3488 | + }) |
| 3489 | + |
| 3490 | + it('stops a batch after an action navigates the page', async () => { |
| 3491 | + const contents = await openPage() |
| 3492 | + respondWith(contents, {}) |
| 3493 | + const sendCommand = vi.mocked(contents.debugger.sendCommand) |
| 3494 | + const dispatch = sendCommand.getMockImplementation() |
| 3495 | + sendCommand.mockImplementation((method, params) => { |
| 3496 | + if (method === 'Input.dispatchMouseEvent' && toRecord(params).type === 'mouseReleased') { |
| 3497 | + emitContentsEvent(contents, 'did-navigate') |
| 3498 | + } |
| 3499 | + return dispatch?.(method, params) ?? Promise.resolve(undefined) |
| 3500 | + }) |
| 3501 | + |
| 3502 | + const result = await driver.executeTool('chat-test', 'browser_batch', { |
| 3503 | + actions: [ |
| 3504 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3505 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3506 | + ], |
| 3507 | + }) |
| 3508 | + |
| 3509 | + expect(mousePresses(contents)).toBe(1) |
| 3510 | + expect(result).toMatchObject({ |
| 3511 | + ok: true, |
| 3512 | + result: { completed: false, completedCount: 1, stoppedIndex: 1, stoppedBy: 'page-change' }, |
| 3513 | + }) |
| 3514 | + }) |
| 3515 | + |
| 3516 | + it('stops a batch after an action changes the URL within the document', async () => { |
| 3517 | + const contents = await openPage() |
| 3518 | + respondWith(contents, {}) |
| 3519 | + const url = contents.getURL() |
| 3520 | + const sendCommand = vi.mocked(contents.debugger.sendCommand) |
| 3521 | + const dispatch = sendCommand.getMockImplementation() |
| 3522 | + sendCommand.mockImplementation((method, params) => { |
| 3523 | + if (method === 'Input.dispatchMouseEvent' && toRecord(params).type === 'mouseReleased') { |
| 3524 | + vi.mocked(contents.getURL).mockReturnValue(`${url}#next`) |
| 3525 | + emitContentsEvent(contents, 'did-navigate-in-page') |
| 3526 | + } |
| 3527 | + return dispatch?.(method, params) ?? Promise.resolve(undefined) |
| 3528 | + }) |
| 3529 | + |
| 3530 | + const result = await driver.executeTool('chat-test', 'browser_batch', { |
| 3531 | + actions: [ |
| 3532 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3533 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3534 | + ], |
| 3535 | + }) |
| 3536 | + |
| 3537 | + expect(mousePresses(contents)).toBe(1) |
| 3538 | + expect(result).toMatchObject({ |
| 3539 | + ok: true, |
| 3540 | + result: { completed: false, completedCount: 1, stoppedIndex: 1, stoppedBy: 'page-change' }, |
| 3541 | + }) |
| 3542 | + }) |
| 3543 | + |
| 3544 | + it('reports a batch cancelled during its first action as an unknown outcome', async () => { |
| 3545 | + const contents = await openPage() |
| 3546 | + respondWith(contents, {}) |
| 3547 | + const sendCommand = vi.mocked(contents.debugger.sendCommand) |
| 3548 | + const dispatch = sendCommand.getMockImplementation() |
| 3549 | + sendCommand.mockImplementation((method, params) => |
| 3550 | + method === 'Input.dispatchKeyEvent' |
| 3551 | + ? new Promise(() => {}) |
| 3552 | + : (dispatch?.(method, params) ?? Promise.resolve(undefined)) |
| 3553 | + ) |
| 3554 | + |
| 3555 | + const pending = driver.executeTool( |
| 3556 | + 'chat-test', |
| 3557 | + 'browser_batch', |
| 3558 | + { |
| 3559 | + actions: [ |
| 3560 | + { tool: 'browser_press_key', args: { key: 'Enter' } }, |
| 3561 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3562 | + ], |
| 3563 | + }, |
| 3564 | + 'batch-first-call' |
| 3565 | + ) |
| 3566 | + await vi.waitFor(() => expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(1)) |
| 3567 | + driver.cancelTool('chat-test', 'batch-first-call') |
| 3568 | + |
| 3569 | + await expect(pending).resolves.toMatchObject({ |
| 3570 | + ok: true, |
| 3571 | + result: { outcomeUnknown: true, doNotRetry: true }, |
| 3572 | + }) |
| 3573 | + }) |
| 3574 | + |
| 3575 | + it('reports a batch cancelled after an action ran as an unknown outcome', async () => { |
| 3576 | + const contents = await openPage() |
| 3577 | + respondWith(contents, {}) |
| 3578 | + const sendCommand = vi.mocked(contents.debugger.sendCommand) |
| 3579 | + const dispatch = sendCommand.getMockImplementation() |
| 3580 | + sendCommand.mockImplementation((method, params) => |
| 3581 | + method === 'Input.dispatchKeyEvent' |
| 3582 | + ? new Promise(() => {}) |
| 3583 | + : (dispatch?.(method, params) ?? Promise.resolve(undefined)) |
| 3584 | + ) |
| 3585 | + |
| 3586 | + const pending = driver.executeTool( |
| 3587 | + 'chat-test', |
| 3588 | + 'browser_batch', |
| 3589 | + { |
| 3590 | + actions: [ |
| 3591 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3592 | + { tool: 'browser_press_key', args: { key: 'Enter' } }, |
| 3593 | + ], |
| 3594 | + }, |
| 3595 | + 'batch-call' |
| 3596 | + ) |
| 3597 | + await vi.waitFor(() => expect(cdpCalls(contents, 'Input.dispatchKeyEvent')).toHaveLength(1)) |
| 3598 | + driver.cancelTool('chat-test', 'batch-call') |
| 3599 | + |
| 3600 | + await expect(pending).resolves.toMatchObject({ |
| 3601 | + ok: true, |
| 3602 | + result: { outcomeUnknown: true, doNotRetry: true }, |
| 3603 | + }) |
| 3604 | + }) |
| 3605 | + |
| 3606 | + it('rejects batches that name non-action tools or observe per action', async () => { |
| 3607 | + await openPage() |
| 3608 | + |
| 3609 | + const navigation = await driver.executeTool('chat-test', 'browser_batch', { |
| 3610 | + actions: [ |
| 3611 | + { tool: 'browser_navigate', args: { url: 'https://example.com' } }, |
| 3612 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3613 | + ], |
| 3614 | + }) |
| 3615 | + const observed = await driver.executeTool('chat-test', 'browser_batch', { |
| 3616 | + actions: [ |
| 3617 | + { tool: 'browser_click', args: { elementId: 0, observe: {} } }, |
| 3618 | + { tool: 'browser_click', args: { elementId: 0 } }, |
| 3619 | + ], |
| 3620 | + }) |
| 3621 | + |
| 3622 | + expect(navigation).toMatchObject({ |
| 3623 | + ok: false, |
| 3624 | + error: expect.stringContaining('Batch action 0'), |
| 3625 | + }) |
| 3626 | + expect(observed).toMatchObject({ ok: false, error: expect.stringContaining('cannot observe') }) |
| 3627 | + }) |
| 3628 | + |
| 3629 | + it('keeps element ids valid when an observed action is refused before dispatch', async () => { |
| 3630 | + const contents = await openPage() |
| 3631 | + respondWith(contents, { clickElement: { error: 'obstructed', blocker: 'IMG' } }) |
| 3632 | + |
| 3633 | + const refused = await driver.executeTool('chat-test', 'browser_click', { |
| 3634 | + elementId: 0, |
| 3635 | + observe: {}, |
| 3636 | + }) |
| 3637 | + respondWith(contents, {}) |
| 3638 | + const retried = await driver.executeTool('chat-test', 'browser_click', { elementId: 0 }) |
| 3639 | + |
| 3640 | + expect(refused).toEqual({ |
| 3641 | + ok: false, |
| 3642 | + error: expect.stringContaining('That element is covered by IMG'), |
| 3643 | + }) |
| 3644 | + expect(retried).toMatchObject({ ok: true, result: { dispatched: true } }) |
| 3645 | + }) |
| 3646 | + |
3428 | 3647 | it('invalidates element ids when the active tab changes', async () => { |
3429 | 3648 | await openPage() |
3430 | 3649 | await driver.executeTool('chat-test', 'browser_open_tab', {}) |
|
0 commit comments