/**
* 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>
<style>#target:hover { background-color: rgb(255, 0, 0); }</style></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>
<script>
// sticky flags: hover windows during replay are short, so sample and latch
document.addEventListener("mouseover", (event) => {
if (event.target.id === "target") window.__jsHoverSeen = true;
});
setInterval(() => {
if (document.querySelector("#target:hover")) window.__cssHoverSeen = true;
}, 50);
</script>
</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;
let jsHoverOk = false;
let cssHoverOk = 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);
}
}
// wait for the replay to finish while sampling the sticky hover flags:
// __jsHoverSeen latches the synthetic mouseover family, __cssHoverSeen
// latches real CSS :hover (only possible when the debugger API attached)
let result;
for (;;) {
const winner = await Promise.race([
resultPromise.then((r) => ({ done: true, r })),
new Promise((resolve) => setTimeout(() => resolve({ done: false }), 300)),
]);
if (winner.done) {
result = winner.r;
break;
}
if (replayPage && !replayPage.isClosed()) {
const flags = await replayPage
.evaluate(() => ({ js: Boolean(window.__jsHoverSeen), css: Boolean(window.__cssHoverSeen) }))
.catch(() => null);
if (flags) {
jsHoverOk = jsHoverOk || flags.js;
cssHoverOk = cssHoverOk || flags.css;
}
}
}
// 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, jsHoverOk, cssHoverOk }));
}
// --- 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 && jsHoverOk;
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(jsHoverOk ? "synthetic hover OK" : "synthetic hover MISMATCH");
// the debugger API can legitimately be unavailable (Firefox, another
// debugger attached, setting off) — JS-level hover still works, so the CSS
// check is reported but doesn't fail the run
console.log(cssHoverOk ? "css :hover OK" : "css :hover NOT OBSERVED");
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();
}