viewable-one 0.0.0-stage → 0.12.2

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 (89) hide show
  1. package/CHANGELOG.md +1483 -0
  2. package/LICENSE +21 -0
  3. package/README.md +738 -2
  4. package/SPAWN.md +2108 -0
  5. package/glytch.mjs +517 -0
  6. package/lib/acp.mjs +1018 -0
  7. package/lib/admission.mjs +105 -0
  8. package/lib/agent-selection.mjs +124 -0
  9. package/lib/agents.mjs +914 -0
  10. package/lib/atomic.mjs +96 -0
  11. package/lib/auth.mjs +1491 -0
  12. package/lib/authenticatedHttp.mjs +75 -0
  13. package/lib/boardside.mjs +225 -0
  14. package/lib/child.mjs +1292 -0
  15. package/lib/cli-output.mjs +176 -0
  16. package/lib/cloudflared-release.json +25 -0
  17. package/lib/cloudflared.mjs +81 -0
  18. package/lib/codex-rpc.mjs +92 -0
  19. package/lib/codex.mjs +767 -0
  20. package/lib/codexApps.mjs +344 -0
  21. package/lib/codexPlanWindow.mjs +84 -0
  22. package/lib/codexToolInputs.mjs +139 -0
  23. package/lib/codingChecks.mjs +55 -0
  24. package/lib/compose.mjs +76 -0
  25. package/lib/computerApps.mjs +110 -0
  26. package/lib/conversationRules.mjs +45 -0
  27. package/lib/detect.mjs +629 -0
  28. package/lib/devserver.mjs +670 -0
  29. package/lib/digest.mjs +290 -0
  30. package/lib/doctor.mjs +464 -0
  31. package/lib/exitClass.mjs +64 -0
  32. package/lib/hosted.mjs +112 -0
  33. package/lib/imageAttachments.mjs +31 -0
  34. package/lib/init-connect.mjs +418 -0
  35. package/lib/kill.mjs +104 -0
  36. package/lib/logindaemon.mjs +976 -0
  37. package/lib/logs.mjs +120 -0
  38. package/lib/mcpjson.mjs +242 -0
  39. package/lib/memberPreview.mjs +173 -0
  40. package/lib/multiwatch.mjs +428 -0
  41. package/lib/pagePictures.mjs +396 -0
  42. package/lib/pageRoutes.mjs +70 -0
  43. package/lib/paths.mjs +10 -0
  44. package/lib/permissionAsk.mjs +418 -0
  45. package/lib/picker.mjs +300 -0
  46. package/lib/platform.mjs +60 -0
  47. package/lib/previewGateway.mjs +130 -0
  48. package/lib/previewTunnel.mjs +85 -0
  49. package/lib/procs.mjs +428 -0
  50. package/lib/project-mcp.mjs +288 -0
  51. package/lib/projectPaths.mjs +31 -0
  52. package/lib/refusal.mjs +70 -0
  53. package/lib/registry.mjs +204 -0
  54. package/lib/repomap.mjs +323 -0
  55. package/lib/restartPass.mjs +151 -0
  56. package/lib/retired-address.mjs +78 -0
  57. package/lib/rowsAnswers.mjs +388 -0
  58. package/lib/rowsEvents.mjs +86 -0
  59. package/lib/run-engine.mjs +16 -0
  60. package/lib/runner-health.mjs +57 -0
  61. package/lib/runner.mjs +1640 -0
  62. package/lib/runnerAdmission.mjs +35 -0
  63. package/lib/runnerApi.mjs +312 -0
  64. package/lib/runnerCompletion.mjs +406 -0
  65. package/lib/runnerLease.mjs +37 -0
  66. package/lib/runnerMaintenance.mjs +27 -0
  67. package/lib/runnerPace.mjs +283 -0
  68. package/lib/runnerPerformance.mjs +26 -0
  69. package/lib/runnerRealtime.mjs +340 -0
  70. package/lib/runnerRegister.mjs +96 -0
  71. package/lib/runnerSettings.mjs +40 -0
  72. package/lib/runnerSettlement.mjs +145 -0
  73. package/lib/runnerWorkspace.mjs +177 -0
  74. package/lib/serverSweep.mjs +90 -0
  75. package/lib/sharedPreview.mjs +292 -0
  76. package/lib/signIn.mjs +47 -0
  77. package/lib/spawn.mjs +330 -0
  78. package/lib/sweep.mjs +161 -0
  79. package/lib/sync.mjs +484 -0
  80. package/lib/uninstall-project.mjs +589 -0
  81. package/lib/uninstall.mjs +464 -0
  82. package/lib/update.mjs +302 -0
  83. package/lib/usageLimit.mjs +108 -0
  84. package/lib/viewableFolder.mjs +105 -0
  85. package/lib/windows-command.mjs +85 -0
  86. package/lib/worktree.mjs +1142 -0
  87. package/lib/writePolicy.mjs +45 -0
  88. package/package.json +58 -4
  89. package/spawn-runner.mjs +167 -0
