diff --git a/Makefile b/Makefile index 51ba3b1..3c9a4af 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,18 @@ -.PHONY: help dev web server migrate ext ext:watch ext:test test typecheck build prod down logs +.PHONY: help dev web server migrate ext ext-watch ext-test test typecheck build prod down logs help: - @echo "make dev — postgres + API in docker, web panel on :5173" - @echo "make web — web panel dev server (Vite, :5173)" - @echo "make migrate — apply alembic migrations (docker)" - @echo "make ext — build the extension (chrome + firefox)" - @echo "make ext:watch — rebuild the extension on change" - @echo "make ext:test — e2e smoke test of the built extension (needs dev stack up)" - @echo "make test — server tests" - @echo "make typecheck — vue-tsc for web + extension" - @echo "make build — production build of the web panel" + @echo "make dev — postgres + API in docker, web panel on :5173" + @echo "make web — web panel dev server (Vite, :5173)" + @echo "make migrate — apply alembic migrations (docker)" + @echo "make ext — build the extension (chrome + firefox)" + @echo "make ext-watch — rebuild the extension on change" + @echo "make ext-test — e2e smoke test of the built extension (needs dev stack up)" + @echo "make test — server tests" + @echo "make typecheck — vue-tsc for web + extension" + @echo "make build — production build of the web panel" @echo "make prod — production stack (caddy :8081 + API + postgres)" - @echo "make down — stop the dev stack" - @echo "make logs — follow server logs" + @echo "make down — stop the dev stack" + @echo "make logs — follow server logs" dev: docker compose up -d --build @@ -27,10 +27,10 @@ ext: npm run build -w @ltt/extension -ext:watch: +ext-watch: npm run watch:chrome -w @ltt/extension -ext:test: +ext-test: node packages/extension/e2e.mjs test: diff --git a/README.md b/README.md index 394317f..ed3c32c 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ## E2E-проверка расширения -`make ext:test` (нужен поднятый `make dev`) — headless Chromium грузит собранное +`make ext-test` (нужен поднятый `make dev`) — headless Chromium грузит собранное расширение, проходит сценарий «пикер → заметка» и «рекордер: клики + ввод», проверяет репорты через API. diff --git a/packages/extension/src/content/overlay/mount.ts b/packages/extension/src/content/overlay/mount.ts index 123abd1..02e170f 100644 --- a/packages/extension/src/content/overlay/mount.ts +++ b/packages/extension/src/content/overlay/mount.ts @@ -34,6 +34,20 @@ .replaceAll("url('/assets/", `url('${base}assets/`); } +// same palette as the panel (packages/web/src/theme.css); overlay components +// reference these via var(--color-*, fallback) — the fallbacks stay in sync +const OVERLAY_VARS = { + "--color-bg": "#10121c", + "--color-surface": "#1f2335", + "--color-border": "#2f334d", + "--color-text": "#c0caf5", + "--color-text-dim": "#787c99", + "--color-accent": "#7aa2f7", + "--color-success": "#9ece6a", + "--color-warning": "#e0af68", + "--color-danger": "#f7768e", +}; + export function mountOverlay(): Overlay { const host = document.createElement("div"); host.id = "ltt-overlay-host"; @@ -42,7 +56,10 @@ const shadowRoot = host.attachShadow({ mode: "closed" }); const mountPoint = document.createElement("div"); - mountPoint.style.cssText = "all: initial; pointer-events: none; width: 100%; height: 100%;"; + const vars = Object.entries(OVERLAY_VARS) + .map(([name, value]) => `${name}:${value};`) + .join(""); + mountPoint.style.cssText = `all: initial; pointer-events: none; width: 100%; height: 100%; ${vars}`; shadowRoot.appendChild(mountPoint); const state = reactive({ mode: "idle", recording: false }); @@ -65,7 +82,7 @@ return { startPicker: () => { state.mode = "picker"; -state.recording = false; + state.recording = false; }, setRecorderState: (recording: boolean) => { // only toggles the bar; picking stays opt-in via the bar's Note button diff --git a/packages/ui/src/ElementContext.vue b/packages/ui/src/ElementContext.vue index 32280f6..435af4d 100644 --- a/packages/ui/src/ElementContext.vue +++ b/packages/ui/src/ElementContext.vue @@ -50,27 +50,34 @@ .element-context { display: flex; flex-direction: column; - gap: 6px; min-width: 0; } .element-context-row { display: grid; - grid-template-columns: 90px 1fr; - gap: 10px; + grid-template-columns: 96px 1fr; + gap: 12px; align-items: baseline; + padding: 6px 0; font-size: 13px; } +.element-context-row + .element-context-row { + border-top: 1px solid var(--color-border, #2f334d); +} .element-context-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; - opacity: 0.6; + color: var(--color-text-dim, #787c99); } .element-context-value { font-family: inherit; overflow-wrap: anywhere; + color: var(--color-text, #c0caf5); } .element-context-selector { - opacity: 0.85; + padding: 2px 6px; + background: var(--color-bg, #10121c); + border: 1px solid var(--color-border, #2f334d); + font-size: 12px; } \ No newline at end of file diff --git a/packages/ui/src/EnvironmentInfo.vue b/packages/ui/src/EnvironmentInfo.vue index f65dc6f..91d5877 100644 --- a/packages/ui/src/EnvironmentInfo.vue +++ b/packages/ui/src/EnvironmentInfo.vue @@ -5,6 +5,13 @@ return { label, value: value == null || value === "" ? null : String(value) }; } +/** ISO strings render as-is otherwise; anything unparseable falls back to the raw value. */ +function fmtWhen(value: unknown): string | null { + if (typeof value !== "string" || !value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); +} + const rows = [ row("Browser", [props.environment.browser, props.environment.browser_version].filter(Boolean).join(" ")), row("OS", props.environment.os), @@ -16,7 +23,7 @@ : null ), row("Device pixel ratio", props.environment.dpr), - row("Captured", props.environment.captured_at), + row("Captured", fmtWhen(props.environment.captured_at)), row("User agent", props.environment.user_agent), ].filter((r) => r.value !== null); @@ -35,24 +42,31 @@ .env-info { display: flex; flex-direction: column; - gap: 6px; + min-width: 0; font-size: 13px; } .env-info-row { display: grid; - grid-template-columns: 160px 1fr; - gap: 10px; + grid-template-columns: 120px 1fr; + gap: 12px; + align-items: baseline; + padding: 6px 0; +} +.env-info-row + .env-info-row { + border-top: 1px solid var(--color-border, #2f334d); } .env-info-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; - opacity: 0.6; + color: var(--color-text-dim, #787c99); } .env-info-value { overflow-wrap: anywhere; + color: var(--color-text, #c0caf5); } .env-info-empty { + margin: 0; opacity: 0.5; font-size: 12px; } diff --git a/packages/ui/src/ScreenshotViewer.vue b/packages/ui/src/ScreenshotViewer.vue index e2f40a5..bf4be10 100644 --- a/packages/ui/src/ScreenshotViewer.vue +++ b/packages/ui/src/ScreenshotViewer.vue @@ -10,12 +10,21 @@ shapes?: AnnotationShape[] | null; /** Whether the current user may edit annotations. */ editable?: boolean; + /** Button labels; defaults keep the component usable without an i18n setup. */ + labels?: { edit?: string; save?: string; saving?: string; cancel?: string }; }>(); const emit = defineEmits<{ (e: "save", shapes: AnnotationShape[]): void; }>(); +const text = { + edit: "Edit annotations", + save: "Save annotations", + saving: "Saving…", + cancel: "Cancel", +}; + const editing = ref(false); const draft = ref(props.shapes ?? []); const editorRef = ref>(); @@ -50,12 +59,14 @@
diff --git a/packages/web/shot.mjs b/packages/web/shot.mjs new file mode 100644 index 0000000..6fb170a --- /dev/null +++ b/packages/web/shot.mjs @@ -0,0 +1,147 @@ +/** + * 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"); \ No newline at end of file diff --git a/packages/web/src/components/AppShell.vue b/packages/web/src/components/AppShell.vue index f8e70e8..4a2c807 100644 --- a/packages/web/src/components/AppShell.vue +++ b/packages/web/src/components/AppShell.vue @@ -2,7 +2,7 @@ import { computed } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useI18n } from "vue-i18n"; -import { GnButton, GnNavigationShell, GnAvatar } from "gnexus-ui-kit/vue"; +import { GnAvatar, GnButton, GnNavigationShell } from "gnexus-ui-kit/vue"; import { useAuth } from "../stores/auth"; const auth = useAuth(); @@ -26,15 +26,23 @@