pursor 0.2.0 → 0.3.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.
package/src/sweep.js CHANGED
@@ -1,105 +1,119 @@
1
- // Sweep: batch capture plan. Each step is one of:
2
- // { name, shoot: { url?, preset?, ...flags } }
3
- // { name, hover: { url?, selector } }
4
- // { name, frames: { url?, count, intervalMs } }
5
- // { name, diff: { url?, ref, threshold? } }
6
- // { name, <customOp>: { ... } } // from a registered plugin
7
-
8
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
- import { join, resolve as pathResolve } from "node:path";
10
- import { runShootWithSidecar } from "./shoot.js";
11
- import { runFrames } from "./frames.js";
12
- import { runDiff } from "./diff.js";
13
- import { launch, newPage } from "./runway.js";
14
- import { resolveViewport } from "./viewport.js";
15
- import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
16
- import { resolveLocator } from "./selector.js";
17
- import { resolveHealedSelector } from "./selector-heal.js";
18
- import { asNum, nowIso, findStepPng, renderSweepHtml } from "./util.js";
19
- import { getSweepOp } from "./plugin.js";
20
- import { writeCiOutput } from "./ci-output.js";
21
-
22
- export async function runSweep(planPath, outDirArg) {
23
- const plan = JSON.parse(readFileSync(planPath, "utf8"));
24
- // Validate plan shape early for actionable errors.
25
- const { validateSweepPlan } = await import("./sweep-schema.js");
26
- const v = validateSweepPlan(plan);
27
- if (!v.valid) {
28
- const msg = v.errors.length === 1
29
- ? v.errors[0]
30
- : v.errors.map((e, i) => " " + (i + 1) + ". " + e).join("\n");
31
- throw new Error("sweep plan validation failed:\n" + msg);
32
- }
33
- const dir = outDirArg ?? plan.outDir ?? join(plan.outBase || ".", `sweep-${plan.name || "plan"}`);
34
- mkdirSync(dir, { recursive: true });
35
- const summary = { plan: pathResolve(planPath), outDir: dir, name: plan.name || null, steps: [], ts: nowIso() };
36
- const browser = await launch();
37
- try {
38
- for (let i = 0; i < plan.steps.length; i++) {
39
- const s = plan.steps[i] || {};
40
- const stepName = s.name || `step-${i}`;
41
- const stepOut = join(dir, `${String(i).padStart(2, "0")}-${stepName}.png`);
42
- const t0 = Date.now();
43
- const stepKeys = Object.keys(s).filter(k => k !== "name");
44
- if (!stepKeys.length) throw new Error(`step ${i} has no operation key (only "name"?)`);
45
- if (stepKeys.length > 1) throw new Error(`step ${i} has ${stepKeys.length} operation keys (${stepKeys.join(", ")}). Only one allowed per step.`);
46
- const opKey = stepKeys[0];
47
- const entry = { i, name: stepName, op: opKey };
48
- try {
49
- if (s.shoot) {
50
- const url = s.shoot.url || (plan.base ? plan.base : null);
51
- if (!url) throw new Error("shoot: missing url (and no plan.base)");
52
- const flags = Object.fromEntries(Object.entries(s.shoot).filter(([k]) => k !== "url").map(([k, v]) => [k, v]));
53
- const meta = await runShootWithSidecar({ url, out: stepOut, flags, browser });
54
- entry.meta = meta;
55
- } else if (s.hover) {
56
- const url = s.hover.url || (plan.base ? plan.base : null);
57
- if (!url) throw new Error("hover: missing url (and no plan.base)");
58
- const selector = s.hover.selector;
59
- if (!selector) throw new Error("hover: missing selector");
60
- const viewport = resolveViewport(s.hover);
61
- const page = await newPage(browser, viewport);
62
- const r = await gotoOrThrow(page, url); await settle(page);
63
- // Auto-heal: selector can be string or array of fallbacks
64
- const healed = await resolveHealedSelector(page, selector);
65
- await healed.locator.first().hover({ timeout: CLICK_TIMEOUT_MS });
66
- await page.waitForTimeout(asNum(s.hover.settleMs, 300));
67
- await page.screenshot({ path: stepOut, fullPage: false });
68
- await page.close();
69
- entry.meta = { ...r, url, out: stepOut, selector, viewport };
70
- } else if (s.frames) {
71
- const url = s.frames.url || (plan.base ? plan.base : null);
72
- if (!url) throw new Error("frames: missing url (and no plan.base)");
73
- const subDir = join(dir, `${String(i).padStart(2, "0")}-${stepName}-frames`);
74
- const meta = await runFrames({ url, count: asNum(s.frames.count, 8), intervalMs: asNum(s.frames.intervalMs, 200), outDir: subDir, flags: s.frames, browser });
75
- entry.meta = meta;
76
- } else if (s.diff) {
77
- const refName = s.diff.ref;
78
- if (!refName) throw new Error("diff: missing ref (step name or filename)");
79
- const refPath = findStepPng(dir, refName);
80
- if (!refPath) throw new Error(`diff: ref not found in dir: ${refName}`);
81
- const url = s.diff.url || (plan.base ? plan.base : null);
82
- if (!url) throw new Error("diff: missing url (and no plan.base)");
83
- const meta = await runDiff(url, refPath, stepOut, asNum(s.diff.threshold, 0.1), browser);
84
- entry.meta = meta;
85
- } else if (opKey && getSweepOp(opKey)) {
86
- const customOp = getSweepOp(opKey);
87
- if (typeof customOp !== "function") throw new Error(`custom op "${opKey}" is not a function`);
88
- const ctx = { url: plan.base, out: stepOut, browser, page: null };
89
- const result = await customOp(ctx, s[opKey] || {});
90
- entry.meta = result != null ? result : { out: stepOut };
91
- } else {
92
- throw new Error(`step "${opKey}": unknown or unregistered operation`);
93
- }
94
- entry.ms = Date.now() - t0; entry.ok = true;
95
- } catch (e) {
96
- entry.ok = false; entry.error = e.message; entry.ms = Date.now() - t0;
97
- }
98
- summary.steps.push(entry);
99
- }
100
- } finally { try { await browser.close(); } catch {} }
101
- writeFileSync(join(dir, "sweep.json"), JSON.stringify(summary, null, 2));
102
- writeFileSync(join(dir, "index.html"), renderSweepHtml(summary));
103
- try { writeCiOutput(summary, dir); } catch (e) { console.error("[pursor] CI output error:", e.message); }
104
- return summary;
1
+ // Sweep: batch capture plan. Each step is one of:
2
+ // { name, shoot: { url?, preset?, ...flags } }
3
+ // { name, hover: { url?, selector } }
4
+ // { name, frames: { url?, count, intervalMs } }
5
+ // { name, diff: { url?, ref, threshold? } }
6
+ // { name, <customOp>: { ... } } // from a registered plugin
7
+
8
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { join, resolve as pathResolve } from "node:path";
10
+ import { runShootWithSidecar } from "./shoot.js";
11
+ import { runFrames } from "./frames.js";
12
+ import { runDiff } from "./diff.js";
13
+ import { launch, newPage } from "./runway.js";
14
+ import { resolveViewport } from "./viewport.js";
15
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
16
+ import { resolveLocator } from "./selector.js";
17
+ import { resolveHealedSelector } from "./selector-heal.js";
18
+ import { asNum, nowIso, findStepPng, renderSweepHtml } from "./util.js";
19
+ import { getSweepOp } from "./plugin.js";
20
+ import { writeCiOutput } from "./ci-output.js";
21
+
22
+ export async function runSweep(planPath, outDirArg) {
23
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
24
+ if (!plan || typeof plan !== "object" || !Array.isArray(plan.steps)) throw new Error("sweep plan: missing steps[]");
25
+ if (!plan.steps.length) throw new Error("sweep plan: steps[] is empty");
26
+ const dir = outDirArg ?? plan.outDir ?? join(plan.outBase || ".", `sweep-${plan.name || "plan"}`);
27
+ mkdirSync(dir, { recursive: true });
28
+ const summary = { plan: pathResolve(planPath), outDir: dir, name: plan.name || null, steps: [], ts: nowIso() };
29
+ const browser = await launch();
30
+ try {
31
+ // Per-step runner: returns an entry for the summary.
32
+ async function runStep(i) {
33
+ const s = plan.steps[i] || {};
34
+ const stepName = s.name || `step-${i}`;
35
+ const stepOut = join(dir, `${String(i).padStart(2, "0")}-${stepName}.png`);
36
+ const t0 = Date.now();
37
+ const stepKeys = Object.keys(s).filter(k => k !== "name");
38
+ if (!stepKeys.length) throw new Error(`step ${i} has no operation key (only "name"?)`);
39
+ if (stepKeys.length > 1) throw new Error(`step ${i} has ${stepKeys.length} operation keys (${stepKeys.join(", ")}). Only one allowed per step.`);
40
+ const opKey = stepKeys[0];
41
+ const entry = { i, name: stepName, op: opKey };
42
+ try {
43
+ if (s.shoot) {
44
+ const url = s.shoot.url || (plan.base ? plan.base : null);
45
+ if (!url) throw new Error("shoot: missing url (and no plan.base)");
46
+ const flags = Object.fromEntries(Object.entries(s.shoot).filter(([k]) => k !== "url").map(([k, v]) => [k, v]));
47
+ const meta = await runShootWithSidecar({ url, out: stepOut, flags, browser });
48
+ entry.meta = meta;
49
+ } else if (s.hover) {
50
+ const url = s.hover.url || (plan.base ? plan.base : null);
51
+ if (!url) throw new Error("hover: missing url (and no plan.base)");
52
+ const selector = s.hover.selector;
53
+ if (!selector) throw new Error("hover: missing selector");
54
+ const viewport = resolveViewport(s.hover);
55
+ const page = await newPage(browser, viewport);
56
+ const r = await gotoOrThrow(page, url); await settle(page);
57
+ const healed = await resolveHealedSelector(page, selector);
58
+ await healed.locator.first().hover({ timeout: CLICK_TIMEOUT_MS });
59
+ await page.waitForTimeout(asNum(s.hover.settleMs, 300));
60
+ await page.screenshot({ path: stepOut, fullPage: false });
61
+ await page.close();
62
+ entry.meta = { ...r, url, out: stepOut, selector, viewport };
63
+ } else if (s.frames) {
64
+ const url = s.frames.url || (plan.base ? plan.base : null);
65
+ if (!url) throw new Error("frames: missing url (and no plan.base)");
66
+ const subDir = join(dir, `${String(i).padStart(2, "0")}-${stepName}-frames`);
67
+ const meta = await runFrames({ url, count: asNum(s.frames.count, 8), intervalMs: asNum(s.frames.intervalMs, 200), outDir: subDir, flags: s.frames, browser });
68
+ entry.meta = meta;
69
+ } else if (s.diff) {
70
+ const refName = s.diff.ref;
71
+ if (!refName) throw new Error("diff: missing ref (step name or filename)");
72
+ const refPath = findStepPng(dir, refName);
73
+ if (!refPath) throw new Error(`diff: ref not found in dir: ${refName}`);
74
+ const url = s.diff.url || (plan.base ? plan.base : null);
75
+ if (!url) throw new Error("diff: missing url (and no plan.base)");
76
+ const meta = await runDiff(url, refPath, stepOut, asNum(s.diff.threshold, 0.1), browser);
77
+ entry.meta = meta;
78
+ } else if (opKey && getSweepOp(opKey)) {
79
+ const customOp = getSweepOp(opKey);
80
+ if (typeof customOp !== "function") throw new Error(`custom op "${opKey}" is not a function`);
81
+ const ctx = { url: plan.base, out: stepOut, browser, page: null };
82
+ const result = await customOp(ctx, s[opKey] || {});
83
+ entry.meta = result != null ? result : { out: stepOut };
84
+ } else {
85
+ throw new Error(`step "${opKey}": unknown or unregistered operation`);
86
+ }
87
+ entry.ms = Date.now() - t0; entry.ok = true;
88
+ } catch (e) {
89
+ entry.ok = false; entry.error = e.message; entry.ms = Date.now() - t0;
90
+ }
91
+ return entry;
92
+ }
93
+
94
+ // Schedule: serial by default, or via a worker pool when plan.parallel > 1.
95
+ const poolSize = Math.max(1, Number(plan.parallel) || 1);
96
+ const results = new Array(plan.steps.length);
97
+ if (poolSize === 1) {
98
+ for (let i = 0; i < plan.steps.length; i++) {
99
+ results[i] = await runStep(i);
100
+ }
101
+ } else {
102
+ let cursor = 0;
103
+ async function worker() {
104
+ while (true) {
105
+ const idx = cursor++;
106
+ if (idx >= plan.steps.length) return;
107
+ results[idx] = await runStep(idx);
108
+ }
109
+ }
110
+ const workers = Array.from({ length: Math.min(poolSize, plan.steps.length) }, () => worker());
111
+ await Promise.all(workers);
112
+ }
113
+ for (const r of results) summary.steps.push(r);
114
+ } finally { try { await browser.close(); } catch {} }
115
+ writeFileSync(join(dir, "sweep.json"), JSON.stringify(summary, null, 2));
116
+ writeFileSync(join(dir, "index.html"), renderSweepHtml(summary));
117
+ try { writeCiOutput(summary, dir); } catch (e) { console.error("[pursor] CI output error:", e.message); }
118
+ return summary;
105
119
  }