@@ -0,0 +1,396 @@
1
+ // BEFORE AND AFTER PICTURES OF THE PAGE A CODING RUN CHANGED (#2194, the
2
+ // result card #2023, ruling #2040 item 5: still pictures, nothing runs).
3
+ //
4
+ // The one module the runner calls for pictures and the page check. Drop it
5
+ // (and pageRoutes.mjs, which only it uses) and a run goes back to tests and
6
+ // build only (library choices are on trial). It
7
+ // adds no dependency: the browser is one already installed (Chrome, Edge or
8
+ // Chromium), driven headless over its own DevTools pipe, and the changed area
9
+ // is found by comparing the two pictures inside that same browser.
10
+ //
11
+ // The order is fixed and one app runs at a time:
12
+ // checkPage the copy's app, After, and the page check (part of the
13
+ // check pass, so a failure gets the one automatic fix)
14
+ // beforePicture the folder's app, Before, and the changed area
15
+ // The folder is still the "before" state at the end of a run, because the run
16
+ // worked in its copy. Nothing here ever fails a run: every problem is a
17
+ // skipped or failed check and a plain line.
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync } from "node:fs";
20
+ import { access, mkdtemp, readFile, rm } from "node:fs/promises";
21
+ import { createServer } from "node:net";
22
+ import { tmpdir } from "node:os";
23
+ import { delimiter, join } from "node:path";
24
+ import { devServerEnv } from "./child.mjs";
25
+ import { killTree } from "./kill.mjs";
26
+ import { pageOf, isScreenFile } from "./pageRoutes.mjs";
27
+ import { runnableCommand } from "./windows-command.mjs";
28
+
29
+ export { isScreenFile, PAGE_RECORD_FILE, pageNameOf, pageOf, routeOf } from "./pageRoutes.mjs";
30
+
31
+
32
+ export const NO_BROWSER = "No pictures: no browser found.";
33
+ export const NO_APP = "No pictures: the app did not start.";
34
+ export const NO_APP_SCRIPT = "No pictures: this folder has no app to start.";
35
+ export const BROWSER_CLOSED = "No pictures: the browser closed.";
36
+
37
+ const VIEWPORT = { width: 1440, height: 900 };
38
+ const APP_START_MS = 60_000;
39
+ const PAGE_LOAD_MS = 30_000;
40
+ const SETTLE_MS = 1_000;
41
+ const CALL_MS = 20_000;
42
+ const CLOSE_MS = 2_000;
43
+ const POLL_MS = 200;
44
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
45
+
46
+ // ---------- which browser ----------
47
+
48
+ const MAC_APPS = ["Google Chrome", "Microsoft Edge", "Chromium"];
49
+ const WINDOWS_EXES = [["Google", "Chrome", "Application", "chrome.exe"], ["Microsoft", "Edge", "Application", "msedge.exe"], ["Chromium", "Application", "chrome.exe"]];
50
+ const LINUX_BINS = ["google-chrome", "google-chrome-stable", "microsoft-edge", "microsoft-edge-stable", "chromium", "chromium-browser"];
51
+
52
+ /** A browser already on this computer, as `{bin, args}`, or null. Nothing is
53
+ * ever downloaded. `GLYTCH_BROWSER` names one, or `none` turns pictures off. */
54
+ export function findBrowser({ env = process.env, platform = process.platform, exists = existsSync } = {}) {
55
+ const named = (env.VIEWABLE_BROWSER ?? env.GLYTCH_BROWSER)?.trim();
56
+ if (named) return named.toLowerCase() === "none" ? null : { bin: named, args: [] };
57
+ const candidates = [];
58
+ if (platform === "darwin") {
59
+ for (const app of MAC_APPS) {
60
+ for (const base of ["/Applications", env.HOME && join(env.HOME, "Applications")].filter(Boolean)) {
61
+ candidates.push(join(base, `${app}.app`, "Contents", "MacOS", app));
62
+ }
63
+ }
64
+ } else if (platform === "win32") {
65
+ const bases = [env.ProgramFiles ?? env.PROGRAMFILES, env["ProgramFiles(x86)"], env.LOCALAPPDATA].filter(Boolean);
66
+ for (const parts of WINDOWS_EXES) for (const base of bases) candidates.push(join(base, ...parts));
67
+ } else {
68
+ const dirs = String(env.PATH ?? "").split(delimiter).filter(Boolean);
69
+ for (const bin of LINUX_BINS) for (const dir of dirs) candidates.push(join(dir, bin));
70
+ }
71
+ const bin = candidates.find((c) => exists(c));
72
+ return bin ? { bin, args: [] } : null;
73
+ }
74
+
75
+ // ---------- the browser, over its DevTools pipe ----------
76
+
77
+ /** Start the browser headless with a fresh profile it forgets afterwards. */
78
+ export async function openBrowser(browser, { platform = process.platform, stopCheck, deadline = Infinity } = {}) {
79
+ const profile = await mkdtemp(join(tmpdir(), "viewable-browser-"));
80
+ const args = [
81
+ ...browser.args, "--headless=new", "--remote-debugging-pipe", `--user-data-dir=${profile}`,
82
+ "--no-first-run", "--no-default-browser-check", "--disable-extensions", "--disable-sync",
83
+ "--disable-background-networking", "--hide-scrollbars", "--mute-audio",
84
+ `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, "about:blank",
85
+ ];
86
+ // The person's own browser, but not the runner's secrets.
87
+ const child = spawn(browser.bin, args, { stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"], windowsHide: true, env: devServerEnv(process.env, platform) });
88
+ const waiting = new Map();
89
+ const listeners = new Set();
90
+ let nextId = 1;
91
+ let gone = null;
92
+ let buffer = "";
93
+ const fail = (error) => {
94
+ gone ??= error;
95
+ for (const { reject } of waiting.values()) reject(gone);
96
+ waiting.clear();
97
+ };
98
+ child.on("error", fail);
99
+ child.on("exit", () => fail(new Error("The browser closed.")));
100
+ child.stdio[3].on("error", () => {});
101
+ child.stdio[4].setEncoding("utf8");
102
+ child.stdio[4].on("data", (chunk) => {
103
+ buffer += chunk;
104
+ for (let end = buffer.indexOf("\0"); end >= 0; end = buffer.indexOf("\0")) {
105
+ let message;
106
+ try { message = JSON.parse(buffer.slice(0, end)); } catch { message = null; }
107
+ buffer = buffer.slice(end + 1);
108
+ if (!message) continue;
109
+ const call = message.id !== undefined ? waiting.get(message.id) : undefined;
110
+ if (call) {
111
+ waiting.delete(message.id);
112
+ if (message.error) call.reject(new Error(message.error.message ?? "DevTools error"));
113
+ else call.resolve(message.result ?? {});
114
+ } else {
115
+ for (const listener of listeners) listener(message);
116
+ }
117
+ }
118
+ });
119
+ const send = (method, params = {}, sessionId, ms = CALL_MS) => new Promise((resolve, reject) => {
120
+ if (gone) return reject(gone);
121
+ const id = nextId++;
122
+ const timer = setTimeout(() => { waiting.delete(id); reject(new Error(`${method} did not answer.`)); }, ms);
123
+ waiting.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); } });
124
+ child.stdio[3].write(`${JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })}\0`);
125
+ });
126
+ const close = async ({ ask = true } = {}) => {
127
+ if (ask) try { await send("Browser.close", {}, undefined, CLOSE_MS); } catch { /* gone, or not answering */ }
128
+ if (child.exitCode === null && child.signalCode === null) {
129
+ await killTree(child.pid, { force: true, platform, signal: (_pid, sig) => child.kill(sig) }).catch(() => {});
130
+ }
131
+ await rm(profile, { recursive: true, force: true, maxRetries: 3 }).catch(() => {});
132
+ };
133
+ const tab = async () => {
134
+ const { targetId } = await send("Target.createTarget", { url: "about:blank" });
135
+ const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
136
+ const call = (method, params) => send(method, params, sessionId);
137
+ await call("Page.enable");
138
+ await call("Network.enable");
139
+ await call("Emulation.setDeviceMetricsOverride", { ...VIEWPORT, deviceScaleFactor: 1, mobile: false });
140
+ return { sessionId, call };
141
+ };
142
+ /** `promise`, looked at every POLL_MS so Stop, the time limit and a browser
143
+ * that went away each end the wait at once. */
144
+ const waitFor = async (promise, { until, stopCheck }) => {
145
+ let settled = null;
146
+ promise.then((value) => { settled = { value }; }, (error) => { settled = { error }; });
147
+ for (;;) {
148
+ if (settled?.error) throw settled.error;
149
+ if (settled) return { value: settled.value };
150
+ if (gone) throw gone;
151
+ if (await stopCheck?.()) return { stopped: true };
152
+ if (Date.now() >= until) return { late: true };
153
+ await sleep(Math.max(1, Math.min(POLL_MS, until - Date.now())));
154
+ }
155
+ };
156
+ /** Open `url` and take a still picture once it has loaded. */
157
+ const shoot = async (url, { stopCheck, deadline = Infinity } = {}) => {
158
+ const until = Math.min(Date.now() + PAGE_LOAD_MS, deadline);
159
+ const page = await tab();
160
+ let status;
161
+ let loaded;
162
+ const load = new Promise((resolve) => { loaded = resolve; });
163
+ const listener = (m) => {
164
+ if (m.sessionId !== page.sessionId) return;
165
+ if (m.method === "Network.responseReceived" && m.params?.type === "Document" && status === undefined) status = m.params.response?.status;
166
+ if (m.method === "Page.loadEventFired") loaded(true);
167
+ };
168
+ listeners.add(listener);
169
+ const late = () => ({ ok: false, status, reason: "it did not finish loading", ...(Date.now() >= deadline ? { timedOut: true } : {}) });
170
+ try {
171
+ const nav = await waitFor(page.call("Page.navigate", { url }), { until, stopCheck });
172
+ if (nav.stopped) return { ok: false, stopped: true };
173
+ if (nav.late) return late();
174
+ if (nav.value.errorText) return { ok: false, reason: nav.value.errorText };
175
+ const done = await waitFor(load, { until, stopCheck });
176
+ if (done.stopped) return { ok: false, stopped: true };
177
+ if (done.late) return late();
178
+ await sleep(SETTLE_MS);
179
+ const shot = await page.call("Page.captureScreenshot", { format: "jpeg", quality: 70 });
180
+ return { ok: status === undefined || status < 400, status, image: `data:image/jpeg;base64,${shot.data}`, ...(status >= 400 ? { reason: `it answered ${status}` } : {}) };
181
+ } finally {
182
+ listeners.delete(listener);
183
+ }
184
+ };
185
+ /** The part of After that differs from Before, as fractions, or null. */
186
+ const changedArea = async (before, after) => {
187
+ const page = await tab();
188
+ const out = await page.call("Runtime.evaluate", {
189
+ expression: `(${compareInPage.toString()})(${JSON.stringify(before)}, ${JSON.stringify(after)})`,
190
+ awaitPromise: true, returnByValue: true,
191
+ });
192
+ return out.result?.value ?? null;
193
+ };
194
+ // A browser that is missing or crashes on start says so here, not as a
195
+ // page that did not open.
196
+ // One that starts but never answers is bounded by Stop and the deadline,
197
+ // and is killed without being asked to close.
198
+ let up;
199
+ try { up = await waitFor(send("Browser.getVersion"), { until: Math.min(Date.now() + CALL_MS, deadline), stopCheck }); } catch (error) { up = { error }; }
200
+ if (!up.value) {
201
+ await close({ ask: false });
202
+ throw Object.assign(up.error ?? new Error("The browser did not answer."), { stopped: Boolean(up.stopped) });
203
+ }
204
+ return { shoot, changedArea, close, isGone: () => gone !== null };
205
+ }
206
+
207
+ /** Runs inside the headless page: both pictures on a half-size canvas, the box
208
+ * around every pixel that moved more than a little. */
209
+ async function compareInPage(a, b) {
210
+ const load = (src) => new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = reject; img.src = src; });
211
+ const [one, two] = await Promise.all([load(a), load(b)]);
212
+ const w = 720;
213
+ const h = Math.max(1, Math.round((one.height / one.width) * w));
214
+ const pixels = (img) => { const c = document.createElement("canvas"); c.width = w; c.height = h; const g = c.getContext("2d"); g.drawImage(img, 0, 0, w, h); return g.getImageData(0, 0, w, h).data; };
215
+ const p = pixels(one);
216
+ const q = pixels(two);
217
+ let x0 = w, y0 = h, x1 = -1, y1 = -1;
218
+ for (let y = 0; y < h; y++) {
219
+ for (let x = 0; x < w; x++) {
220
+ const i = (y * w + x) * 4;
221
+ if (Math.abs(p[i] - q[i]) + Math.abs(p[i + 1] - q[i + 1]) + Math.abs(p[i + 2] - q[i + 2]) > 30) {
222
+ if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
223
+ }
224
+ }
225
+ }
226
+ if (x1 < 0) return null;
227
+ const pad = 4;
228
+ x0 = Math.max(0, x0 - pad); y0 = Math.max(0, y0 - pad); x1 = Math.min(w - 1, x1 + pad); y1 = Math.min(h - 1, y1 + pad);
229
+ return { x: x0 / w, y: y0 / h, w: (x1 - x0 + 1) / w, h: (y1 - y0 + 1) / h };
230
+ }
231
+
232
+ // ---------- the folder's app ----------
233
+
234
+ const freePort = () => new Promise((resolve) => {
235
+ const server = createServer();
236
+ server.unref();
237
+ server.on("error", () => resolve(undefined));
238
+ server.listen(0, "127.0.0.1", () => { const { port } = server.address(); server.close(() => resolve(port)); });
239
+ });
240
+
241
+ const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g;
242
+ const ADDRESS = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):\d{2,5}/;
243
+
244
+ /** The folder's own `dev` script, else `start`, or what is missing. */
245
+ async function appScript(dir) {
246
+ let pkg;
247
+ try { pkg = JSON.parse(await readFile(join(dir, "package.json"), "utf8")); } catch { return { missing: "script" }; }
248
+ const script = ["dev", "start"].find((s) => typeof pkg?.scripts?.[s] === "string" && pkg.scripts[s].trim());
249
+ if (!script) return { missing: "script" };
250
+ if (Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).length) {
251
+ try { await access(join(dir, "node_modules")); } catch { return { missing: "dependencies" }; }
252
+ }
253
+ return { script };
254
+ }
255
+
256
+ /** Start the folder's app on a free port and wait until it answers. */
257
+ export async function startApp(dir, { env, platform = process.platform, deadline, stopCheck, killGraceMs = 3_000, fetchImpl = fetch }) {
258
+ const found = await appScript(dir);
259
+ if (!found.script) return { ok: false, missing: found.missing };
260
+ const port = await freePort();
261
+ const cmd = runnableCommand("npm", ["run", found.script], { platform, env });
262
+ const child = spawn(cmd.file, cmd.args, {
263
+ cwd: dir, shell: cmd.shell, windowsHide: true, detached: platform !== "win32",
264
+ stdio: ["ignore", "pipe", "pipe"],
265
+ env: { ...devServerEnv(env, platform), ...(port ? { PORT: String(port) } : {}), BROWSER: "none", NO_COLOR: "1", FORCE_COLOR: "0" },
266
+ });
267
+ let log = "";
268
+ let exited = false;
269
+ const take = (chunk) => { log = (log + String(chunk)).slice(-8_000); };
270
+ child.stdout.on("data", take);
271
+ child.stderr.on("data", take);
272
+ child.on("error", (e) => { take(`${e.message}\n`); exited = true; });
273
+ child.on("exit", () => { exited = true; });
274
+ // The app's server can outlive npm, so the whole group is stopped, and
275
+ // killed after the grace, whether or not npm itself has exited.
276
+ let stopping;
277
+ const stop = () => (stopping ??= (async () => {
278
+ if (!child.pid) return;
279
+ const signal = (_pid, sig) => (platform === "win32" ? child.kill(sig) : process.kill(-child.pid, sig));
280
+ if (platform === "win32") {
281
+ if (!exited) await killTree(child.pid, { platform, signal }).catch(() => {});
282
+ if (!exited) await new Promise((resolve) => { const t = setTimeout(resolve, killGraceMs); child.once("exit", () => { clearTimeout(t); resolve(); }); });
283
+ await killTree(child.pid, { force: true, platform, signal }).catch(() => {});
284
+ return;
285
+ }
286
+ const groupAlive = () => { try { process.kill(-child.pid, 0); return true; } catch { return false; } };
287
+ if (groupAlive()) await killTree(child.pid, { platform, signal }).catch(() => {});
288
+ const until = Date.now() + killGraceMs;
289
+ while (groupAlive() && Date.now() < until) await sleep(50);
290
+ if (groupAlive()) await killTree(child.pid, { force: true, platform, signal }).catch(() => {});
291
+ })());
292
+ const until = Math.min(deadline, Date.now() + APP_START_MS);
293
+ while (Date.now() < until && !exited) {
294
+ if (await stopCheck?.()) { await stop(); return { ok: false, stopped: true, log }; }
295
+ const said = ADDRESS.exec(log.replace(ANSI, ""));
296
+ const url = (said?.[0] ?? (port ? `http://localhost:${port}` : "")).replace("0.0.0.0", "127.0.0.1").replace(/\[::\]/, "[::1]");
297
+ if (url) {
298
+ try {
299
+ await fetchImpl(url, { signal: AbortSignal.timeout(2_000), redirect: "manual" });
300
+ return { ok: true, url, stop, log: () => log };
301
+ } catch { /* not answering yet */ }
302
+ }
303
+ await sleep(300);
304
+ }
305
+ await stop();
306
+ return { ok: false, timedOut: Date.now() >= deadline, log };
307
+ }
308
+
309
+ // ---------- what the runner calls ----------
310
+
311
+ const pageUrl = (base, path) => new URL(path, `${base.replace(/\/$/, "")}/`).toString();
312
+
313
+ /** Start the app in `dir`, take one picture of `path`, stop the app. */
314
+ async function pictureOf(dir, page, browser, options) {
315
+ const app = await startApp(dir, options);
316
+ if (!app.ok) return { started: false, ...app };
317
+ try {
318
+ const shot = await browser.shoot(pageUrl(app.url, page.path), { stopCheck: options.stopCheck, deadline: options.deadline });
319
+ return { started: true, url: app.url, ...shot, log: app.log() };
320
+ } catch (error) {
321
+ if (browser.isGone?.()) return { started: true, url: app.url, ok: false, browserGone: true };
322
+ return { started: true, url: app.url, ok: false, reason: error.message, log: app.log() };
323
+ } finally {
324
+ await app.stop();
325
+ }
326
+ }
327
+
328
+ /** The page check and After, from the copy. Null when no screen changed.
329
+ *
330
+ * `files` are the paths the run changed. `folder` is where the app is tried
331
+ * when the copy's does not start: if it does not start there either, the
332
+ * check is skipped (not the run's doing), never failed. */
333
+ export async function checkPage({ cwd, folder, files, env, baseEnv = process.env, platform = process.platform, deadline, stopCheck, killGraceMs, browser: given, fetchImpl }) {
334
+ // A page the run deleted has no address left to open.
335
+ const screens = files.filter((file) => isScreenFile(file) && existsSync(join(cwd, file)));
336
+ if (screens.length === 0) return null;
337
+ const page = await pageOf(cwd, screens);
338
+ const check = (status, reason) => ({ kind: "page", status, failed: status === "failed" ? 1 : 0, page: page.name, ...(reason ? { reason } : {}) });
339
+ const browser = given === undefined ? findBrowser({ env: baseEnv, platform }) : given;
340
+ if (!browser) return { page, check: check("skipped", "No browser was found on this computer."), note: NO_BROWSER };
341
+ if (await stopCheck?.()) return { page, browser, stopped: true, check: check("skipped", "Stopped before this check finished.") };
342
+ if (Date.now() >= deadline) return { page, browser, check: check("failed", "Ran out of time before this check finished.") };
343
+ const options = { env, platform, deadline, stopCheck, killGraceMs, ...(fetchImpl ? { fetchImpl } : {}) };
344
+ let opened;
345
+ let stoppedStarting = false;
346
+ try { opened = await openBrowser(browser, { platform, stopCheck, deadline }); } catch (error) { opened = null; stoppedStarting = error.stopped === true; }
347
+ if (stoppedStarting) return { page, browser, stopped: true, check: check("skipped", "Stopped before this check finished.") };
348
+ if (!opened) return { page, check: check("skipped", "The browser on this computer did not start."), note: NO_BROWSER };
349
+ try {
350
+ const after = await pictureOf(cwd, page, opened, options);
351
+ if (after.stopped) return { page, browser, stopped: true, check: check("skipped", "Stopped before this check finished.") };
352
+ if (after.browserGone) return { page, browser, check: check("skipped", "The browser on this computer closed."), note: BROWSER_CLOSED };
353
+ if (after.missing === "script") return { page, browser, check: check("skipped", "This folder has no app to start."), note: NO_APP_SCRIPT };
354
+ if (after.missing === "dependencies") return { page, browser, check: check("skipped", "The folder's dependencies are not installed."), note: NO_APP };
355
+ if (after.timedOut) return { page, browser, check: check("failed", "Ran out of time before this check finished.") };
356
+ if (!after.started) {
357
+ const there = folder ? await startApp(folder, options) : { ok: false };
358
+ if (there.ok) await there.stop();
359
+ if (!there.ok) return { page, browser, check: check("skipped", "The app did not start."), note: NO_APP };
360
+ return {
361
+ page, browser, check: check("failed", "The app did not start after this change."),
362
+ failure: `The app did not start with npm (it starts in the original folder).\n${after.log ?? ""}`,
363
+ };
364
+ }
365
+ if (!after.ok) {
366
+ return {
367
+ page, browser, ...(after.image ? { after: after.image } : {}),
368
+ check: check("failed", `The ${page.name} did not open.`),
369
+ failure: `Opening ${pageUrl(after.url, page.path)} in a browser failed: ${after.reason ?? "unknown"}.\n${after.log ?? ""}`,
370
+ };
371
+ }
372
+ return { page, browser, after: after.image, check: check("passed") };
373
+ } finally {
374
+ await opened.close();
375
+ }
376
+ }
377
+
378
+ /** Before, from the folder, and the changed part of After. Pictures that do
379
+ * not differ mean the wrong page was opened: no pictures at all. */
380
+ export async function beforePicture({ folder, page, after, browser, env, platform = process.platform, deadline, stopCheck, killGraceMs, fetchImpl }) {
381
+ if (!after || !browser) return {};
382
+ if (!folder || await stopCheck?.() || Date.now() >= deadline) return { after };
383
+ let opened;
384
+ try { opened = await openBrowser(browser, { platform, stopCheck, deadline }); } catch { return { after }; }
385
+ try {
386
+ const before = await pictureOf(folder, page, opened, { env, platform, deadline, stopCheck, killGraceMs, ...(fetchImpl ? { fetchImpl } : {}) });
387
+ if (!before.started || !before.ok || !before.image) return { after };
388
+ let changed = null;
389
+ try { changed = await opened.changedArea(before.image, after); } catch { return { before: before.image, after }; }
390
+ if (!changed) return {};
391
+ return { before: before.image, after, changed };
392
+ } finally {
393
+ await opened.close();
394
+ }
395
+ }
396
+
@@ -0,0 +1,70 @@
1
+ // WHICH FILES A CODING RUN CHANGED ARE A SCREEN, AND WHICH PAGE TO OPEN
2
+ // (#2194). Pure path rules plus the session's own page record; only
3
+ // pagePictures.mjs uses this, and goes with it.
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ /** The file a session writes in its copy's root to name the page it changed. */
8
+ export const PAGE_RECORD_FILE = ".viewable-page.json";
9
+
10
+ const SCREEN_EXT = /\.(?:tsx|jsx|vue|svelte|astro|html|css|scss|sass|less)$/i;
11
+ const NOT_SCREEN = /(?:\.(?:test|spec|stories)\.[a-z]+$|(?:^|\/)(?:__tests__|tests?|e2e)\/)/i;
12
+
13
+ /** A changed file a person could see on a page: markup, styles or a routed
14
+ * page file (page.ts, an .mdx page), not tests. */
15
+ export const isScreenFile = (file) => (SCREEN_EXT.test(file) || routeOf(file) !== null) && !NOT_SCREEN.test(file);
16
+
17
+ const PAGE_EXT = "(?:tsx|jsx|ts|js|mdx|vue|svelte|astro)";
18
+ const ROUTE_RULES = [
19
+ // Next app router: app/settings/page.tsx; (groups) are not in the address.
20
+ [new RegExp(`^(?:src/)?app/(?:(.*)/)?page\\.${PAGE_EXT}$`), (m) => m[1] ?? ""],
21
+ // SvelteKit: src/routes/settings/+page.svelte
22
+ [/^src\/routes\/(?:(.*)\/)?\+page\.svelte$/, (m) => m[1] ?? ""],
23
+ // Next pages router, Nuxt, Astro: pages/settings.tsx, src/pages/index.astro
24
+ [new RegExp(`^(?:src/)?pages/(.*)\\.${PAGE_EXT}$`), (m) => (/(?:^|\/)(?:_app|_document|_error|api)(?:\/|$)/.test(m[1]) ? null : m[1].replace(/(?:^|\/)index$/, ""))],
25
+ // Remix: app/routes/account.settings.tsx, app/routes/_index.tsx
26
+ [new RegExp(`^app/routes/(.*)\\.${PAGE_EXT}$`), (m) => m[1].replace(/^_index$/, "").replace(/\./g, "/")],
27
+ ];
28
+
29
+ /** The address of a page file in a folder that routes by file, or null. */
30
+ export function routeOf(file) {
31
+ for (const [rule, take] of ROUTE_RULES) {
32
+ const m = rule.exec(file);
33
+ if (!m) continue;
34
+ const route = take(m);
35
+ if (route === null) return null;
36
+ const parts = route.split("/").filter((p) => p && !/^\(.*\)$/.test(p));
37
+ // A [slug] page has no one address to open.
38
+ if (parts.some((p) => /[[\]$]/.test(p))) return null;
39
+ return `/${parts.join("/")}`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** "/" is the home page, "/sign-in" the sign in page. */
45
+ export function pageNameOf(path) {
46
+ const last = path.split(/[?#]/)[0].split("/").filter(Boolean).pop();
47
+ if (!last) return "home page";
48
+ return `${decodeURIComponent(last).replace(/[-_]+/g, " ").trim().toLowerCase()} page`;
49
+ }
50
+
51
+ /** Which page the run touched: the session's own record, else the file route
52
+ * of a changed page file, else the home page. */
53
+ export async function pageOf(cwd, files) {
54
+ try {
55
+ const record = JSON.parse(await readFile(join(cwd, PAGE_RECORD_FILE), "utf8").catch((error) => {
56
+ if (error.code !== "ENOENT") throw error;
57
+ return readFile(join(cwd, ".glytch-page.json"), "utf8");
58
+ }));
59
+ const path = typeof record?.path === "string" ? record.path.trim() : "";
60
+ if (/^\/(?!\/)[^\s]{0,300}$/.test(path)) {
61
+ const name = typeof record.name === "string" ? record.name.replace(/\s+/g, " ").trim().slice(0, 60) : "";
62
+ return { path, name: name || pageNameOf(path), recorded: true };
63
+ }
64
+ } catch { /* no record: guess from the files */ }
65
+ for (const file of files) {
66
+ const route = routeOf(file);
67
+ if (route !== null) return { path: route, name: pageNameOf(route), recorded: false };
68
+ }
69
+ return { path: "/", name: pageNameOf("/"), recorded: false };
70
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,10 @@
1
+ // Local runner scratch and image staging. Board coordination uses hosted rows.
2
+ // Historical request files and the repo-map cache still live under SPAWN_DIR.
3
+
4
+ export const BOARD_DIR = ".viewable";
5
+ export const INBOX_DIR = `${BOARD_DIR}/inbox`;
6
+ // Kept on purpose: the sweep reads receipts written by old runners.
7
+ export const SPAWN_DIR = ".glytch/inbox/spawn";
8
+
9
+ /** Path segments, for join(root, ...segmentsOf(DIR)). */
10
+ export const segmentsOf = (dir) => dir.split("/");