src/lib/demo/step-executor.ts
File Relationships
Architecture violations
- [warning] max-cyclomatic-complexity: 'executeStep' has cyclomatic complexity 27 (max 10)
- [warning] max-cyclomatic-complexity: 'handleSelect' has cyclomatic complexity 11 (max 10)
- [warning] max-cyclomatic-complexity: 'handleAssert' has cyclomatic complexity 28 (max 10)
- [warning] max-lines: 'executeStep' has 99 lines (max 80)
Symbols by Kind
function
17
All Symbols
| Name | Kind | Visibility | Status | Lines | Signature |
|---|---|---|---|---|---|
| executeStep | function | exported- | 31-129 | executeStep(
payload: ExecuteStepPayload,
): : Promise<StepResult> |
|
| ensureVisible | function | - | 137-146 | ensureVisible(el: Element): : void |
|
| findElement | function | - | 148-171 | findElement(step: FlowStep): : Element | null |
|
| requireElement | function | - | 173-181 | requireElement(step: FlowStep): : Element |
|
| handleNavigate | function | - | 185-192 | handleNavigate(step: FlowStep): : StepResult |
|
| handleCaption | function | - | 194-200 | handleCaption(step: FlowStep): : Promise<StepResult> |
|
| handleFill | function | - | 202-242 | handleFill(
step: FlowStep,
resolvedValue: string | undefined,
config: ReplayConfig,
): : Promise<StepResult> |
|
| handleClick | function | - | 244-256 | handleClick(step: FlowStep): : StepResult |
|
| handleSelect | function | - | 258-291 | handleSelect(step: FlowStep): : StepResult |
|
| handleCheck | function | - | 293-308 | handleCheck(step: FlowStep, checked: boolean): : StepResult |
|
| handleClear | function | - | 310-321 | handleClear(step: FlowStep): : StepResult |
|
| handleWait | function | - | 323-342 | handleWait(step: FlowStep): : Promise<StepResult> |
|
| handleScroll | function | - | 344-353 | handleScroll(step: FlowStep): : StepResult |
|
| handlePressKey | function | - | 355-381 | handlePressKey(step: FlowStep): : StepResult |
|
| handleAssert | function | - | 383-456 | handleAssert(step: FlowStep): : StepResult |
|
| sleep | function | - | 460-462 | sleep(ms: number): : Promise<void> |
|
| highlightElement | function | exported- | 467-483 | highlightElement(step: FlowStep, durationMs: number): : void |
Full Source
/**
* Step Executor — runs a single FlowStep in the content script context.
*
* Receives an `ExecuteStepPayload` (step + resolved value + config) from
* the background orchestrator and performs the corresponding DOM action.
*
* Every action is wrapped in a try/catch so the step result is always
* reported back (never throws).
*/
import { createLogger } from "@/lib/logger";
import type {
FlowStep,
StepResult,
ReplayConfig,
ExecuteStepPayload,
} from "./demo.types";
import { applyStepEffects, showCaption, cancelActiveZoom } from "./effects";
import { DEFAULT_EFFECT_TIMING } from "./effects/effect.types";
import type { StepEffect } from "./effects/effect.types";
const log = createLogger("StepExecutor");
// ── Public API ────────────────────────────────────────────────────────────
/**
* Execute a single flow step in the current page.
*
* Returns a `StepResult` describing the outcome. Never throws.
*/
export async function executeStep(
payload: ExecuteStepPayload,
): Promise<StepResult> {
const { step, resolvedValue, replayConfig } = payload;
try {
let result: StepResult;
const selector =
step.smartSelectors?.[0]?.value ?? step.selector ?? undefined;
// Partition effects by timing (respecting per-kind defaults)
const allEffects: StepEffect[] = step.effects ?? [];
const byTiming = (t: "before" | "during" | "after") =>
allEffects.filter(
(e) => (e.timing ?? DEFAULT_EFFECT_TIMING[e.kind]) === t,
);
const beforeEffects = byTiming("before");
const duringEffects = byTiming("during");
const afterEffects = byTiming("after");
// 1. "before" effects complete fully before the action starts
if (beforeEffects.length) await applyStepEffects(beforeEffects, selector);
// 2. "during" effects start concurrently with the action
const duringPromise = duringEffects.length
? applyStepEffects(duringEffects, selector)
: Promise.resolve();
switch (step.action) {
case "navigate":
result = handleNavigate(step);
break;
case "fill":
result = await handleFill(step, resolvedValue, replayConfig);
break;
case "click":
result = handleClick(step);
break;
case "select":
result = handleSelect(step);
break;
case "check":
result = handleCheck(step, true);
break;
case "uncheck":
result = handleCheck(step, false);
break;
case "clear":
result = handleClear(step);
break;
case "wait":
result = await handleWait(step);
break;
case "scroll":
result = handleScroll(step);
break;
case "press-key":
result = handlePressKey(step);
break;
case "assert":
result = handleAssert(step);
break;
case "caption":
result = await handleCaption(step);
break;
default:
return { status: "skipped", reason: `Unknown action: ${step.action}` };
}
// Wait for "during" effects (they run in parallel with the action)
await duringPromise;
// Check if any "during" zoom effect was indefinite (duration 0 or Infinity)
// If so, cancel it now that the action is complete
const hasIndefiniteZoom = duringEffects.some(
(e) => e.kind === "zoom" && (e.duration === 0 || e.duration === Infinity),
);
if (hasIndefiniteZoom) {
cancelActiveZoom();
}
// 3. "after" effects run once the action has finished successfully
if (result.status === "success" && afterEffects.length) {
await applyStepEffects(afterEffects, selector);
}
return result;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`Step ${step.id} failed:`, message);
if (step.optional) {
return { status: "skipped", reason: message };
}
return { status: "failed", error: message };
}
}
// ── Element resolution ────────────────────────────────────────────────────
/**
* Scrolls element into view if it lies outside the visible viewport.
* Uses instant scrolling to avoid animations that would delay replay timing.
*/
function ensureVisible(el: Element): void {
const rect = el.getBoundingClientRect();
const inViewport =
rect.top >= 0 &&
rect.bottom <=
(window.innerHeight || document.documentElement.clientHeight);
if (!inViewport) {
el.scrollIntoView({ behavior: "instant", block: "center" });
}
}
function findElement(step: FlowStep): Element | null {
// Try smart selectors first (ordered by priority)
if (step.smartSelectors?.length) {
for (const ss of step.smartSelectors) {
try {
const el = document.querySelector(ss.value);
if (el) return el;
} catch {
// invalid selector — skip
}
}
}
// Fallback to primary selector
if (step.selector) {
try {
return document.querySelector(step.selector);
} catch {
return null;
}
}
return null;
}
function requireElement(step: FlowStep): Element {
const el = findElement(step);
if (!el) {
throw new Error(
`Element not found: ${step.selector ?? step.smartSelectors?.[0]?.value ?? "(no selector)"}`,
);
}
return el;
}
// ── Action handlers ───────────────────────────────────────────────────────
function handleNavigate(step: FlowStep): StepResult {
if (!step.url) {
return { status: "failed", error: "Navigate step missing url" };
}
// Navigation handled by orchestrator via chrome.tabs.update — this is a no-op
// when executed in content script context. Return success so orchestrator proceeds.
return { status: "success" };
}
async function handleCaption(step: FlowStep): Promise<StepResult> {
if (!step.caption) {
return { status: "skipped", reason: "Caption step missing caption config" };
}
await showCaption(step.caption);
return { status: "success" };
}
async function handleFill(
step: FlowStep,
resolvedValue: string | undefined,
config: ReplayConfig,
): Promise<StepResult> {
const el = requireElement(step);
ensureVisible(el);
if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
return { status: "failed", error: "Fill target is not an input/textarea" };
}
const value = resolvedValue ?? "";
// Clear existing value first
el.value = "";
el.dispatchEvent(new Event("input", { bubbles: true }));
// Simulate typing character by character
if (config.typingDelay > 0 && value.length > 0) {
for (const char of value) {
el.value += char;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(
new KeyboardEvent("keydown", { key: char, bubbles: true }),
);
el.dispatchEvent(
new KeyboardEvent("keyup", { key: char, bubbles: true }),
);
await sleep(config.typingDelay);
}
} else {
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
}
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("blur", { bubbles: true }));
return { status: "success" };
}
function handleClick(step: FlowStep): StepResult {
const el = requireElement(step);
ensureVisible(el);
if (el instanceof HTMLElement) {
el.focus();
el.click();
} else {
el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
}
return { status: "success" };
}
function handleSelect(step: FlowStep): StepResult {
const el = requireElement(step);
ensureVisible(el);
if (!(el instanceof HTMLSelectElement)) {
return { status: "failed", error: "Select target is not a <select>" };
}
if (step.selectIndex != null) {
if (step.selectIndex >= 0 && step.selectIndex < el.options.length) {
el.selectedIndex = step.selectIndex;
} else {
return {
status: "failed",
error: `Select index ${step.selectIndex} out of range`,
};
}
} else if (step.selectText != null) {
const option = Array.from(el.options).find(
(o) => o.text === step.selectText || o.value === step.selectText,
);
if (option) {
el.value = option.value;
} else {
return {
status: "failed",
error: `Option "${step.selectText}" not found`,
};
}
}
el.dispatchEvent(new Event("change", { bubbles: true }));
return { status: "success" };
}
function handleCheck(step: FlowStep, checked: boolean): StepResult {
const el = requireElement(step);
ensureVisible(el);
if (!(el instanceof HTMLInputElement)) {
return { status: "failed", error: "Check target is not an input" };
}
if (el.checked !== checked) {
el.checked = checked;
el.dispatchEvent(new Event("change", { bubbles: true }));
el.dispatchEvent(new Event("click", { bubbles: true }));
}
return { status: "success" };
}
function handleClear(step: FlowStep): StepResult {
const el = requireElement(step);
ensureVisible(el);
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
el.value = "";
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
return { status: "success" };
}
async function handleWait(step: FlowStep): Promise<StepResult> {
const timeout = step.waitTimeout ?? 10_000;
if (!step.selector) {
// Simple delay
await sleep(timeout);
return { status: "success" };
}
// Wait for element to appear
const start = Date.now();
while (Date.now() - start < timeout) {
if (findElement(step)) {
return { status: "success" };
}
await sleep(200);
}
return { status: "timeout" };
}
function handleScroll(step: FlowStep): StepResult {
if (step.scrollPosition) {
window.scrollTo({
left: step.scrollPosition.x,
top: step.scrollPosition.y,
behavior: "smooth",
});
}
return { status: "success" };
}
function handlePressKey(step: FlowStep): StepResult {
if (!step.key) {
return { status: "failed", error: "press-key step missing key" };
}
const target = step.selector ? findElement(step) : document.activeElement;
if (!target) {
return { status: "failed", error: "No target for key press" };
}
target.dispatchEvent(
new KeyboardEvent("keydown", { key: step.key, bubbles: true }),
);
target.dispatchEvent(
new KeyboardEvent("keyup", { key: step.key, bubbles: true }),
);
// Handle Enter on forms
if (step.key === "Enter" && target instanceof HTMLElement) {
const form = target.closest("form");
if (form) {
form.dispatchEvent(new Event("submit", { bubbles: true }));
}
}
return { status: "success" };
}
function handleAssert(step: FlowStep): StepResult {
if (!step.assertion) {
return { status: "skipped", reason: "Assert step has no assertion config" };
}
const { operator, expected } = step.assertion;
switch (operator) {
case "visible": {
const el = findElement(step);
if (!el)
return { status: "failed", error: "Element not visible (not found)" };
if (el instanceof HTMLElement && el.offsetParent === null) {
return { status: "failed", error: "Element not visible (hidden)" };
}
return { status: "success" };
}
case "hidden": {
const el = findElement(step);
if (!el) return { status: "success" }; // not found = hidden
if (el instanceof HTMLElement && el.offsetParent === null) {
return { status: "success" };
}
return { status: "failed", error: "Element is visible" };
}
case "exists": {
const el = findElement(step);
return el
? { status: "success" }
: { status: "failed", error: "Element does not exist" };
}
case "equals": {
const el = findElement(step);
if (!el) return { status: "failed", error: "Element not found" };
const text =
el instanceof HTMLInputElement ? el.value : (el.textContent ?? "");
return text === expected
? { status: "success" }
: { status: "failed", error: `Expected "${expected}", got "${text}"` };
}
case "contains": {
const el = findElement(step);
if (!el) return { status: "failed", error: "Element not found" };
const text =
el instanceof HTMLInputElement ? el.value : (el.textContent ?? "");
return expected && text.includes(expected)
? { status: "success" }
: { status: "failed", error: `Text does not contain "${expected}"` };
}
case "url-equals":
return window.location.href === expected
? { status: "success" }
: {
status: "failed",
error: `URL: expected "${expected}", got "${window.location.href}"`,
};
case "url-contains":
return expected && window.location.href.includes(expected)
? { status: "success" }
: { status: "failed", error: `URL does not contain "${expected}"` };
default:
return {
status: "skipped",
reason: `Unknown assertion operator: ${operator}`,
};
}
}
// ── Utilities ─────────────────────────────────────────────────────────────
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Highlight an element briefly before interaction for visual feedback.
*/
export function highlightElement(step: FlowStep, durationMs: number): void {
if (durationMs <= 0) return;
const el = findElement(step);
if (!(el instanceof HTMLElement)) return;
const originalOutline = el.style.outline;
const originalTransition = el.style.transition;
el.style.transition = "outline 0.15s ease";
el.style.outline = "3px solid #4285f4";
setTimeout(() => {
el.style.outline = originalOutline;
el.style.transition = originalTransition;
}, durationMs);
}