Newer
Older
bugtrail / packages / web / shot.mjs
/**
 * 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");