/**
* 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" }),
});
// a second project: the tester juggles several at once, so the popup must
// be able to switch between them
const project2 = await api("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` },
body: JSON.stringify({ name: "Extension E2E Second" }),
});
console.log("seeded user+projects:", project.id, project2.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");
// console tap: the error must land as a "console" step of the recording
await page.evaluate(() => {
console.error("e2e console boom", { code: 42 });
console.warn("e2e console warn");
});
// 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());
// the report is submitted right at stop (auto-title); the composer opens
// once it exists and edits it by token — wait for the report to land first
const waitForNewReport = async (knownCount) => {
for (let i = 0; i < 40; i++) {
const listing = await api(`/api/projects/${project.id}/reports`, {
headers: { Authorization: `Bearer ${login.token}` },
});
const found = listing.items ?? listing;
if (found.length > knownCount) return found;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error("recording report did not appear after stop");
};
await waitForNewReport(1); // the note from the first flow
await page.waitForTimeout(1200); // composer mounts right after the report
await page.keyboard.type("E2E recorded bug"); // replaces the selected prefill
await page.keyboard.press("Tab"); // title → description textarea
await page.keyboard.type("Recorded via e2e composer.");
await page.keyboard.press("Control+Enter"); // composer save shortcut
await page.waitForTimeout(3000); // title/description patch request
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 screen recording is delivered as a silent WebM video (GIF fallback)
const videoAttachment = (recordingSummary.attachments ?? []).find((a) => a.mime === "video/webm");
const gifAttachment = (recordingSummary.attachments ?? []).find((a) => a.mime === "image/gif");
let videoOk = false;
const mediaAttachment = videoAttachment ?? gifAttachment;
if (mediaAttachment) {
const fileResponse = await fetch(
`${SERVER}/api/reports/by-token/${all[0].share_token}/files/${mediaAttachment.file_id}`
);
const { writeFile } = await import("node:fs/promises");
const buf = Buffer.from(await fileResponse.arrayBuffer());
await writeFile(videoAttachment ? "/tmp/ltt-shots/25-recording.webm" : "/tmp/ltt-shots/25-recording.gif", buf);
if (videoAttachment) {
// EBML magic (0x1A45DFA3) marks a Matroska/WebM container
const isWebm = buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3;
videoOk = isWebm && buf.length > 4000;
console.log("screen video:", JSON.stringify({ size: buf.length, isWebm }));
// full container validation when ffprobe is available: the file must be
// a decodable VP8 stream with a positive duration (catches muxer bugs
// that produce structurally valid but unplayable output)
if (videoOk) {
try {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const out = JSON.parse(
(
await promisify(execFile)("ffprobe", [
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=codec_name,width,height:format=duration",
"-of", "json",
"/tmp/ltt-shots/25-recording.webm",
])
).stdout
);
const stream = out.streams?.[0];
console.log("ffprobe:", JSON.stringify({ codec: stream?.codec_name, size: `${stream?.width}x${stream?.height}`, duration: out.format?.duration }));
// the cursor-following crop produces a square video around the
// cursor, capped at 512px (256 floor = the crop actually happened)
const width = stream?.width ?? 0;
const height = stream?.height ?? 0;
videoOk =
stream?.codec_name === "vp8" &&
Number(out.format?.duration) > 0 &&
width === height &&
width <= 512 &&
width >= 256;
} catch (error) {
if (String(error).includes("ENOENT")) console.log("ffprobe: not installed, skipping");
else {
console.log("ffprobe: FAILED", String(error).slice(0, 200));
videoOk = false;
}
}
}
} else {
// GIF fallback: logical screen size at offset 6 (little-endian); frames
// each carry a Graphic Control Extension (0x21 0xF9)
const isGif = buf.subarray(0, 3).toString() === "GIF";
const gifWidth = buf.readUInt16LE(6);
let frameCount = 0;
for (let i = 0; i < buf.length - 1; i++) {
if (buf[i] === 0x21 && buf[i + 1] === 0xf9) frameCount++;
}
videoOk = isGif && gifWidth === 512 && frameCount >= 3;
console.log("track gif:", JSON.stringify({ size: buf.length, isGif, gifWidth, frameCount }));
}
}
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,
hasVideo: Boolean(videoAttachment),
hasGif: Boolean(gifAttachment),
})
);
const consoleOk = steps.some((s) => s.type === "console" && String(s.data?.text ?? "").includes("e2e console boom"));
const recorderOk =
recordingSummary.type === "recording" &&
steps.some((s) => s.type === "click") &&
steps.some((s) => s.type === "input" && s.data?.value === "Hello recorder") &&
Boolean(mediaAttachment);
// 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 }));
// the composer's title/description must reach the server verbatim
const composerOk =
recordingSummary.title === "E2E recorded bug" && (recordingSummary.description ?? "") === "Recorded via e2e composer.";
console.log("composer:", JSON.stringify({ title: recordingSummary.title, description: recordingSummary.description }));
// --- discard flow: a short second recording dropped in the composer ---
// the report exists from the moment of the stop; Discard deletes it, so
// the count returns to where it was. Keyboard path through the closed
// shadow root: title input is focused on mount, so Tab×2 reaches the
// two-step Discard button, Enter×2 confirms
const beforeDiscard = all.length;
await page.bringToFront();
await worker.evaluate(() => self.__lttToggleRecorder());
await page.waitForTimeout(800);
const headingBox = await page.locator("h1").boundingBox();
await page.mouse.click(headingBox.x + 30, headingBox.y + 10); // one event so the buffer is non-empty
await page.waitForTimeout(400);
await worker.evaluate(() => self.__lttToggleRecorder());
await waitForNewReport(beforeDiscard); // report submitted at stop
await page.waitForTimeout(1200); // composer mounts
await page.keyboard.press("Tab"); // title → description
await page.keyboard.press("Tab"); // description → Discard button
await page.keyboard.press("Enter"); // arm the two-step discard
await page.waitForTimeout(250);
await page.keyboard.press("Enter"); // confirm
await page.waitForTimeout(1500);
const afterDiscard = (await api(`/api/projects/${project.id}/reports`, {
headers: { Authorization: `Bearer ${login.token}` },
}));
const discardOk = (afterDiscard.items ?? afterDiscard).length === beforeDiscard;
console.log("discard:", JSON.stringify({ before: beforeDiscard, after: (afterDiscard.items ?? afterDiscard).length, discardOk }));
// --- 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();
// --- popup: project switcher — the tester juggles several projects ---
await popup.selectOption(".popup-project-select", project2.id);
await popup.waitForTimeout(1000);
const savedSecond = await popup.evaluate(() => chrome.runtime.sendMessage({ type: "settings_get" }));
const emptyShown = await popup.locator("text=No reports yet in this project").count();
await popup.selectOption(".popup-project-select", project.id);
await popup.waitForTimeout(1000);
const savedFirst = await popup.evaluate(() => chrome.runtime.sendMessage({ type: "settings_get" }));
const switcherOk =
savedSecond?.data?.defaultProjectId === project2.id &&
emptyShown > 0 &&
savedFirst?.data?.defaultProjectId === project.id;
console.log(
"project switcher:",
JSON.stringify({ second: savedSecond?.data?.defaultProjectId, back: savedFirst?.data?.defaultProjectId, emptyShown, switcherOk })
);
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 && videoOk && consoleOk && composerOk && discardOk && switcherOk;
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(videoOk ? "screen video OK" : "screen video MISMATCH");
console.log(consoleOk ? "console dump OK" : "console dump MISMATCH");
console.log(composerOk ? "recording composer OK" : "recording composer MISMATCH");
console.log(discardOk ? "recording discard OK" : "recording discard MISMATCH");
console.log(switcherOk ? "project switcher OK" : "project switcher 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();
}