Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/pipeline/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
2 changes: 1 addition & 1 deletion src/pipeline/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions src/pipeline/steps/intercept.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
24 changes: 16 additions & 8 deletions src/pipeline/steps/intercept.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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] :
Expand Down
21 changes: 21 additions & 0 deletions src/pipeline/steps/tap.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
10 changes: 9 additions & 1 deletion src/pipeline/steps/tap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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('')
Expand Down Expand Up @@ -96,5 +104,5 @@ export async function stepTap(page: IPage | null, params: any, data: any, args:
}
`;

return page!.evaluate(js);
return page.evaluate(js);
}