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();
  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;

  // --- recorder: start, click the button, type, stop → recording report ---
  await worker.evaluate(() => self.__lttToggleRecorder());
  await page.waitForTimeout(800);
  const buttonBox = await page.locator("#target").boundingBox();
  await page.mouse.click(buttonBox.x + buttonBox.width / 2, buttonBox.y + buttonBox.height / 2);
  const inputBox = await page.locator("#name-input").boundingBox();
  await page.mouse.click(inputBox.x + 10, inputBox.y + inputBox.height / 2);
  await page.keyboard.type("Hello recorder");
  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 ?? [];
  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,
    })
  );
  const recorderOk =
    recordingSummary.type === "recording" &&
    steps.some((s) => s.type === "click") &&
    steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") &&
    steps.some((s) => s.screenshot_attachment_id != null);

  const ok = noteOk && recorderOk;
  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(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();
}