/**
* 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());
clipboardOk = clipboard === `${SERVER}/r/${items[0].share_token}`;
} catch (error) {
console.log("[clipboard read failed]", String(error).slice(0, 120));
}
// --- 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 ?? [];
// keep a step screenshot around for manual inspection: the recorder bar
// must not appear in it (plugin UI is hidden during captures)
const shotStep = steps.find((s) => s.screenshot_attachment_id != null);
if (shotStep) {
// steps reference the attachment row id; the files route wants the file id
const attachment = (recordingSummary.attachments ?? []).find((a) => a.id === shotStep.screenshot_attachment_id);
const fileId = attachment?.file_id ?? shotStep.screenshot_attachment_id;
const fileResponse = await fetch(
`${SERVER}/api/reports/by-token/${all[0].share_token}/files/${fileId}`
);
if (!fileResponse.ok) {
console.log(
"step screenshot fetch failed:",
fileResponse.status,
JSON.stringify({ attachments: recordingSummary.attachments?.map((a) => a.file_id), steps: steps.map((s) => s.screenshot_attachment_id) })
);
} else {
const { writeFile } = await import("node:fs/promises");
await writeFile("/tmp/ltt-shots/23-step-screenshot.png", Buffer.from(await fileResponse.arrayBuffer()));
console.log("step screenshot saved for inspection");
}
}
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);
// --- popup: mode buttons, latest report, delete from the popup ---
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 latestOk = Boolean(latestTitle && latestTitle.length > 0);
// two-step delete: arm then 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;
const popupOk = hasRecord > 0 && latestOk && deleteOk;
console.log("popup:", JSON.stringify({ hasRecord: hasRecord > 0, latestOk, deleteOk }));
const ok = noteOk && recorderOk && clipboardOk && popupOk;
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(clipboardOk ? "clipboard link OK" : "clipboard link MISMATCH");
console.log(popupOk ? "popup OK" : "popup 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();
}