pursr 0.6.0 → 0.7.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.
@@ -1,192 +1,192 @@
1
- // pursor — DOM Snapshot + CSSOM + Selector Map.
2
- //
3
- // Every capture can optionally produce a .dom.json sidecar containing:
4
- // - serialized DOM (document.documentElement.outerHTML)
5
- // - computed styles for every visible element
6
- // - selector map (id → role → accessible name → xpath → css selector)
7
- // - viewport-relative bounding rects
8
- //
9
- // Useful for visual regression debugging without a browser —
10
- // compare DOM structure directly.
11
-
12
- import { launch, newPage } from "./runway.js";
13
- import { resolveViewport } from "./viewport.js";
14
- import { gotoOrThrow, settle } from "./overlays.js";
15
- import { writeFileSync, mkdirSync } from "node:fs";
16
- import { join, dirname } from "node:path";
17
- import { nowIso, requireArg } from "./util.js";
18
-
19
- // ─── Injected page script ──────────────────────────────────────────────
20
- // Runs inside the browser to collect all DOM + CSSOM data in one pass.
21
-
22
- const SNAPSHOT_PAGE_SCRIPT = `(() => {
23
- const results = {
24
- url: location.href,
25
- title: document.title,
26
- timestamp: new Date().toISOString(),
27
- viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
28
- dom: null, // outerHTML
29
- selectorMap: [], // element entries
30
- styles: {}, // cssText keyed by selector
31
- };
32
-
33
- // --- build xpath for an element ---
34
- function getXPath(el) {
35
- if (el === document.body) return '/html/body';
36
- if (el === document.documentElement) return '/html';
37
- let path = '';
38
- let current = el;
39
- while (current && current !== document.documentElement) {
40
- let idx = 1;
41
- let sib = current;
42
- while ((sib = sib.previousElementSibling) !== null) {
43
- if (sib.tagName === current.tagName) idx++;
44
- }
45
- path = '/' + current.tagName.toLowerCase() + '[' + idx + ']' + path;
46
- current = current.parentElement;
47
- }
48
- return '/html' + path;
49
- }
50
-
51
- // --- CSS selector for an element ---
52
- function getCSSSelector(el) {
53
- if (el.id) return '#' + CSS.escape(el.id);
54
- let path = [];
55
- let c = el;
56
- while (c && c !== document.documentElement) {
57
- let sel = c.tagName.toLowerCase();
58
- if (c.id) { path.unshift('#' + CSS.escape(c.id)); break; }
59
- if (c.className && typeof c.className === 'string') {
60
- const cls = c.className.trim().split(/\\s+/).filter(Boolean).map(cl => '.' + CSS.escape(cl)).join('');
61
- if (cls) sel += cls;
62
- }
63
- // add nth-child if needed
64
- const parent = c.parentElement;
65
- if (parent) {
66
- const siblings = Array.from(parent.children).filter(ch => ch.tagName === c.tagName);
67
- if (siblings.length > 1) {
68
- const idx = siblings.indexOf(c) + 1;
69
- sel += ':nth-of-type(' + idx + ')';
70
- }
71
- }
72
- path.unshift(sel);
73
- c = c.parentElement;
74
- }
75
- return path.join(' > ');
76
- }
77
-
78
- // --- collect element data ---
79
- const allElements = document.querySelectorAll('*');
80
- for (const el of allElements) {
81
- const tag = el.tagName.toLowerCase();
82
- // skip non-visible / empty elements (but keep <canvas>, <img>, <video>, <svg>, input, textarea)
83
- const keep = ['canvas','img','video','svg','input','textarea','select','button','a','p','h1','h2','h3','h4','h5','h6','li','td','th','blockquote','code','pre','figure','figcaption'];
84
- const rect = el.getBoundingClientRect();
85
- const visible = rect.width > 0 && rect.height > 0 && el.offsetParent !== null;
86
- if (!visible && !keep.includes(tag)) continue;
87
- if (['script','style','link','meta','head'].includes(tag)) continue;
88
-
89
- const id = el.id || null;
90
- const role = el.getAttribute('role');
91
- const ariaLabel = el.getAttribute('aria-label');
92
- const text = ((el.textContent || '').trim().slice(0, 200)) || null;
93
- const placeholder = el.getAttribute('placeholder');
94
- const alt = el.getAttribute('alt');
95
- const href = el.getAttribute('href');
96
- const src = el.getAttribute('src');
97
-
98
- const entry = {
99
- tag,
100
- id,
101
- css: getCSSSelector(el),
102
- xpath: getXPath(el),
103
- role: role || null,
104
- ariaLabel: ariaLabel || null,
105
- text,
106
- placeholder: placeholder || null,
107
- alt: alt || null,
108
- href: href || null,
109
- src: src || null,
110
- rect: visible ? { x: round(rect.x), y: round(rect.y), w: round(rect.width), h: round(rect.height) } : null,
111
- visible,
112
- };
113
-
114
- // get computed role from accessibility tree
115
- try { entry.ariaRole = el.computedRole || el.getAttribute('role') || null; } catch {}
116
-
117
- results.selectorMap.push(entry);
118
- }
119
-
120
- function round(n) { return Math.round(n * 10) / 10; }
121
-
122
- // --- get all computed stylesheets ---
123
- for (let i = 0; i < document.styleSheets.length; i++) {
124
- try {
125
- const ss = document.styleSheets[i];
126
- const rules = ss.cssRules || ss.rules;
127
- if (!rules) continue;
128
- for (let j = 0; j < rules.length; j++) {
129
- const r = rules[j];
130
- if (r && r.cssText && r.selectorText) {
131
- if (!results.styles[r.selectorText]) results.styles[r.selectorText] = [];
132
- if (results.styles[r.selectorText].length < 5) { // cap per selector
133
- results.styles[r.selectorText].push(r.cssText);
134
- }
135
- }
136
- }
137
- } catch {}
138
- }
139
-
140
- // --- serialize DOM ---
141
- results.dom = document.documentElement.outerHTML;
142
-
143
- return results;
144
- })()`;
145
-
146
- // ─── Public API ─────────────────────────────────────────────────────────
147
-
148
- /**
149
- * Capture full DOM snapshot of a URL.
150
- * Returns the snapshot data AND writes it to out path.
151
- */
152
- export async function captureDomSnapshot({ url, out, flags = {} }) {
153
- requireArg("url", url, "string");
154
- const viewport = resolveViewport(flags);
155
- const browser = await launch();
156
- try {
157
- const page = await newPage(browser, viewport);
158
- const r = await gotoOrThrow(page, url);
159
- await settle(page);
160
- // Give dynamic content a moment
161
- await page.waitForTimeout(500);
162
- const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
163
- snapshot.navStatus = r.status;
164
- snapshot.navTitle = r.title;
165
-
166
- // Write output
167
- if (out) {
168
- mkdirSync(dirname(out), { recursive: true });
169
- writeFileSync(out, JSON.stringify(snapshot, null, 2));
170
- }
171
-
172
- return snapshot;
173
- } finally {
174
- try { await browser.close(); } catch {}
175
- }
176
- }
177
-
178
- /**
179
- * Attach DOM snapshot as sidecar to an existing shoot result.
180
- * Call after runShoot — reuses the active page.
181
- */
182
- export async function captureDomSnapshotSidecar(page, out) {
183
- if (!page || !out) return null;
184
- try {
185
- const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
186
- const domPath = out.replace(/\.png$/i, ".dom.json");
187
- writeFileSync(domPath, JSON.stringify(snapshot, null, 2));
188
- return domPath;
189
- } catch {
190
- return null;
191
- }
192
- }
1
+ // pursr — DOM Snapshot + CSSOM + Selector Map.
2
+ //
3
+ // Every capture can optionally produce a .dom.json sidecar containing:
4
+ // pursr - serialized DOM (document.documentElement.outerHTML)
5
+ // pursr - computed styles for every visible element
6
+ // pursr - selector map (id → role → accessible name → xpath → css selector)
7
+ // pursr - viewport-relative bounding rects
8
+ //
9
+ // Useful for visual regression debugging without a browser —
10
+ // compare DOM structure directly.
11
+
12
+ import { launch, newPage } from "./runway.js";
13
+ import { resolveViewport } from "./viewport.js";
14
+ import { gotoOrThrow, settle } from "./overlays.js";
15
+ import { writeFileSync, mkdirSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { nowIso, requireArg } from "./util.js";
18
+
19
+ // ─── Injected page script ──────────────────────────────────────────────
20
+ // Runs inside the browser to collect all DOM + CSSOM data in one pass.
21
+
22
+ const SNAPSHOT_PAGE_SCRIPT = `(() => {
23
+ const results = {
24
+ url: location.href,
25
+ title: document.title,
26
+ timestamp: new Date().toISOString(),
27
+ viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio },
28
+ dom: null, // outerHTML
29
+ selectorMap: [], // element entries
30
+ styles: {}, // cssText keyed by selector
31
+ };
32
+
33
+ // --- build xpath for an element ---
34
+ function getXPath(el) {
35
+ if (el === document.body) return '/html/body';
36
+ if (el === document.documentElement) return '/html';
37
+ let path = '';
38
+ let current = el;
39
+ while (current && current !== document.documentElement) {
40
+ let idx = 1;
41
+ let sib = current;
42
+ while ((sib = sib.previousElementSibling) !== null) {
43
+ if (sib.tagName === current.tagName) idx++;
44
+ }
45
+ path = '/' + current.tagName.toLowerCase() + '[' + idx + ']' + path;
46
+ current = current.parentElement;
47
+ }
48
+ return '/html' + path;
49
+ }
50
+
51
+ // --- CSS selector for an element ---
52
+ function getCSSSelector(el) {
53
+ if (el.id) return '#' + CSS.escape(el.id);
54
+ let path = [];
55
+ let c = el;
56
+ while (c && c !== document.documentElement) {
57
+ let sel = c.tagName.toLowerCase();
58
+ if (c.id) { path.unshift('#' + CSS.escape(c.id)); break; }
59
+ if (c.className && typeof c.className === 'string') {
60
+ const cls = c.className.trim().split(/\\s+/).filter(Boolean).map(cl => '.' + CSS.escape(cl)).join('');
61
+ if (cls) sel += cls;
62
+ }
63
+ // add nth-child if needed
64
+ const parent = c.parentElement;
65
+ if (parent) {
66
+ const siblings = Array.from(parent.children).filter(ch => ch.tagName === c.tagName);
67
+ if (siblings.length > 1) {
68
+ const idx = siblings.indexOf(c) + 1;
69
+ sel += ':nth-of-type(' + idx + ')';
70
+ }
71
+ }
72
+ path.unshift(sel);
73
+ c = c.parentElement;
74
+ }
75
+ return path.join(' > ');
76
+ }
77
+
78
+ // --- collect element data ---
79
+ const allElements = document.querySelectorAll('*');
80
+ for (const el of allElements) {
81
+ const tag = el.tagName.toLowerCase();
82
+ // skip non-visible / empty elements (but keep <canvas>, <img>, <video>, <svg>, input, textarea)
83
+ const keep = ['canvas','img','video','svg','input','textarea','select','button','a','p','h1','h2','h3','h4','h5','h6','li','td','th','blockquote','code','pre','figure','figcaption'];
84
+ const rect = el.getBoundingClientRect();
85
+ const visible = rect.width > 0 && rect.height > 0 && el.offsetParent !== null;
86
+ if (!visible && !keep.includes(tag)) continue;
87
+ if (['script','style','link','meta','head'].includes(tag)) continue;
88
+
89
+ const id = el.id || null;
90
+ const role = el.getAttribute('role');
91
+ const ariaLabel = el.getAttribute('aria-label');
92
+ const text = ((el.textContent || '').trim().slice(0, 200)) || null;
93
+ const placeholder = el.getAttribute('placeholder');
94
+ const alt = el.getAttribute('alt');
95
+ const href = el.getAttribute('href');
96
+ const src = el.getAttribute('src');
97
+
98
+ const entry = {
99
+ tag,
100
+ id,
101
+ css: getCSSSelector(el),
102
+ xpath: getXPath(el),
103
+ role: role || null,
104
+ ariaLabel: ariaLabel || null,
105
+ text,
106
+ placeholder: placeholder || null,
107
+ alt: alt || null,
108
+ href: href || null,
109
+ src: src || null,
110
+ rect: visible ? { x: round(rect.x), y: round(rect.y), w: round(rect.width), h: round(rect.height) } : null,
111
+ visible,
112
+ };
113
+
114
+ // get computed role from accessibility tree
115
+ try { entry.ariaRole = el.computedRole || el.getAttribute('role') || null; } catch {}
116
+
117
+ results.selectorMap.push(entry);
118
+ }
119
+
120
+ function round(n) { return Math.round(n * 10) / 10; }
121
+
122
+ // --- get all computed stylesheets ---
123
+ for (let i = 0; i < document.styleSheets.length; i++) {
124
+ try {
125
+ const ss = document.styleSheets[i];
126
+ const rules = ss.cssRules || ss.rules;
127
+ if (!rules) continue;
128
+ for (let j = 0; j < rules.length; j++) {
129
+ const r = rules[j];
130
+ if (r && r.cssText && r.selectorText) {
131
+ if (!results.styles[r.selectorText]) results.styles[r.selectorText] = [];
132
+ if (results.styles[r.selectorText].length < 5) { // cap per selector
133
+ results.styles[r.selectorText].push(r.cssText);
134
+ }
135
+ }
136
+ }
137
+ } catch {}
138
+ }
139
+
140
+ // --- serialize DOM ---
141
+ results.dom = document.documentElement.outerHTML;
142
+
143
+ return results;
144
+ })()`;
145
+
146
+ // ─── Public API ─────────────────────────────────────────────────────────
147
+
148
+ /**
149
+ * Capture full DOM snapshot of a URL.
150
+ * Returns the snapshot data AND writes it to out path.
151
+ */
152
+ export async function captureDomSnapshot({ url, out, flags = {} }) {
153
+ requireArg("url", url, "string");
154
+ const viewport = resolveViewport(flags);
155
+ const browser = await launch();
156
+ try {
157
+ const page = await newPage(browser, viewport);
158
+ const r = await gotoOrThrow(page, url);
159
+ await settle(page);
160
+ // Give dynamic content a moment
161
+ await page.waitForTimeout(500);
162
+ const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
163
+ snapshot.navStatus = r.status;
164
+ snapshot.navTitle = r.title;
165
+
166
+ // Write output
167
+ if (out) {
168
+ mkdirSync(dirname(out), { recursive: true });
169
+ writeFileSync(out, JSON.stringify(snapshot, null, 2));
170
+ }
171
+
172
+ return snapshot;
173
+ } finally {
174
+ try { await browser.close(); } catch {}
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Attach DOM snapshot as sidecar to an existing shoot result.
180
+ * Call after runShoot — reuses the active page.
181
+ */
182
+ export async function captureDomSnapshotSidecar(page, out) {
183
+ if (!page || !out) return null;
184
+ try {
185
+ const snapshot = await page.evaluate(SNAPSHOT_PAGE_SCRIPT);
186
+ const domPath = out.replace(/\.png$/i, ".dom.json");
187
+ writeFileSync(domPath, JSON.stringify(snapshot, null, 2));
188
+ return domPath;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
package/src/eval.js CHANGED
@@ -1,18 +1,18 @@
1
- // Evaluate a JS string in the page, optionally screenshot after.
2
-
3
- import { launch, newPage } from "./runway.js";
4
- import { DEFAULT_VIEWPORT } from "./viewport.js";
5
- import { gotoOrThrow } from "./overlays.js";
6
- import { requireArg } from "./util.js";
7
-
8
- export async function runEval(url, js, out) {
9
- requireArg("url", url, "string");
10
- const browser = await launch();
11
- try {
12
- const page = await newPage(browser, DEFAULT_VIEWPORT);
13
- const r = await gotoOrThrow(page, url);
14
- const result = await page.evaluate(js);
15
- if (out) await page.screenshot({ path: out, fullPage: false });
16
- return { ...r, url, out, result };
17
- } finally { try { await browser.close(); } catch {} }
1
+ // Evaluate a JS string in the page, optionally screenshot after.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { DEFAULT_VIEWPORT } from "./viewport.js";
5
+ import { gotoOrThrow } from "./overlays.js";
6
+ import { requireArg } from "./util.js";
7
+
8
+ export async function runEval(url, js, out) {
9
+ requireArg("url", url, "string");
10
+ const browser = await launch();
11
+ try {
12
+ const page = await newPage(browser, DEFAULT_VIEWPORT);
13
+ const r = await gotoOrThrow(page, url);
14
+ const result = await page.evaluate(js);
15
+ if (out) await page.screenshot({ path: out, fullPage: false });
16
+ return { ...r, url, out, result };
17
+ } finally { try { await browser.close(); } catch {} }
18
18
  }
@@ -1,51 +1,51 @@
1
- // Every-viewport: capture one screenshot at every registered viewport
2
- // preset in a single command. No JSON plan needed.
3
- //
4
- // Usage:
5
- // pursor every-viewport https://example.com
6
- // pursor every-viewport https://example.com --out ./report
7
-
8
- import { mkdirSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { launch } from "./runway.js";
11
- import { listViewports } from "./viewport.js";
12
- import { runShoot } from "./shoot.js";
13
- import { asNum, nowIso, renderEveryViewportHtml } from "./util.js";
14
-
15
- export async function runEveryViewport({ url, outDir, viewports, browser: extBrowser }) {
16
- const ownBrowser = !extBrowser;
17
- const browser = extBrowser || await launch();
18
- const dir = outDir || join(".", `every-viewport-${Date.now()}`);
19
- mkdirSync(dir, { recursive: true });
20
- const all = listViewports();
21
- const wanted = viewports?.length ? all.filter(v => viewports.includes(v.name)) : all;
22
- const captures = [];
23
- try {
24
- // Bounded concurrency: 3 viewports at a time. Each runShoot reuses the
25
- // shared browser, so we cap the pool to avoid exhausting Chromium.
26
- const POOL = 3;
27
- let cursor = 0;
28
- async function worker() {
29
- while (cursor < wanted.length) {
30
- const idx = cursor++;
31
- const vp = wanted[idx];
32
- const out = join(dir, `${vp.name}.png`);
33
- const t0 = Date.now();
34
- try {
35
- const meta = await runShoot({ url, out, flags: { preset: vp.name }, browser });
36
- captures.push({ name: vp.name, out, ok: true, ms: Date.now() - t0, meta });
37
- } catch (e) {
38
- captures.push({ name: vp.name, out, ok: false, ms: Date.now() - t0, error: e.message });
39
- }
40
- }
41
- }
42
- const workers = Array.from({ length: Math.min(POOL, wanted.length) }, () => worker());
43
- await Promise.all(workers);
44
- } finally {
45
- if (ownBrowser) try { await browser.close(); } catch {}
46
- }
47
- const summary = { url, outDir: dir, captures, ts: nowIso(), ok: captures.every(c => c.ok) };
48
- writeFileSync(join(dir, "every-viewport.json"), JSON.stringify(summary, null, 2));
49
- writeFileSync(join(dir, "index.html"), renderEveryViewportHtml(summary));
50
- return summary;
51
- }
1
+ // Every-viewport: capture one screenshot at every registered viewport
2
+ // preset in a single command. No JSON plan needed.
3
+ //
4
+ // Usage:
5
+ // pursr every-viewport https://example.com
6
+ // pursr every-viewport https://example.com --out ./report
7
+
8
+ import { mkdirSync, writeFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { launch } from "./runway.js";
11
+ import { listViewports } from "./viewport.js";
12
+ import { runShoot } from "./shoot.js";
13
+ import { asNum, nowIso, renderEveryViewportHtml } from "./util.js";
14
+
15
+ export async function runEveryViewport({ url, outDir, viewports, browser: extBrowser }) {
16
+ const ownBrowser = !extBrowser;
17
+ const browser = extBrowser || await launch();
18
+ const dir = outDir || join(".", `every-viewport-${Date.now()}`);
19
+ mkdirSync(dir, { recursive: true });
20
+ const all = listViewports();
21
+ const wanted = viewports?.length ? all.filter(v => viewports.includes(v.name)) : all;
22
+ const captures = [];
23
+ try {
24
+ // Bounded concurrency: 3 viewports at a time. Each runShoot reuses the
25
+ // shared browser, so we cap the pool to avoid exhausting Chromium.
26
+ const POOL = 3;
27
+ let cursor = 0;
28
+ async function worker() {
29
+ while (cursor < wanted.length) {
30
+ const idx = cursor++;
31
+ const vp = wanted[idx];
32
+ const out = join(dir, `${vp.name}.png`);
33
+ const t0 = Date.now();
34
+ try {
35
+ const meta = await runShoot({ url, out, flags: { preset: vp.name }, browser });
36
+ captures.push({ name: vp.name, out, ok: true, ms: Date.now() - t0, meta });
37
+ } catch (e) {
38
+ captures.push({ name: vp.name, out, ok: false, ms: Date.now() - t0, error: e.message });
39
+ }
40
+ }
41
+ }
42
+ const workers = Array.from({ length: Math.min(POOL, wanted.length) }, () => worker());
43
+ await Promise.all(workers);
44
+ } finally {
45
+ if (ownBrowser) try { await browser.close(); } catch {}
46
+ }
47
+ const summary = { url, outDir: dir, captures, ts: nowIso(), ok: captures.every(c => c.ok) };
48
+ writeFileSync(join(dir, "every-viewport.json"), JSON.stringify(summary, null, 2));
49
+ writeFileSync(join(dir, "index.html"), renderEveryViewportHtml(summary));
50
+ return summary;
51
+ }
package/src/frames.js CHANGED
@@ -1,34 +1,34 @@
1
- // Frames: capture N screenshots at intervalMs.
2
-
3
- import { launch, newPage } from "./runway.js";
4
- import { resolveViewport } from "./viewport.js";
5
- import { gotoOrThrow, settle } from "./overlays.js";
6
- import { asNum, nowIso, shortHash, requireArg } from "./util.js";
7
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
9
-
10
- export async function runFrames({ url, count, intervalMs, outDir, flags = {}, browser: extBrowser }) {
11
- requireArg("url", url, "string");
12
- const n = Math.max(1, Math.min(120, asNum(count, 8)));
13
- const stepMs = Math.max(16, asNum(intervalMs, 250));
14
- const dir = outDir;
15
- mkdirSync(dir, { recursive: true });
16
- const viewport = resolveViewport(flags);
17
- const ownBrowser = !extBrowser;
18
- const browser = extBrowser || await launch();
19
- const meta = { url, outDir: dir, count: n, intervalMs: stepMs, viewport, files: [], ts: nowIso() };
20
- try {
21
- const page = await newPage(browser, viewport);
22
- const r = await gotoOrThrow(page, url); await settle(page);
23
- meta.status = r.status; meta.title = r.title;
24
- for (let i = 0; i < n; i++) {
25
- const f = join(dir, `frame-${String(i).padStart(3, "0")}.png`);
26
- await page.screenshot({ path: f, fullPage: false });
27
- const buf = readFileSync(f);
28
- meta.files.push({ i, out: f, size: buf.length, hash: shortHash(buf) });
29
- if (i + 1 < n) await page.waitForTimeout(stepMs);
30
- }
31
- writeFileSync(join(dir, "frames.json"), JSON.stringify(meta, null, 2));
32
- return meta;
33
- } finally { if (ownBrowser) try { await browser.close(); } catch {} }
1
+ // Frames: capture N screenshots at intervalMs.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { resolveViewport } from "./viewport.js";
5
+ import { gotoOrThrow, settle } from "./overlays.js";
6
+ import { asNum, nowIso, shortHash, requireArg } from "./util.js";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export async function runFrames({ url, count, intervalMs, outDir, flags = {}, browser: extBrowser }) {
11
+ requireArg("url", url, "string");
12
+ const n = Math.max(1, Math.min(120, asNum(count, 8)));
13
+ const stepMs = Math.max(16, asNum(intervalMs, 250));
14
+ const dir = outDir;
15
+ mkdirSync(dir, { recursive: true });
16
+ const viewport = resolveViewport(flags);
17
+ const ownBrowser = !extBrowser;
18
+ const browser = extBrowser || await launch();
19
+ const meta = { url, outDir: dir, count: n, intervalMs: stepMs, viewport, files: [], ts: nowIso() };
20
+ try {
21
+ const page = await newPage(browser, viewport);
22
+ const r = await gotoOrThrow(page, url); await settle(page);
23
+ meta.status = r.status; meta.title = r.title;
24
+ for (let i = 0; i < n; i++) {
25
+ const f = join(dir, `frame-${String(i).padStart(3, "0")}.png`);
26
+ await page.screenshot({ path: f, fullPage: false });
27
+ const buf = readFileSync(f);
28
+ meta.files.push({ i, out: f, size: buf.length, hash: shortHash(buf) });
29
+ if (i + 1 < n) await page.waitForTimeout(stepMs);
30
+ }
31
+ writeFileSync(join(dir, "frames.json"), JSON.stringify(meta, null, 2));
32
+ return meta;
33
+ } finally { if (ownBrowser) try { await browser.close(); } catch {} }
34
34
  }