/**
* Captures screenshots of all web panel pages for visual review.
* Usage: node shot.mjs (server :8001 must be running)
*/
import { chromium } from "playwright-core";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
const SERVER = "http://localhost:8001";
const WEB = "http://localhost:5173";
const stamp = Date.now();
const EMAIL = `shot-${stamp}@example.com`;
const PASSWORD = "shot-password-1";
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();
}
const user = await api("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname: `shot-${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: "WebApp Frontend", description: "Visual review seed" }),
});
// 1x1 transparent PNG + a red pixel annotation + steps
const pngBase64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
async function uploadFile(name) {
const bytes = Buffer.from(pngBase64, "base64");
const form = new FormData();
form.append("file", new Blob([bytes], { type: "image/png" }), name);
const response = await fetch(`${SERVER}/api/uploads`, {
method: "POST",
headers: { Authorization: `Bearer ${login.token}` },
body: form,
});
return (await response.json()).file_id;
}
const shot1 = await uploadFile("screenshot.png");
const shot2 = await uploadFile("step1.png");
const note = await api("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` },
body: JSON.stringify({
project_id: project.id,
type: "element_note",
title: "Button overlaps the input on mobile",
description: "Saw it on iPhone 12 viewport. Screenshot attached.\n\nhttps://example.com/design-spec",
page_url: "http://localhost:8060/",
page_title: "Oselya",
environment: {
user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)",
browser: "Chrome",
browser_version: "126.0",
os: "iOS",
viewport: { w: 390, h: 844 },
dpr: 3,
language: "uk",
captured_at: new Date().toISOString(),
},
element: {
tag: "button",
selector: "#submit-order",
unique_selector: "[data-testid='submit-order']",
id: "submit-order",
classes: ["btn", "btn-primary"],
text_snippet: "Підтвердити замовлення",
aria_label: "Підтвердити",
test_attributes: { "data-testid": "submit-order" },
rect: { x: 12, y: 480, w: 366, h: 44 },
screenshot_rel: { x: 0.03, y: 0.55, w: 0.94, h: 0.06 },
},
attachment_ids: [shot1],
annotation_shapes: {
[shot1]: [
{ tool: "rect", color: "#F7768E", strokeWidth: 3, rect: [0.05, 0.5, 0.9, 0.12] },
{ tool: "text", color: "#F7768E", strokeWidth: 3, pos: [0.06, 0.48], text: "overlap!" },
],
},
}),
});
const recording = await api("/api/reports", {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${login.token}` },
body: JSON.stringify({
project_id: project.id,
type: "recording",
title: "Cannot save client card twice",
description: null,
page_url: "http://localhost:8060/",
page_title: "Oselya",
environment: { browser: "Firefox", os: "Linux", viewport: { w: 1440, h: 900 }, dpr: 1, language: "uk" },
steps: [
{ type: "click", offset_ms: 1200, data: { element: { tag: "button", selector: "#save-card" }, button: 0 }, attachment_id: shot2 },
{ type: "input", offset_ms: 3400, data: { element: { tag: "input" }, value: "Олена Коваленко", value_length: 15, input_type: "text" } },
{ type: "url_change", offset_ms: 5100, data: { from_url: "http://localhost:8060/clients", to_url: "http://localhost:8060/clients/new", trigger: "tab_updated" } },
{ type: "click", offset_ms: 7600, data: { element: { tag: "button" }, button: 0 } },
].map((s) => ({ ...s, attachment_id: s.attachment_id ?? null })),
attachment_ids: [shot2],
}),
});
console.log("seeded:", project.share_token, note.share_token, recording.share_token);
const executablePath = `${homedir()}/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome`;
const browser = await chromium.launch({ executablePath, headless: true });
const context = await browser.newContext({ viewport: { width: 1360, height: 900 } });
const page = await context.newPage();
async function shot(path, name, wait = 800) {
await page.goto(WEB + path, { waitUntil: "networkidle" });
await page.waitForTimeout(wait);
await page.screenshot({ path: `/tmp/ltt-shots/${name}.png`, fullPage: true });
console.log("shot:", name);
}
await shot(`/login`, "01-login");
await shot(`/register`, "02-register");
// authenticate through the UI to get the session cookie
await page.goto(`${WEB}/login`);
await page.fill("input[type='text'], input[type='email']", EMAIL);
await page.fill("input[type='password']", PASSWORD);
await page.click("button[type='submit']");
await page.waitForTimeout(1500);
await shot(`/`, "03-projects");
await shot(`/p/${project.share_token}`, "04-project-public");
await shot(`/r/${note.share_token}`, "05-report-note");
await shot(`/r/${recording.share_token}`, "06-report-recording");
await shot(`/settings`, "07-settings");
await browser.close();
console.log("done -> /tmp/ltt-shots");