Newer
Older
bugtrail / packages / extension / e2e.mjs
/**
 * E2E smoke test for the extension (chrome target) against the dev server:
 * register a user via API, sign in through the options page, pick an element
 * on a local test page, and submit a note report.
 * Usage: node e2e.mjs   (server on :8001 must be running)
 */
import { chromium } from "playwright-core";
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";

const root = dirname(fileURLToPath(import.meta.url));
const SERVER = "http://localhost:8001";
const PAGE_PORT = 8899;
const stamp = Date.now();
const EMAIL = `ext-e2e-${stamp}@example.com`;
const PASSWORD = "e2e-password-1";

const pageHtml = `<!doctype html><html><head><title>E2E target page</title></head>
<body style="font-family: sans-serif">
  <h1>E2E page</h1>
  <button id="target" data-testid="buggy-button">Buggy button</button>
  <form onsubmit="return false">
    <input id="name-input" placeholder="type here" />
  </form>
</body></html>`;

const httpServer = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/html" });
  res.end(pageHtml);
});
await new Promise((resolve) => httpServer.listen(PAGE_PORT, resolve));

async function api(path, options = {}) {
  const response = await fetch(SERVER + path, options);
  if (!response.ok) throw new Error(`API ${path} -> ${response.status}: ${await response.text()}`);
  return response.json();
}

