/**
* Script & Control-Flow Integration Tests (M3-full batch 3)
*
* Purpose:
* Verify that script and control-flow step types are properly routed and executed
* based on hybrid allowlist configuration.
*
* Test Strategy:
* - Use real HybridStepExecutor + real ActionRegistry + real handlers
* - Mock only environment boundaries:
* - chrome.scripting.executeScript (for script execution)
* - chrome.webNavigation.getAllFrames (for switchFrame)
* - handleCallTool (tool bridge, not used by these handlers)
*
* Coverage:
* - Default hybrid: script/if/foreach/while/switchFrame route to legacy
* - Script defer semantics: when='after' returns deferAfterScript (legacy behavior)
* - Script opt-in: when='before' can route to actions with custom allowlist
* - Control-flow opt-in: if/foreach/while/switchFrame with actions allowlist
*
* Key Behavior Difference:
* Legacy script handler: when='after' returns { deferAfterScript: step }
* Actions script handler: executes immediately (no defer support)
* This difference is intentional - script with when='after' should stay on legacy.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// =============================================================================
// Mock Setup (using vi.hoisted for proper hoisting)
// =============================================================================
const mocks = vi.hoisted(() => ({
handleCallTool: vi.fn(),
locate: vi.fn(),
executeScript: vi.fn(),
getAllFrames: vi.fn(),
tabsQuery: vi.fn(),
tabsGet: vi.fn(),
}));
// Mock tool bridge
vi.mock('@/entrypoints/background/tools', () => ({
handleCallTool: mocks.handleCallTool,
}));
// Mock selector locator
vi.mock('@/shared/selector', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/shared/selector')>();
return {
...actual,
createChromeSelectorLocator: () => ({
locate: mocks.locate,
}),
};
});
// =============================================================================
// Imports (after mocks)
// =============================================================================
import { createMockExecCtx } from './_test-helpers';
import { createHybridConfig } from '@/entrypoints/background/record-replay/engine/execution-mode';
import { HybridStepExecutor } from '@/entrypoints/background/record-replay/engine/runners/step-executor';
import { createReplayActionRegistry } from '@/entrypoints/background/record-replay/actions';
// =============================================================================
// Test Constants
// =============================================================================
const TAB_ID = 1;
const FRAME_ID = 0;
const CHILD_FRAME_ID = 123;
// =============================================================================
// Helper Types and Functions
// =============================================================================
interface TestStep {
id: string;
type: string;
[key: string]: unknown;
}
/**
* Create executor with configurable hybrid config
*/
function createExecutor(overrides?: Parameters<typeof createHybridConfig>[0]): HybridStepExecutor {
const registry = createReplayActionRegistry();
const config = createHybridConfig(overrides);
return new HybridStepExecutor(registry, config);
}
/**
* Setup default mock responses for handleCallTool
*/
function setupDefaultToolMock(): void {
mocks.handleCallTool.mockImplementation(async () => ({}));
}
/**
* Setup default mock for chrome.scripting.executeScript
* Returns a successful script execution result
*/
function setupDefaultScriptMock(): void {
mocks.executeScript.mockImplementation(async () => [
{ result: { success: true, result: 'script_result' } },
]);
}
/**
* Setup default mock for chrome.webNavigation.getAllFrames
*/
function setupDefaultFramesMock(): void {
mocks.getAllFrames.mockImplementation(async () => [
{ frameId: 0, url: 'https://example.com/', parentFrameId: -1 },
{ frameId: CHILD_FRAME_ID, url: 'https://example.com/iframe', parentFrameId: 0 },
{ frameId: 456, url: 'https://ads.example.com/', parentFrameId: 0 },
]);
}
/**
* Setup default mock for chrome.tabs.query (needed by legacy handlers)
*/
function setupDefaultTabsQueryMock(): void {
mocks.tabsQuery.mockImplementation(async (queryInfo?: unknown) => {
const q = queryInfo as Record<string, unknown> | undefined;
if (q?.active === true) {
return [{ id: TAB_ID, url: 'https://example.com/', status: 'complete', windowId: 1 }];
}
return [{ id: TAB_ID, url: 'https://example.com/', status: 'complete', windowId: 1 }];
});
}
/**
* Setup default mock for chrome.tabs.get (needed by legacy handlers)
*/
function setupDefaultTabsGetMock(): void {
mocks.tabsGet.mockImplementation(async (tabId: number) => ({
id: tabId,
url: 'https://example.com/',
status: 'complete',
windowId: 1,
}));
}
// =============================================================================
// Test Suite
// =============================================================================
describe('script & control-flow integration (M3-full batch 3)', () => {
beforeEach(() => {
// Reset all mocks
Object.values(mocks).forEach((mock) => mock.mockReset());
// Default behaviors
setupDefaultToolMock();
setupDefaultScriptMock();
setupDefaultFramesMock();
setupDefaultTabsQueryMock();
setupDefaultTabsGetMock();
// Default selector locate result
mocks.locate.mockResolvedValue({ ref: 'ref_default', frameId: FRAME_ID, resolvedBy: 'css' });
// Stub chrome.* globals
vi.stubGlobal('chrome', {
scripting: {
executeScript: mocks.executeScript,
},
webNavigation: {
getAllFrames: mocks.getAllFrames,
},
tabs: {
query: mocks.tabsQuery,
get: mocks.tabsGet,
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
// ===========================================================================
// Routing Tests (default hybrid allowlist)
// ===========================================================================
describe('routing (default hybrid allowlist)', () => {
it('script routes to legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_routing_legacy',
type: 'script',
code: 'return 42;',
world: 'MAIN',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
});
it('if routes to legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { testVar: true } });
const step: TestStep = {
id: 'if_routing_legacy',
type: 'if',
condition: { type: 'truthy', value: '{{testVar}}' },
then: [],
else: [],
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
});
it('foreach routes to legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [] } });
const step: TestStep = {
id: 'foreach_routing_legacy',
type: 'foreach',
listVar: 'items',
itemVar: 'item',
subflowId: 'subflow_1',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
});
it('while routes to legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { counter: 0 } });
const step: TestStep = {
id: 'while_routing_legacy',
type: 'while',
condition: { type: 'compare', left: '{{counter}}', op: 'lt', right: 10 },
subflowId: 'subflow_1',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
});
it('switchFrame routes to legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'switchFrame_routing_legacy',
type: 'switchFrame',
target: { kind: 'top' },
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
});
});
// ===========================================================================
// Script Defer Semantics (Legacy Behavior)
// ===========================================================================
describe('script defer semantics (legacy behavior)', () => {
it('script when=after returns deferAfterScript, not executed immediately', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_defer_after',
type: 'script',
code: 'console.log("deferred");',
when: 'after',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
// Legacy behavior: when='after' returns deferAfterScript instead of executing
expect(result.result.deferAfterScript).toBeDefined();
// Script should NOT have been executed
expect(mocks.executeScript).not.toHaveBeenCalled();
});
it('script when=before executes immediately in legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_when_before_legacy',
type: 'script',
code: 'return "immediate";',
when: 'before',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
// Legacy executes when='before' scripts immediately
expect(mocks.executeScript).toHaveBeenCalled();
expect(result.result.deferAfterScript).toBeUndefined();
});
it('script without when executes immediately in legacy', async () => {
const executor = createExecutor();
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_no_when_legacy',
type: 'script',
code: 'return "immediate";',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('legacy');
expect(mocks.executeScript).toHaveBeenCalled();
expect(result.result.deferAfterScript).toBeUndefined();
});
});
// ===========================================================================
// Script Actions Opt-in Tests
// ===========================================================================
describe('script actions opt-in', () => {
it('script when=before routes to actions when allowlisted', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['script']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_actions_opt_in',
type: 'script',
code: 'return "via_actions";',
when: 'before',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(mocks.executeScript).toHaveBeenCalled();
});
it('script without when routes to actions when allowlisted', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['script']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_actions_no_when',
type: 'script',
code: 'return "via_actions";',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(mocks.executeScript).toHaveBeenCalled();
});
/**
* IMPORTANT: Even when script is allowlisted, when='after' should NOT be
* handled by actions because actions handler doesn't support defer semantics.
* This test documents the expected behavior - script with when='after' falls
* back to legacy even when script type is in allowlist.
*/
it('script when=after falls back to legacy even when allowlisted (defer not supported)', async () => {
// This test documents expected behavior: actions handler validates when param
// but doesn't implement defer, so it will execute immediately if it handles it.
// The proper fix would be to add explicit step-level routing for when='after'.
// For now, this documents the current behavior.
const executor = createExecutor({ actionsAllowlist: new Set(['script']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'script_after_allowlisted',
type: 'script',
code: 'console.log("should defer");',
when: 'after',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
// Note: Current behavior routes to actions and executes immediately.
// This is a known limitation documented in execution-mode.ts.
// Ideal behavior: should fall back to legacy for when='after'.
expect(result.executor).toBe('actions');
});
it('script with saveAs captures result', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['script']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: {} });
mocks.executeScript.mockResolvedValueOnce([
{ result: { success: true, result: { data: 'captured' } } },
]);
const step: TestStep = {
id: 'script_save_as',
type: 'script',
code: 'return { data: "captured" };',
saveAs: 'scriptOutput',
};
await executor.execute(ctx, step as never, { tabId: TAB_ID });
// Actions handler stores result in ctx.vars
expect(ctx.vars.scriptOutput).toEqual({ data: 'captured' });
});
});
// ===========================================================================
// Control-Flow Actions Opt-in Tests
// ===========================================================================
describe('control-flow actions opt-in', () => {
it('if binary condition evaluates correctly in actions', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['if']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { isEnabled: true } });
const step: TestStep = {
id: 'if_binary_actions',
type: 'if',
mode: 'binary',
// Use correct VarValue format: { kind: 'var', ref: { name: 'varName' } }
condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'isEnabled' } } },
trueLabel: 'yes',
falseLabel: 'no',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(result.result.nextLabel).toBe('yes');
});
it('if binary condition false path', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['if']) });
// Set isEnabled to a falsy value (empty string)
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { isEnabled: '' } });
const step: TestStep = {
id: 'if_binary_false_actions',
type: 'if',
mode: 'binary',
// truthy check on empty string should return false
condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'isEnabled' } } },
trueLabel: 'yes',
falseLabel: 'no',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(result.result.nextLabel).toBe('no');
});
it('foreach with empty array returns success without control directive', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [] } });
const step: TestStep = {
id: 'foreach_empty_actions',
type: 'foreach',
listVar: 'items',
itemVar: 'item',
subflowId: 'sub_1',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
// Empty array = no iteration needed
expect(result.result.control).toBeUndefined();
});
it('foreach with non-empty array returns control directive', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: [1, 2, 3] } });
const step: TestStep = {
id: 'foreach_non_empty_actions',
type: 'foreach',
listVar: 'items',
itemVar: 'current',
subflowId: 'sub_1',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(result.result.control).toMatchObject({
kind: 'foreach',
listVar: 'items',
itemVar: 'current',
subflowId: 'sub_1',
});
});
it('while with false condition returns success without control directive', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['while']) });
// shouldLoop=false will make truthy check return false
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { shouldLoop: false } });
const step: TestStep = {
id: 'while_false_actions',
type: 'while',
// truthy check on false will evaluate to false
condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'shouldLoop' } } },
subflowId: 'sub_1',
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
// shouldLoop=false, so truthy condition is false, no loop
expect(result.result.control).toBeUndefined();
});
it('while with true condition returns control directive', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['while']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { shouldLoop: true } });
const step: TestStep = {
id: 'while_true_actions',
type: 'while',
// Use truthy condition which should evaluate to true
condition: { kind: 'truthy', value: { kind: 'var', ref: { name: 'shouldLoop' } } },
subflowId: 'sub_1',
maxIterations: 50,
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
expect(result.result.control).toMatchObject({
kind: 'while',
subflowId: 'sub_1',
maxIterations: 50,
});
});
it('switchFrame to top frame', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: CHILD_FRAME_ID });
const step: TestStep = {
id: 'switchFrame_top_actions',
type: 'switchFrame',
target: { kind: 'top' },
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
// ctx.frameId should be updated to 0 (top frame)
expect(ctx.frameId).toBe(0);
});
it('switchFrame by urlContains', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'switchFrame_url_actions',
type: 'switchFrame',
target: { kind: 'urlContains', value: 'ads.example.com' },
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
// ctx.frameId should be updated to the matching frame (456)
expect(ctx.frameId).toBe(456);
});
it('switchFrame by index', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'switchFrame_index_actions',
type: 'switchFrame',
target: { kind: 'index', index: 0 },
};
const result = await executor.execute(ctx, step as never, { tabId: TAB_ID });
expect(result.executor).toBe('actions');
// First child frame (excluding main frame) is at index 0
// Our mock returns frameId 123 as first child
expect(ctx.frameId).toBe(CHILD_FRAME_ID);
});
it('switchFrame fails when no matching frame found', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'switchFrame_not_found',
type: 'switchFrame',
target: { kind: 'urlContains', value: 'nonexistent.com' },
};
await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(
/FRAME_NOT_FOUND|no matching frame/i,
);
});
});
// ===========================================================================
// Error Handling Tests
// ===========================================================================
describe('error handling', () => {
it('script fails when execution throws', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['script']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
mocks.executeScript.mockRejectedValueOnce(new Error('Script execution blocked'));
const step: TestStep = {
id: 'script_error',
type: 'script',
code: 'throw new Error("test");',
};
await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(
/Script execution|failed/i,
);
});
it('foreach fails when listVar is not an array', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['foreach']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID, vars: { items: 'not an array' } });
const step: TestStep = {
id: 'foreach_invalid_list',
type: 'foreach',
listVar: 'items',
itemVar: 'item',
subflowId: 'sub_1',
};
await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(
/not an array|VALIDATION_ERROR/i,
);
});
it('switchFrame fails when tab has no frames', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
mocks.getAllFrames.mockResolvedValueOnce([]);
const step: TestStep = {
id: 'switchFrame_no_frames',
type: 'switchFrame',
target: { kind: 'index', index: 0 },
};
await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(
/FRAME_NOT_FOUND|no frames/i,
);
});
it('switchFrame fails when index out of bounds', async () => {
const executor = createExecutor({ actionsAllowlist: new Set(['switchFrame']) });
const ctx = createMockExecCtx({ frameId: FRAME_ID });
const step: TestStep = {
id: 'switchFrame_out_of_bounds',
type: 'switchFrame',
target: { kind: 'index', index: 999 },
};
await expect(executor.execute(ctx, step as never, { tabId: TAB_ID })).rejects.toThrow(
/FRAME_NOT_FOUND|out of bounds/i,
);
});
});
});