design-qa-capture 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +23 -0
  2. package/dist/index.js +269 -0
  3. package/package.json +31 -0
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # design-qa-capture
2
+
3
+ Local authenticated browser capture runner for Design QA comparisons.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx design-qa-capture@latest capture <capture-token>
9
+ ```
10
+
11
+ The command opens a browser window, lets you sign in or navigate to the exact screen to review, captures the page, and uploads the screenshot plus DOM element metadata back to Design QA.
12
+
13
+ ## Options
14
+
15
+ ```bash
16
+ npx design-qa-capture@latest capture <capture-token> --api https://app.quality.dev
17
+ npx design-qa-capture@latest capture <capture-token> --settle 5000
18
+ ```
19
+
20
+ - `--api`: Design QA app origin. Defaults to `DESIGN_QA_API`, then `QUALITY_API`, then `https://app.quality.dev`.
21
+ - `--settle`: Extra wait time in milliseconds before capture. Defaults to `3500`.
22
+
23
+ Sessions are stored under `~/.design-qa/<host>.json` so repeat captures can reuse login state.
package/dist/index.js ADDED
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createInterface } from "node:readline/promises";
5
+
6
+ // ../packages/core/src/capture.ts
7
+ import { chromium } from "playwright";
8
+ async function captureFromPage(page, opts = {}) {
9
+ const maxHeightPx = opts.maxHeightPx ?? 2e4;
10
+ const fullPage = opts.fullPage ?? true;
11
+ const viewport = page.viewportSize() ?? { width: 1280, height: 720 };
12
+ await freezeVolatileRendering(page);
13
+ await page.waitForTimeout(100);
14
+ const dimensions = fullPage ? await page.evaluate(`(() => {
15
+ const doc = document.documentElement;
16
+ const body = document.body;
17
+ return {
18
+ width: Math.ceil(Math.max(doc.scrollWidth, body?.scrollWidth ?? 0, window.innerWidth)),
19
+ height: Math.ceil(Math.max(doc.scrollHeight, body?.scrollHeight ?? 0, window.innerHeight)),
20
+ };
21
+ })()`) : { width: viewport.width, height: viewport.height };
22
+ const height = Math.min(dimensions.height, maxHeightPx);
23
+ const cappedOut = dimensions.height > maxHeightPx;
24
+ const elements = opts.extractElements ? await extractElements(page, height) : [];
25
+ const png = await page.screenshot(
26
+ !fullPage ? { type: "png" } : cappedOut ? { type: "png", clip: { x: 0, y: 0, width: dimensions.width, height } } : { type: "png", fullPage: true }
27
+ );
28
+ return { png, width: dimensions.width, height, fullHeight: dimensions.height, cappedOut, elements };
29
+ }
30
+ async function extractElements(page, maxY) {
31
+ const raw = await page.evaluate(`(() => {
32
+ const selector = "a,button,h1,h2,h3,h4,h5,h6,p,span,li,label,img,input,svg";
33
+ const out = [];
34
+ for (const el of Array.from(document.querySelectorAll(selector))) {
35
+ const r = el.getBoundingClientRect();
36
+ if (r.width < 8 || r.height < 6) continue;
37
+ const tag = el.tagName.toLowerCase();
38
+ const htmlEl = el;
39
+ const style = window.getComputedStyle(el);
40
+ const raw =
41
+ tag === "img"
42
+ ? el.alt
43
+ : tag === "input"
44
+ ? el.value || el.placeholder
45
+ : htmlEl.innerText || el.textContent || "";
46
+ const text = cleanElementText(raw, 80);
47
+ const ariaLabel = cleanElementText(el.getAttribute("aria-label") || "", 80);
48
+ const role = cleanElementText(el.getAttribute("role") || "", 32);
49
+ const title = cleanElementText(el.getAttribute("title") || "", 80);
50
+ const semanticLabel = ariaLabel || title;
51
+ if (!text && !semanticLabel && tag !== "img" && tag !== "svg") continue;
52
+ out.push({
53
+ text,
54
+ tag,
55
+ ...(role ? { role } : {}),
56
+ ...(ariaLabel ? { ariaLabel } : {}),
57
+ x: r.left + window.scrollX,
58
+ y: r.top + window.scrollY,
59
+ w: r.width,
60
+ h: r.height,
61
+ fontFamily: style.fontFamily,
62
+ fontSize: style.fontSize,
63
+ fontWeight: style.fontWeight,
64
+ color: style.color,
65
+ letterSpacing: style.letterSpacing,
66
+ backgroundColor: style.backgroundColor,
67
+ });
68
+ }
69
+
70
+ function cleanElementText(value, max) {
71
+ return value.trim().replace(/s+/g, " ").slice(0, max);
72
+ }
73
+
74
+ return out;
75
+ })()`);
76
+ return raw.filter((e) => e.y < maxY).slice(0, 800);
77
+ }
78
+ async function freezeVolatileRendering(page) {
79
+ try {
80
+ await page.addStyleTag({
81
+ content: `
82
+ *, *::before, *::after {
83
+ animation-delay: 0s !important;
84
+ animation-duration: 0s !important;
85
+ animation-iteration-count: 1 !important;
86
+ caret-color: transparent !important;
87
+ transition-delay: 0s !important;
88
+ transition-duration: 0s !important;
89
+ }
90
+ video {
91
+ visibility: hidden !important;
92
+ }
93
+ `
94
+ });
95
+ } catch {
96
+ }
97
+ }
98
+
99
+ // src/api.ts
100
+ async function fetchTarget(apiBase, token, fetchImpl = fetch) {
101
+ const res = await fetchImpl(`${apiBase}/api/capture/${token}`, { method: "GET" });
102
+ if (res.status === 410) throw new Error("This capture link has expired. Start a new comparison and try again.");
103
+ if (res.status === 404) throw new Error("Capture link not found. Copy the command again from the comparison page.");
104
+ if (!res.ok) throw new Error(`Could not start capture (HTTP ${res.status}).`);
105
+ return await res.json();
106
+ }
107
+ async function uploadCapture(apiBase, token, args, fetchImpl = fetch) {
108
+ const form = new FormData();
109
+ form.append("live", new Blob([new Uint8Array(args.png)], { type: "image/png" }), "live.png");
110
+ form.append("elements", JSON.stringify({ width: args.width, height: args.height, elements: args.elements }));
111
+ const res = await fetchImpl(`${apiBase}/api/capture/${token}`, { method: "POST", body: form });
112
+ if (res.status === 410) throw new Error("This capture link has expired or was already used.");
113
+ if (!res.ok) throw new Error(`Upload failed (HTTP ${res.status}).`);
114
+ return await res.json();
115
+ }
116
+
117
+ // src/browser.ts
118
+ import { execSync } from "node:child_process";
119
+ import { chromium as chromium2 } from "playwright";
120
+
121
+ // src/session.ts
122
+ import { homedir } from "node:os";
123
+ import { join } from "node:path";
124
+ import { mkdir } from "node:fs/promises";
125
+ import { existsSync } from "node:fs";
126
+ function sessionPathForUrl(url, home = homedir()) {
127
+ const host = new URL(url).hostname;
128
+ return join(home, ".design-qa", `${host}.json`);
129
+ }
130
+ function sessionExists(sessionPath) {
131
+ return existsSync(sessionPath);
132
+ }
133
+ async function saveSession(context, sessionPath) {
134
+ await mkdir(join(sessionPath, ".."), { recursive: true });
135
+ await context.storageState({ path: sessionPath });
136
+ }
137
+
138
+ // src/browser.ts
139
+ var LAUNCH_ARGS = ["--disable-blink-features=AutomationControlled"];
140
+ var EVASION_SCRIPT = `
141
+ Object.defineProperty(navigator, "webdriver", { get: function () { return false; } });
142
+
143
+ var __noop = function () { return null; };
144
+ var __methods = ["log","debug","info","warn","error","table","dir","dirxml","group","groupCollapsed","trace"];
145
+ for (var __i = 0; __i < __methods.length; __i++) {
146
+ (function (m) {
147
+ var orig = typeof console[m] === "function" ? console[m].bind(console) : __noop;
148
+ console[m] = function () {
149
+ var args = Array.prototype.slice.call(arguments).map(function (a) {
150
+ try { return typeof a === "object" ? "[obj]" : a; } catch (e) { return "[?]"; }
151
+ });
152
+ return orig.apply(console, args);
153
+ };
154
+ })(__methods[__i]);
155
+ }
156
+
157
+ var __realOpen = typeof window.open === "function" ? window.open.bind(window) : null;
158
+ window.open = function (u, t, f) {
159
+ var url = typeof u === "string" ? u : (u ? String(u) : u);
160
+ if ((!url || url === "about:blank") && (t === "_self" || t === undefined)) return null;
161
+ return __realOpen ? __realOpen(u, t, f) : null;
162
+ };
163
+ `;
164
+ async function openAuthedContext(url, opts) {
165
+ const storageState = sessionExists(opts.sessionPath) ? opts.sessionPath : void 0;
166
+ let browser;
167
+ try {
168
+ browser = await chromium2.launch({ headless: false, channel: "chrome", args: LAUNCH_ARGS });
169
+ } catch {
170
+ try {
171
+ browser = await chromium2.launch({ headless: false, args: LAUNCH_ARGS });
172
+ } catch (bundledError) {
173
+ const ok = await opts.consentToDownload();
174
+ if (!ok) {
175
+ const detail = bundledError instanceof Error ? bundledError.message : String(bundledError);
176
+ throw new Error(`A browser is required to capture. Aborted. (${detail})`);
177
+ }
178
+ execSync("npx playwright install chromium", { stdio: "inherit" });
179
+ browser = await chromium2.launch({ headless: false, args: LAUNCH_ARGS });
180
+ }
181
+ }
182
+ const context = await browser.newContext({ viewport: { width: 1280, height: 800 }, storageState });
183
+ await context.route(/theajack\.github\.io/, (route) => route.abort());
184
+ await context.addInitScript({ content: EVASION_SCRIPT });
185
+ const page = await context.newPage();
186
+ try {
187
+ await page.goto(url, { waitUntil: "commit", timeout: 6e4 });
188
+ } catch (navError) {
189
+ const detail = navError instanceof Error ? navError.message : String(navError);
190
+ console.warn(`\u26A0 Couldn't auto-open ${url} (${detail}). Navigate there yourself in the open window.`);
191
+ }
192
+ return { context, page, close: async () => browser.close() };
193
+ }
194
+ async function waitForLogin(page, targetUrl, timeoutMs = 5 * 6e4) {
195
+ const target = new URL(targetUrl);
196
+ try {
197
+ await page.waitForFunction(
198
+ (path) => window.location.pathname === path,
199
+ target.pathname,
200
+ { timeout: timeoutMs, polling: 1e3 }
201
+ );
202
+ } catch {
203
+ throw new Error(`Timed out waiting for login. Make sure you signed in and reached: ${targetUrl}`);
204
+ }
205
+ }
206
+
207
+ // src/index.ts
208
+ function arg(flag, fallback) {
209
+ const i = process.argv.indexOf(flag);
210
+ return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
211
+ }
212
+ async function confirm(question) {
213
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
214
+ const answer = (await rl.question(`${question} (Y/n) `)).trim().toLowerCase();
215
+ rl.close();
216
+ return answer === "" || answer === "y" || answer === "yes";
217
+ }
218
+ async function waitForEnter(prompt) {
219
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
220
+ await rl.question(`${prompt} `);
221
+ rl.close();
222
+ }
223
+ async function main() {
224
+ const [, , command, token] = process.argv;
225
+ if (command !== "capture" || !token) {
226
+ console.error("Usage: design-qa-capture capture <token>");
227
+ process.exit(1);
228
+ }
229
+ const apiBase = arg("--api", process.env.DESIGN_QA_API ?? process.env.QUALITY_API ?? "https://app.quality.dev");
230
+ const settleMs = Number(arg("--settle", "3500"));
231
+ const { url, comparisonId } = await fetchTarget(apiBase, token);
232
+ const sessionPath = sessionPathForUrl(url);
233
+ const reusing = sessionExists(sessionPath);
234
+ console.log(reusing ? `\u2714 Reusing saved session for ${new URL(url).hostname}.` : "\u2197 Opening a browser window so you can sign in\u2026");
235
+ const { context, page, close } = await openAuthedContext(url, {
236
+ sessionPath,
237
+ consentToDownload: () => confirm("No Chrome found. Download a one-time ~150 MB Chromium for Design QA?")
238
+ });
239
+ try {
240
+ if (process.stdin.isTTY) {
241
+ console.log("A browser window is open. Log in if needed and go to the screen you want to QA.");
242
+ await waitForEnter("When the page is fully loaded, press Enter here to capture\u2026");
243
+ } else if (!reusing) {
244
+ console.log("\u2026 waiting to reach the page (no TTY; detecting by URL).");
245
+ await waitForLogin(page, url).catch(() => {
246
+ });
247
+ }
248
+ await page.waitForLoadState("networkidle", { timeout: 15e3 }).catch(() => {
249
+ });
250
+ await page.evaluate(async () => {
251
+ if ("fonts" in document) await document.fonts.ready;
252
+ }).catch(() => {
253
+ });
254
+ await page.waitForTimeout(Number.isFinite(settleMs) ? settleMs : 3500);
255
+ console.log("Capturing\u2026");
256
+ const capture = await captureFromPage(page, { extractElements: true, fullPage: true });
257
+ await saveSession(context, sessionPath);
258
+ console.log(`\u2714 Captured (${capture.width}\xD7${capture.height}). Uploading\u2026`);
259
+ await uploadCapture(apiBase, token, { png: capture.png, width: capture.width, height: capture.height, elements: capture.elements });
260
+ console.log(`\u2714 Done. Session saved \u2192 ${sessionPath}`);
261
+ console.log(`View it: ${apiBase}/comparisons/${comparisonId}`);
262
+ } finally {
263
+ await close();
264
+ }
265
+ }
266
+ main().catch((error) => {
267
+ console.error(`\u2716 ${error instanceof Error ? error.message : String(error)}`);
268
+ process.exit(1);
269
+ });
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "design-qa-capture",
3
+ "version": "0.1.0",
4
+ "description": "Local authenticated browser capture runner for Design QA comparisons.",
5
+ "type": "module",
6
+ "bin": {
7
+ "design-qa-capture": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist/index.js",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js --external:playwright --banner:js='#!/usr/bin/env node'",
18
+ "test": "vitest run",
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "dependencies": {
22
+ "playwright": "^1.48.0"
23
+ },
24
+ "devDependencies": {
25
+ "@design-qa/core": "*",
26
+ "@types/node": "^22.10.2",
27
+ "esbuild": "^0.24.0",
28
+ "typescript": "^5.7.2",
29
+ "vitest": "^2.1.8"
30
+ }
31
+ }