// --- seed: user + project via API ---
const user = await api("/api/auth/register", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ nickname: `ext-e2e-${stamp}`, email: EMAIL, password: PASSWORD }),
});
const login = await api("/api/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Client": "extension" },
  body: JSON.stringify({ email: EMAIL, password: PASSWORD }),
});
const project = await api("/api/projects", {
  method: "POST",
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` },
  body: JSON.stringify({ name: "Extension E2E" }),
});
console.log("seeded user+project:", project.id);

// --- launch with the extension ---
const executablePath = join(homedir(), ".cache/ms-playwright/chromium-1234/chrome-linux64/chrome");
const context = await chromium.launchPersistentContext("", {
  executablePath,
  headless: true,
  args: [
    `--disable-extensions-except=${join(root, "dist/chrome")}`,
    `--load-extension=${join(root, "dist/chrome")}`,
  ],
});

let [worker] = context.serviceWorkers();
if (!worker) worker = await context.waitForEvent("serviceworker", { timeout: 10000 });
const extensionId = new URL(worker.url()).host;
console.log("extension id:", extensionId);

try {
  // --- options page: sign in, choose default project ---
  const options = await context.newPage();
  await options.goto(`chrome-extension://${extensionId}/src/options/options.html`);
  await options.fill('input[type="email"]', EMAIL);
  await options.fill('input[type="password"]', PASSWORD);
  await options.click("text=Sign in");
  await options.waitForSelector("text=Default project", { timeout: 10000 });
  await options.selectOption("select", project.id);
  console.log("options page: signed in, project selected");

  // --- target page: pick an element and submit a note ---
  const page = await context.newPage();
  await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: `http://localhost:${PAGE_PORT}` });
  page.on("console", (msg) => {
    if (msg.type() === "error") console.log("[page error]", msg.text());
  });
  await page.goto(`http://localhost:${PAGE_PORT}/`);
  // headless Chromium doesn't deliver extension shortcut keys; trigger the
  // same code path the toolbar button runs through the debug hook
  await worker.evaluate(() => self.__lttTriggerAction());
  await page.waitForTimeout(1000);
  // the picker overlay intentionally intercepts pointer events, so use raw
  // mouse events at the target's coordinates instead of locator.hover/click
  const box = await page.locator("#target").boundingBox();
  if (!box) throw new Error("target not found");
  await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
  await page.waitForTimeout(300);
  await page.mouse.down();
  await page.mouse.up();

  // the composer autofocuses its title input; drive it with real keys since
  // Playwright locators can't pierce the closed shadow root. The region
  // selector sits between the pick and the composer — Enter accepts the
  // whole-page screenshot.
  await page.waitForTimeout(1500); // picker click → region selector mount + capture
  await page.keyboard.press("Enter"); // whole-page screenshot option
  await page.waitForTimeout(1500); // confirm → composer mount + focus
  await page.keyboard.type("Button looks broken after hover");
  await page.keyboard.press("Tab");
  await page.keyboard.type("Repro: hover, then click.");
  await page.keyboard.press("Control+Enter");
  await page.waitForTimeout(4000); // capture + upload + submit
  await page.screenshot({ path: "/tmp/ltt-e2e-after-submit.png" });

  // --- verify the report landed on the server ---
  const reports = await api(`/api/projects/${project.id}/reports`, {
    headers: { Authorization: `Bearer ${login.token}` },
  });
  const items = reports.items ?? reports;
  if (!items.length) throw new Error("No reports found after submit");
  const report = await api(`/api/reports/${items[0].share_token}`);
  console.log("report:", JSON.stringify({ title: report.title, selector: report.element?.selector }));
  const noteOk =
    report.title.includes("Button looks broken") &&
    report.element?.selector?.includes("buggy-button") &&
    report.attachments?.length > 0;

  // the share link must land in the clipboard automatically after submit
  let clipboardOk = false;
  try {
    const clipboard = await page.evaluate(() => navigator.clipboard.readText());
    // the share link points at the web panel (panelUrl), not the API server
    const expected = `http://localhost:5173/r/${items[0].share_token}`;
    clipboardOk = clipboard === expected;
    if (!clipboardOk) console.log("[clipboard]", JSON.stringify({ got: clipboard, expected }));
  } catch (error) {
    console.log("[clipboard read failed]", String(error).slice(0, 120));
  }

  // --- recorder: start, move the mouse around, click the button, type, stop ---
  // human-like movement: several move events with pauses so the cursor
  // sampler (~25 Hz) actually accumulates a track
  const wiggle = async (box) => {
    for (let i = 0; i < 6; i++) {
      await page.mouse.move(box.x + box.width / 2 + i * 4, box.y + box.height / 2 - i * 2);
      await page.waitForTimeout(70);
    }
  };
  await worker.evaluate(() => self.__lttToggleRecorder());
  await page.waitForTimeout(800);
  const buttonBox = await page.locator("#target").boundingBox();
  await wiggle(buttonBox);
  await page.mouse.click(buttonBox.x + box.width / 2, buttonBox.y + buttonBox.height / 2);
  const inputBox = await page.locator("#name-input").boundingBox();
  await wiggle(inputBox);
  await page.mouse.click(inputBox.x + 10, inputBox.y + inputBox.height / 2);
  await page.keyboard.type("Hello recorder");
  // full navigation in the middle of the recording: capture must resume on
  // the new page and the report must keep the *initial* URL
  await page.goto(`http://localhost:${PAGE_PORT}/?page=2`);
  await page.waitForTimeout(1500); // new document: recorder_state re-sent on complete
  await wiggle(buttonBox);
  await page.mouse.click(buttonBox.x + box.width / 2, buttonBox.y + buttonBox.height / 2);
  await page.waitForTimeout(600); // let the input debounce flush
  await worker.evaluate(() => self.__lttToggleRecorder());
  await page.waitForTimeout(4000); // upload step screenshots + submit

  const itemsAfter = (await api(`/api/projects/${project.id}/reports`, {
    headers: { Authorization: `Bearer ${login.token}` },
  }));
  const all = itemsAfter.items ?? itemsAfter;
  const recordingSummary = await api(`/api/reports/${all[0].share_token}`);
  const steps = recordingSummary.steps ?? [];
  // the track is delivered as an animated GIF attachment instead of step screenshots
  const gifAttachment = (recordingSummary.attachments ?? []).find((a) => a.mime === "image/gif");
  if (gifAttachment) {
    const fileResponse = await fetch(
      `${SERVER}/api/reports/by-token/${all[0].share_token}/files/${gifAttachment.file_id}`
    );
    const { writeFile } = await import("node:fs/promises");
    const buf = Buffer.from(await fileResponse.arrayBuffer());
    await writeFile("/tmp/ltt-shots/25-recording.gif", buf);
    console.log("track gif:", JSON.stringify({ size: buf.length, isGif: buf.subarray(0, 3).toString() === "GIF" }));
  }
  console.log(
    "recording:",
    JSON.stringify({
      type: recordingSummary.type,
      stepCount: steps.length,
      types: steps.map((s) => s.type),
      inputValue: steps.find((s) => s.type === "input")?.data?.value,
      hasGif: Boolean(gifAttachment),
    })
  );
  const recorderOk =
    recordingSummary.type === "recording" &&
    steps.some((s) => s.type === "click") &&
    steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") &&
    Boolean(gifAttachment);
  // recording survives a full navigation: url_change recorded, and a click
  // captured *after* it (capture listeners reinstalled on the new page);
  // the report's page_url must be the URL where recording started
  const urlChangeIndex = steps.findIndex((s) => s.type === "url_change");
  const resumedOk = urlChangeIndex >= 0 && steps.slice(urlChangeIndex + 1).some((s) => s.type === "click");
  const startUrlOk = (recordingSummary.page_url ?? "").endsWith("localhost:8899/") && !recordingSummary.page_url.includes("page=2");
  console.log("nav:", JSON.stringify({ urlChangeIndex, resumedOk, page_url: recordingSummary.page_url, startUrlOk }));
  // the recorded cursor path must be stored alongside the steps
  const trackPoints = recordingSummary.mouse_track?.points?.length ?? 0;
  const mouseTrackOk = trackPoints >= 2;
  console.log("mouse track:", JSON.stringify({ points: trackPoints }));

  // --- popup: mode buttons + latest report card ---
  const popup = await context.newPage();
  await popup.goto(`chrome-extension://${extensionId}/src/popup/popup.html`);
  await popup.waitForSelector("text=Element note", { timeout: 10000 });
  const hasRecord = await popup.locator("text=Record steps").count();
  const latestTitle = await popup.locator(".popup-report-title").textContent();
  const popupOk = hasRecord > 0 && Boolean(latestTitle && latestTitle.length > 0);

  // --- panel relay: the web panel talks to the extension through window ---
  // messages (the relay content script), then replays the recording and the
  // virtual cursor should appear in the replay tab
  let relayOk = false;
  let cursorOk = false;
  let replayOk = false;
  let replayResultOk = false;
  const panelPage = await context.newPage();
  await panelPage.goto(`http://localhost:5173/r/${all[0].share_token}`);
  await panelPage.waitForTimeout(2000); // let the relay content script install
  relayOk = await panelPage.evaluate(
    () =>
      new Promise((resolve) => {
        const handler = (event) => {
          if (event.source !== window) return;
          const data = event.data;
          if (data?.source === "bugtrail-ext" && data.type === "pong") {
            window.removeEventListener("message", handler);
            resolve(true);
          }
        };
        window.addEventListener("message", handler);
        window.postMessage({ source: "bugtrail-panel", type: "ping" }, window.location.origin);
        setTimeout(() => resolve(false), 2500);
      })
  );
  console.log("panel relay ping:", relayOk);
  if (relayOk) {
    // replay through the exact path the panel's "Start replay" button uses
    const resultPromise = panelPage.evaluate(
      (token) =>
        new Promise((resolve) => {
          const handler = (event) => {
            const data = event.data;
            if (data?.source === "bugtrail-ext" && data.type === "replay_result") {
              window.removeEventListener("message", handler);
              resolve(data);
            }
          };
          window.addEventListener("message", handler);
          window.postMessage({ source: "bugtrail-panel", type: "replay_report", token }, window.location.origin);
          setTimeout(() => window.removeEventListener("message", handler), 90000);
        }),
      all[0].share_token
    );
    // the virtual cursor lives only while the replay animates — poll for it
    let replayPage = null;
    for (let i = 0; i < 60 && !replayPage; i++) {
      await new Promise((resolve) => setTimeout(resolve, 250));
      replayPage = context.pages().find(
        (candidate) =>
          candidate !== popup && candidate !== page && candidate !== panelPage && candidate.url().includes(`localhost:${PAGE_PORT}`)
      );
    }
    if (replayPage) {
      for (let i = 0; i < 30 && !cursorOk; i++) {
        if (replayPage.isClosed()) break;
        cursorOk = await replayPage.evaluate(() => Boolean(document.getElementById("ltt-cursor-host"))).catch(() => false);
        if (cursorOk) break;
        await replayPage.waitForTimeout(300);
      }
    }
    const result = await resultPromise;
    // every step re-executed, including the mid-recording url_change
    replayOk = result?.ok === true && result?.data?.played === result?.data?.total && result.data.total >= 4;
    const inputValue = replayPage && !replayPage.isClosed()
      ? await replayPage.evaluate(() => document.querySelector("#name-input")?.value ?? null).catch(() => null)
      : null;
    replayResultOk = result?.ok === true;
    console.log("panel replay:", JSON.stringify({ result, cursorOk }));
  }

  // --- delete the latest report from the popup (two-step: arm, confirm) ---
  await popup.click(".popup-mini-danger");
  await popup.click(".popup-mini-danger");
  await popup.waitForTimeout(1500);
  await popup.close();
  const itemsAfterDelete = (await api(`/api/projects/${project.id}/reports`, {
    headers: { Authorization: `Bearer ${login.token}` },
  }));
  const deleteOk = (itemsAfterDelete.items ?? itemsAfterDelete).length === all.length - 1;
  console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk: popupOk, deleteOk }));

  const ok =
    noteOk && recorderOk && resumedOk && startUrlOk && clipboardOk && popupOk && relayOk && replayOk && replayResultOk && cursorOk && mouseTrackOk;
  console.log("background log:", JSON.stringify(await worker.evaluate(() => self.__lttDebug()), null, 1));
  console.log(noteOk ? "note flow OK" : "note flow MISMATCH");
  console.log(recorderOk ? "recorder flow OK" : "recorder flow MISMATCH");
  console.log(resumedOk ? "recording across navigation OK" : "recording across navigation MISMATCH");
  console.log(startUrlOk ? "start url OK" : "start url MISMATCH");
  console.log(mouseTrackOk ? "mouse track OK" : "mouse track MISMATCH");
  console.log(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH");
  console.log(popupOk ? "popup OK" : "popup MISMATCH");
  console.log(relayOk && replayResultOk ? "panel relay OK" : "panel relay MISMATCH");
  console.log(replayOk ? "replay OK" : "replay MISMATCH");
  console.log(ok ? "E2E OK" : "E2E FAILED");
  process.exitCode = ok ? 0 : 1;
} catch (error) {
  console.error("E2E FAILED:", error);
  process.exitCode = 1;
} finally {
  await context.close();
  httpServer.close();
}