src/lib/demo/step-executor.ts

Total Symbols
17
Lines of Code
484
Avg Complexity
7.1
Symbol Types
1

File Relationships

graph LR executeStep["executeStep"] handleNavigate["handleNavigate"] handleFill["handleFill"] handleClick["handleClick"] handleSelect["handleSelect"] handleCheck["handleCheck"] handleClear["handleClear"] handleWait["handleWait"] handleScroll["handleScroll"] handlePressKey["handlePressKey"] handleAssert["handleAssert"] handleCaption["handleCaption"] requireElement["requireElement"] findElement["findElement"] ensureVisible["ensureVisible"] sleep["sleep"] highlightElement["highlightElement"] executeStep -->|calls| handleNavigate executeStep -->|calls| handleFill executeStep -->|calls| handleClick executeStep -->|calls| handleSelect executeStep -->|calls| handleCheck executeStep -->|calls| handleClear executeStep -->|calls| handleWait executeStep -->|calls| handleScroll executeStep -->|calls| handlePressKey executeStep -->|calls| handleAssert executeStep -->|calls| handleCaption requireElement -->|calls| findElement handleFill -->|calls| requireElement handleFill -->|calls| ensureVisible handleFill -->|calls| sleep handleClick -->|calls| requireElement handleClick -->|calls| ensureVisible handleSelect -->|calls| requireElement handleSelect -->|calls| ensureVisible handleCheck -->|calls| requireElement handleCheck -->|calls| ensureVisible handleClear -->|calls| requireElement handleClear -->|calls| ensureVisible handleWait -->|calls| sleep handleWait -->|calls| findElement handlePressKey -->|calls| findElement handleAssert -->|calls| findElement highlightElement -->|calls| findElement click executeStep "../symbols/a26ccfb820921de2.html" click handleNavigate "../symbols/fa502f0b873556f6.html" click handleFill "../symbols/8f674e688d002684.html" click handleClick "../symbols/4b4f336a7104e106.html" click handleSelect "../symbols/3c2550d444ab28e5.html" click handleCheck "../symbols/925d9bcf5b79be5d.html" click handleClear "../symbols/9ce51648e6aea0e1.html" click handleWait "../symbols/1e0c6f2b3548f948.html" click handleScroll "../symbols/683f3c239bc85481.html" click handlePressKey "../symbols/195a61c527b09c31.html" click handleAssert "../symbols/fb80a5184e1878d1.html" click handleCaption "../symbols/96ad0deafa86fab8.html" click requireElement "../symbols/54b35883ca14f405.html" click findElement "../symbols/db37f185eea489b4.html" click ensureVisible "../symbols/12336ba1f5ec5dde.html" click sleep "../symbols/25404f77365ed229.html" click highlightElement "../symbols/d623ce50223cce1c.html"

Architecture violations

View all

  • [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);
}