diff --git a/src/pipeline/executor.test.ts b/src/pipeline/executor.test.ts index 2bc373ad..c80ac9fb 100644 --- a/src/pipeline/executor.test.ts +++ b/src/pipeline/executor.test.ts @@ -189,4 +189,33 @@ describe('executePipeline', () => { expect(result).toEqual([{ a: 1 }]); expect(page.goto).toHaveBeenCalledWith('https://example.com'); }); + + it('retries intercept step on transient browser error', async () => { + // trigger is empty, so each attempt calls evaluate twice: + // once for interceptor inject, once for reading intercepted data + const page = createMockPage({ + evaluate: vi.fn() + .mockRejectedValueOnce(new Error('Extension disconnected')) + .mockResolvedValueOnce(undefined) // interceptor inject (retry) + .mockResolvedValueOnce([{ id: 1 }]), // read intercepted + }); + const result = await executePipeline(page, [ + { intercept: { capture: '/api/data' } }, + ]); + expect(page.evaluate).toHaveBeenCalledTimes(3); + expect(result).toEqual({ id: 1 }); + }); + + it('retries tap step on transient browser error', async () => { + const page = createMockPage({ + evaluate: vi.fn() + .mockRejectedValueOnce(new Error('attach failed')) + .mockResolvedValueOnce({ data: 'ok' }), + }); + const result = await executePipeline(page, [ + { tap: { store: 'myStore', action: 'fetch', capture: '/api' } }, + ]); + expect(page.evaluate).toHaveBeenCalledTimes(2); + expect(result).toEqual({ data: 'ok' }); + }); }); diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts index 14133b6f..97e99004 100644 --- a/src/pipeline/executor.ts +++ b/src/pipeline/executor.ts @@ -16,7 +16,7 @@ export interface PipelineContext { } /** Steps that interact with the browser and may fail transiently */ -const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot']); +const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'intercept', 'tap']); export async function executePipeline( page: IPage | null, diff --git a/src/pipeline/steps/intercept.test.ts b/src/pipeline/steps/intercept.test.ts new file mode 100644 index 00000000..cfe83ee3 --- /dev/null +++ b/src/pipeline/steps/intercept.test.ts @@ -0,0 +1,56 @@ +/** + * Tests for pipeline step: intercept + */ + +import { describe, it, expect, vi } from 'vitest'; +import { stepIntercept } from './intercept.js'; +import { ConfigError } from '../../errors.js'; +import type { IPage } from '../../types.js'; + +/** Minimal mock page that records wait() calls */ +function createMockPage(overrides: Partial = {}): IPage { + return { + goto: vi.fn(), + evaluate: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + snapshot: vi.fn().mockResolvedValue(''), + click: vi.fn(), + typeText: vi.fn(), + pressKey: vi.fn(), + getFormState: vi.fn().mockResolvedValue({}), + wait: vi.fn(), + tabs: vi.fn().mockResolvedValue([]), + closeTab: vi.fn(), + newTab: vi.fn(), + selectTab: vi.fn(), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue(''), + scroll: vi.fn(), + scrollTo: vi.fn(), + autoScroll: vi.fn(), + installInterceptor: vi.fn(), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + ...overrides, + }; +} + +describe('stepIntercept', () => { + it('throws ConfigError when page is null and capture is set', async () => { + await expect( + stepIntercept(null, { capture: '/api/data' }, {}, {}), + ).rejects.toThrow(ConfigError); + }); + + it('returns data without error when capture is empty and page is null', async () => { + const input = { items: [1, 2, 3] }; + const result = await stepIntercept(null, { capture: '' }, input, {}); + expect(result).toBe(input); + }); + + it('passes timeout value to page.wait without truncation', async () => { + const page = createMockPage(); + await stepIntercept(page, { capture: '/api/data', timeout: 10 }, {}, {}); + expect(page.wait).toHaveBeenCalledWith(10); + }); +}); diff --git a/src/pipeline/steps/intercept.ts b/src/pipeline/steps/intercept.ts index 26b60d9e..b84aa406 100644 --- a/src/pipeline/steps/intercept.ts +++ b/src/pipeline/steps/intercept.ts @@ -3,6 +3,7 @@ */ import type { IPage } from '../../types.js'; +import { ConfigError } from '../../errors.js'; import { render, normalizeEvaluateSource } from '../template.js'; import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js'; @@ -15,28 +16,35 @@ export async function stepIntercept(page: IPage | null, params: any, data: any, if (!capturePattern) return data; + if (!page) { + throw new ConfigError( + 'intercept step requires a browser session', + 'Set browser: true in your command definition.', + ); + } + // Step 1: Inject fetch/XHR interceptor BEFORE trigger - await page!.evaluate(generateInterceptorJs(JSON.stringify(capturePattern))); + await page.evaluate(generateInterceptorJs(JSON.stringify(capturePattern))); // Step 2: Execute the trigger action if (trigger.startsWith('navigate:')) { const url = render(trigger.slice('navigate:'.length), { args, data }); - await page!.goto(String(url)); + await page.goto(String(url)); } else if (trigger.startsWith('evaluate:')) { const js = trigger.slice('evaluate:'.length); - await page!.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); + await page.evaluate(normalizeEvaluateSource(render(js, { args, data }) as string)); } else if (trigger.startsWith('click:')) { const ref = render(trigger.slice('click:'.length), { args, data }); - await page!.click(String(ref).replace(/^@/, '')); + await page.click(String(ref).replace(/^@/, '')); } else if (trigger === 'scroll') { - await page!.scroll('down'); + await page.scroll('down'); } - // Step 3: Wait a bit for network requests to fire - await page!.wait(Math.min(timeout, 3)); + // Step 3: Wait for network requests to complete (default 8s) + await page.wait(timeout); // Step 4: Retrieve captured data - const matchingResponses = await page!.evaluate(generateReadInterceptedJs()); + const matchingResponses = await page.evaluate(generateReadInterceptedJs()); // Step 5: Select from response if specified let result = matchingResponses.length === 1 ? matchingResponses[0] : diff --git a/src/pipeline/steps/tap.test.ts b/src/pipeline/steps/tap.test.ts new file mode 100644 index 00000000..69b19115 --- /dev/null +++ b/src/pipeline/steps/tap.test.ts @@ -0,0 +1,21 @@ +/** + * Tests for pipeline step: tap + */ + +import { describe, it, expect } from 'vitest'; +import { stepTap } from './tap.js'; +import { ConfigError } from '../../errors.js'; + +describe('stepTap', () => { + it('throws ConfigError when page is null', async () => { + await expect( + stepTap(null, { store: 'myStore', action: 'fetchData', capture: '/api' }, {}, {}), + ).rejects.toThrow(ConfigError); + }); + + it('throws parameter error before ConfigError when store is missing and page is null', async () => { + await expect( + stepTap(null, { store: '', action: 'fetchData', capture: '/api' }, {}, {}), + ).rejects.toThrow('tap: store and action are required'); + }); +}); diff --git a/src/pipeline/steps/tap.ts b/src/pipeline/steps/tap.ts index 9a58a20f..28d4adde 100644 --- a/src/pipeline/steps/tap.ts +++ b/src/pipeline/steps/tap.ts @@ -10,6 +10,7 @@ */ import type { IPage } from '../../types.js'; +import { ConfigError } from '../../errors.js'; import { render } from '../template.js'; import { generateTapInterceptorJs } from '../../interceptor.js'; @@ -25,6 +26,13 @@ export async function stepTap(page: IPage | null, params: any, data: any, args: if (!storeName || !actionName) throw new Error('tap: store and action are required'); + if (!page) { + throw new ConfigError( + 'tap step requires a browser session', + 'Set browser: true in your command definition.', + ); + } + // Build select chain for the captured response const selectChain = selectPath ? selectPath.split('.').map((p: string) => `?.[${JSON.stringify(p)}]`).join('') @@ -96,5 +104,5 @@ export async function stepTap(page: IPage | null, params: any, data: any, args: } `; - return page!.evaluate(js); + return page.evaluate(js); }