|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import type { BrowserNode, ExecutionContext } from '../index.js'; |
| 3 | +import { BrowserNodeError, createBrowserNodeHandler, type WorkflowBrowser, type WorkflowPage } from './browser.js'; |
| 4 | + |
| 5 | +const SNAP = { url: 'https://x/', title: 'X', timestamp: 't', elements: [] }; |
| 6 | + |
| 7 | +function fakePage() { |
| 8 | + const calls: string[] = []; |
| 9 | + const page: WorkflowPage = { |
| 10 | + goto: async (u) => { calls.push('goto:' + u); }, |
| 11 | + snapshot: async () => SNAP, |
| 12 | + click: async (r) => { calls.push('click:' + r); }, |
| 13 | + fill: async (r, v) => { calls.push('fill:' + r + '=' + v); }, |
| 14 | + extract: async (m) => ({ mode: m }), |
| 15 | + screenshot: async () => Buffer.from('PNG'), |
| 16 | + analyze: async (g) => ({ ok: true, mode: 'dry-run', status: 'planned', page: { url: 'x', title: 'X' }, ...(g ? { goal: g } : {}) }), |
| 17 | + runTask: async () => ({ ok: true, mode: 'execute', status: 'complete', page: { url: 'x', title: 'X' } }), |
| 18 | + }; |
| 19 | + return { page, calls }; |
| 20 | +} |
| 21 | + |
| 22 | +function fakeBrowser(page: WorkflowPage) { |
| 23 | + const writes: Array<{ path: string; bytes: Uint8Array }> = []; |
| 24 | + let closed = false; |
| 25 | + const browser: WorkflowBrowser = { |
| 26 | + page: async () => page, |
| 27 | + writeBytes: async (path, bytes) => { writes.push({ path, bytes }); }, |
| 28 | + close: async () => { closed = true; }, |
| 29 | + }; |
| 30 | + return { browser, writes, isClosed: () => closed }; |
| 31 | +} |
| 32 | + |
| 33 | +const ctx: ExecutionContext = { variables: {} }; |
| 34 | +const node = (config: BrowserNode['config']): BrowserNode => ({ id: 'n', type: 'browser', next: [], config }); |
| 35 | + |
| 36 | +describe('browser node handler', () => { |
| 37 | + it('open navigates and outputs a snapshot', async () => { |
| 38 | + const { page, calls } = fakePage(); |
| 39 | + const h = createBrowserNodeHandler(fakeBrowser(page).browser); |
| 40 | + const r = await h.run(node({ action: 'open', url: 'https://x/' }), ctx); |
| 41 | + expect(calls).toContain('goto:https://x/'); |
| 42 | + expect((r.output as typeof SNAP).title).toBe('X'); |
| 43 | + }); |
| 44 | + |
| 45 | + it('click and fill act then output a snapshot', async () => { |
| 46 | + const { page, calls } = fakePage(); |
| 47 | + const h = createBrowserNodeHandler(fakeBrowser(page).browser); |
| 48 | + await h.run(node({ action: 'click', ref: '@e1' }), ctx); |
| 49 | + await h.run(node({ action: 'fill', ref: '@e2', value: 'hi' }), ctx); |
| 50 | + expect(calls).toEqual(expect.arrayContaining(['click:@e1', 'fill:@e2=hi'])); |
| 51 | + }); |
| 52 | + |
| 53 | + it('extract outputs data', async () => { |
| 54 | + const { page } = fakePage(); |
| 55 | + const h = createBrowserNodeHandler(fakeBrowser(page).browser); |
| 56 | + const r = await h.run(node({ action: 'extract', mode: 'links' }), ctx); |
| 57 | + expect(r.output).toEqual({ mode: 'links' }); |
| 58 | + }); |
| 59 | + |
| 60 | + it('screenshot writes the file and outputs its path', async () => { |
| 61 | + const { page } = fakePage(); |
| 62 | + const fb = fakeBrowser(page); |
| 63 | + const h = createBrowserNodeHandler(fb.browser); |
| 64 | + const r = await h.run(node({ action: 'screenshot', path: 'out.png' }), ctx); |
| 65 | + expect(fb.writes[0].path).toBe('out.png'); |
| 66 | + expect(r.output).toEqual({ screenshot: 'out.png' }); |
| 67 | + }); |
| 68 | + |
| 69 | + it('analyze outputs a dry-run result', async () => { |
| 70 | + const { page } = fakePage(); |
| 71 | + const h = createBrowserNodeHandler(fakeBrowser(page).browser); |
| 72 | + const r = await h.run(node({ action: 'analyze', goal: 'Fill form' }), ctx); |
| 73 | + expect((r.output as { status: string; goal: string }).status).toBe('planned'); |
| 74 | + expect((r.output as { goal: string }).goal).toBe('Fill form'); |
| 75 | + }); |
| 76 | + |
| 77 | + it('throws a non-recoverable BrowserNodeError when a required field is missing', async () => { |
| 78 | + const { page } = fakePage(); |
| 79 | + const h = createBrowserNodeHandler(fakeBrowser(page).browser); |
| 80 | + const err = await h.run(node({ action: 'click' }), ctx).catch((e) => e); |
| 81 | + expect(err).toBeInstanceOf(BrowserNodeError); |
| 82 | + expect(err.recovery.recoverable).toBe(false); |
| 83 | + }); |
| 84 | +}); |
0 commit comments