Newer
Older
bugtrail / packages / extension / src / content / index.ts
/**
 * Content script bootstrap — deliberately minimal:
 * no UI, no network; mounts the shadow-DOM overlay, relays DOM events
 * and (while recording) captures clicks/inputs for the background buffer.
 */
import browser from "webextension-polyfill";
import type { ElementContext } from "@ltt/shared";
import { mountOverlay } from "./overlay/mount";
import { buildElementContext, collectEnvironment } from "../lib/selector";
import { pushUiHidden, popUiHidden } from "./uiVisibility";

let overlay: ReturnType<typeof mountOverlay> | null = null;

function getOverlay() {
  if (!overlay) overlay = mountOverlay();
  return overlay;
}

// ---------- recorder capture (event listeners, no UI) ----------

let captureListeners: (() => void) | null = null;

/** Longest input debounced per element; flushes a single input event per pause. */
const pendingInputs = new Map<Element, { timer: number; element: ElementContext }>();

function sendRecorderEvent(type: "click" | "input", element: ElementContext, data: Record<string, unknown>) {
  void browser.runtime
    .sendMessage({
      type: "recorder_event",
      event: { type, data: { element, ...data }, at: Date.now() },
    })
    .catch(() => {});
}

function flushInput(element: Element) {
  const pending = pendingInputs.get(element);
  if (!pending) return;
  pendingInputs.delete(element);
  window.clearTimeout(pending.timer);
  const input = element as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
  const isPassword = (input as HTMLInputElement).type === "password";
  const value = isPassword ? null : input.value.slice(0, 500);
  sendRecorderEvent("input", pending.element, {
    value_length: input.value.length,
    value,
    input_type: (input as HTMLInputElement).type ?? null,
  });
}

function isOurOverlay(target: EventTarget | null): boolean {
  // events originating in the closed shadow root are retargeted to the host
  return target instanceof Element && target.id === "ltt-overlay-host";
}

function attachCaptureListeners() {
  if (captureListeners) return;

  const onClick = (event: MouseEvent) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (!(target instanceof Element)) return;
    sendRecorderEvent("click", buildElementContext(target), {
      button: event.button,
    });
  };

  const onInput = (event: Event) => {
    if (isOurOverlay(event.target)) return;
    const target = event.target;
    if (
      !(target instanceof HTMLInputElement) &&
      !(target instanceof HTMLTextAreaElement) &&
      !(target instanceof HTMLSelectElement)
    ) {
      return true;
    }
    const existing = pendingInputs.get(target);
    if (existing) window.clearTimeout(existing.timer);
    const timer = window.setTimeout(() => flushInput(target), 250);
    pendingInputs.set(target, { timer, element: buildElementContext(target) });
  };

  document.addEventListener("click", onClick, true);
  document.addEventListener("input", onInput, true);
  captureListeners = () => {
    document.removeEventListener("click", onClick, true);
    document.removeEventListener("input", onInput, true);
    for (const [element, pending] of pendingInputs) window.clearTimeout(pending.timer);
    pendingInputs.clear();
  };
}

function detachCaptureListeners() {
  if (!captureListeners) return;
  captureListeners();
  captureListeners = null;
}

// ---------- replay (executes recorded steps on the page) ----------

function resolveRecordedElement(element: unknown): Element | null {
  const context = element as { selector?: string | null; unique_selector?: string | null } | null;
  if (!context) return null;
  for (const selector of [context.unique_selector, context.selector]) {
    if (!selector) continue;
    try {
      const found = document.querySelector(selector);
      if (found) return found;
    } catch {
      // invalid selector — try the next one
    }
  }
  return null;
}

function replayStep(data: Record<string, unknown>): { ok: boolean } {
  const target = resolveRecordedElement(data.element);
  if (!target) return { ok: false };
  const value = typeof data.value === "string" ? data.value : undefined;
  const isField =
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    target instanceof HTMLSelectElement;
  if (value !== undefined && isField) {
    const field = target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
    field.value = value;
    field.dispatchEvent(new Event("input", { bubbles: true }));
    field.dispatchEvent(new Event("change", { bubbles: true }));
  } else {
    (target as HTMLElement).click();
  }
  return { ok: true };
}

// ---------- message wiring ----------

browser.runtime.onMessage.addListener((message, _sender, sendResponse) => {
  const msg = message as { type: string; [key: string]: unknown };
  switch (msg.type) {
    case "ping":
      sendResponse({ ok: true, data: "pong" });
      return true;

    case "start_picker":
      getOverlay().startPicker();
      sendResponse({ ok: true });
      return true;

    case "recorder_state": {
      const recording = Boolean((message as { recording?: unknown }).recording);
      getOverlay().setRecorderState(recording);
      if (recording) {
        attachCaptureListeners();
        void browser.runtime
          .sendMessage({ type: "recorder_environment", environment: collectEnvironment() })
          .catch(() => {});
      } else {
        detachCaptureListeners();
      }
      sendResponse({ ok: true });
      return true;
    }

    case "replay_step": {
      const result = replayStep((msg.data ?? {}) as Record<string, unknown>);
      sendResponse({ ok: result.ok });
      return true;
    }

    case "show_flash":
      getOverlay().setFlash(typeof msg.text === "string" ? msg.text : null);
      sendResponse({ ok: true });
      return true;

    case "set_ui_hidden":
      // the background hides all plugin UI while it grabs a screenshot
      if (Boolean(msg.hidden)) pushUiHidden();
      else popUiHidden();
      sendResponse({ ok: true });
      return true;

    default:
      return true;
  }
});