staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
@@ -0,0 +1,1594 @@
1
+ /**
2
+ * Desktop apps built with Electron — the window AND the machinery behind it.
3
+ *
4
+ * A desktop app is two programs pretending to be one. There is a window, which is a web page
5
+ * and can be read the same way any web page is read. And there is a MAIN PROCESS, which is
6
+ * where nearly everything the app actually DOES happens: opening windows, writing files,
7
+ * starting other programs, talking to the network, and answering the several hundred private
8
+ * doors the window knocks on. Check only the window and you are checking the paint.
9
+ *
10
+ * WHAT THIS ADAPTER WATCHES, per journey:
11
+ *
12
+ * the meaning tree Every control the window offers, by what it IS and what it SAYS —
13
+ * role, name, state. Never the markup: markup changes when nothing did.
14
+ * the doors Every IPC channel the running app has actually registered. Terminal
15
+ * Deck 0.15.0 registers 421 of them plus 26 listeners, and this reads
16
+ * the list in a few milliseconds. A door that quietly stopped being
17
+ * registered is invisible to every screenshot tool ever written.
18
+ * its windows What windows exist, what they are called, how big they are.
19
+ * its menus The whole application menu, with what is enabled and what is not.
20
+ * what it wrote Every file it created or changed in its own settings folder.
21
+ * what it started Every other program it ran.
22
+ * what it reached for Every outbound connection it tried — and every one of them refused.
23
+ * what it complained about Console errors from both halves, crashes, and how it exited.
24
+ * what it looked like One picture, last, as evidence for a finding another channel made.
25
+ *
26
+ * HOW THE DOORS ARE READ, AND WHY IT IS NOT BY KNOCKING. The list of registered channels is
27
+ * ASKED FOR, never tried. Invoking an unknown channel could do anything — send a message,
28
+ * delete a project, spend money — and "it answered" is not worth finding out that way.
29
+ * Registration is observable for free; behaviour is observable only for the channels a
30
+ * journey deliberately names, and those are listed in the project's own config.
31
+ *
32
+ * THE SAFETY BOUNDARY. Before a single line of the app's own code runs, this adapter pauses
33
+ * the main process at its very first statement and puts a boundary in place: every outbound
34
+ * socket, every `fetch`, and every request through Electron's own network stack is recorded,
35
+ * and anything reaching off this machine is refused. So an app that would charge a card is
36
+ * watched asking, and never gets to ask. What could not be covered says so with
37
+ * `covered: false` — a hole with a reason on it, never a pass.
38
+ *
39
+ * ONE COPY AT A TIME, PROVED. Two instances of one desktop app fight over the single-instance
40
+ * lock, the settings folder, the debugging port and whatever identity the app registers
41
+ * somewhere. That fight is not theoretical: it cost a day on this very machine on 2026-08-28,
42
+ * and it looked exactly like a bug in the product. Every run here gets its own of all of
43
+ * those, from `isolate.js`, and the previous run is PROVED gone before the next one starts.
44
+ */
45
+
46
+ import crypto from 'node:crypto';
47
+ import fsp from 'node:fs/promises';
48
+ import path from 'node:path';
49
+
50
+ import { connect } from '../../drive/cdp.js';
51
+ import { resolveElectronBinary } from '../../drive/find.js';
52
+ import { splitPath } from '../observation.js';
53
+ import {
54
+ countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
55
+ trimForStorage, undoOurFootprint,
56
+ } from './contract.js';
57
+ import { compareTrees, snapshotTree } from './process.js';
58
+ import {
59
+ describeIsolation, releaseEverything, releaseIsolation, reserveIsolation, startIsolated,
60
+ verifyAlone,
61
+ } from './isolate.js';
62
+
63
+ /** @typedef {import('./contract.js').Observation} Observation */
64
+ /** @typedef {import('./contract.js').Journey} Journey */
65
+ /** @typedef {import('./contract.js').RunContext} RunContext */
66
+
67
+ /** @param {number} ms */
68
+ const rest = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Finding the app
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** Where a packaged Electron app usually ends up, in the order worth looking. */
75
+ const BUILT_APP_FOLDERS = ['dist', 'out', 'release', 'build', 'packages/desktop/dist'];
76
+
77
+ /**
78
+ * Find the built app inside a build.
79
+ *
80
+ * The config wins when it says something, because a repo that produces five artifacts can
81
+ * only be told apart by somebody who knows which one is meant. Otherwise the usual output
82
+ * folders are looked in — and if nothing is there, that is not an error, it is a `Missing`
83
+ * with the command that would produce one.
84
+ *
85
+ * @param {string} root
86
+ * @param {Record<string, any>} [config]
87
+ * @returns {Promise<{binary: string|null, bundle: string|null, why: string}>}
88
+ */
89
+ export async function findAppBinary(root, config = {}) {
90
+ /** @type {string[]} */
91
+ const tried = [];
92
+
93
+ /** @param {string} candidate */
94
+ const accept = async (candidate) => {
95
+ const full = path.isAbsolute(candidate) ? candidate : path.join(root, candidate);
96
+ tried.push(full);
97
+ try {
98
+ await fsp.stat(full);
99
+ const binary = resolveElectronBinary(full);
100
+ return { binary, bundle: full.endsWith('.app') ? full : null };
101
+ } catch {
102
+ return null;
103
+ }
104
+ };
105
+
106
+ if (config.binary || config.app) {
107
+ const hit = await accept(String(config.binary ?? config.app));
108
+ if (hit) return { ...hit, why: `The config says the app is at ${hit.binary}.` };
109
+ return {
110
+ binary: null,
111
+ bundle: null,
112
+ why: `The config points at "${config.binary ?? config.app}", and there is nothing there.`,
113
+ };
114
+ }
115
+
116
+ for (const folder of BUILT_APP_FOLDERS) {
117
+ /** @type {import('node:fs').Dirent[]} */
118
+ let entries;
119
+ try { entries = await fsp.readdir(path.join(root, folder), { withFileTypes: true }); }
120
+ catch { continue; }
121
+ for (const entry of entries) {
122
+ const looksRight = entry.name.endsWith('.app')
123
+ || (process.platform !== 'darwin' && /\.(exe|AppImage)$/.test(entry.name));
124
+ if (!looksRight) continue;
125
+ const hit = await accept(path.join(folder, entry.name));
126
+ if (hit) return { ...hit, why: `Found the built app at ${path.join(folder, entry.name)}.` };
127
+ }
128
+ }
129
+
130
+ return {
131
+ binary: null,
132
+ bundle: null,
133
+ why: tried.length > 0
134
+ ? `No built app was found. Looked in: ${tried.join(', ')}.`
135
+ : `No built app was found under ${BUILT_APP_FOLDERS.join(', ')}.`,
136
+ };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // The probe that rides inside the main process
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * The script that goes into the main process before the app's own code runs.
145
+ *
146
+ * It is installed while the process is paused at its very first statement, which is the only
147
+ * moment at which it can honestly claim to have seen everything: a boundary that arrives
148
+ * after the app has already booted has already missed whatever the app did on the way up.
149
+ *
150
+ * It patches four things, and knowing WHY it is four rather than one is the difference
151
+ * between a boundary and a suggestion:
152
+ *
153
+ * `net.Socket.prototype.connect` Every HTTP client, database driver and SDK anybody has
154
+ * ever written in Node ends up here, however it was
155
+ * imported. This is where a relay dial, a websocket and an
156
+ * ordinary https request are all seen.
157
+ * `fetch` Electron's main process has its own `fetch`, which goes
158
+ * through Chromium's network stack and never touches a Node
159
+ * socket. Missing it would leave a wide open door.
160
+ * `net.request` (Electron's) Same stack, older spelling. Auto-updaters use it.
161
+ * `child_process` Recorded, never refused. An app that starts a shell is
162
+ * doing its job; an app that started a DIFFERENT one after
163
+ * a change is the finding.
164
+ *
165
+ * Anything reaching off this machine is refused. Anything on this machine is allowed through
166
+ * untouched, because a desktop app talking to its own helper is not an effect on the world.
167
+ *
168
+ * @returns {string} JavaScript, ready to be evaluated inside the paused main process
169
+ */
170
+ export function mainProbeScript() {
171
+ return `(() => {
172
+ const g = globalThis;
173
+ if (g.__staysFixed) return 'already watching';
174
+ const seen = { connects: [], spawns: [], refused: [], watching: [], couldNotWatch: [] };
175
+ g.__staysFixed = seen;
176
+ const req = typeof require === 'function' ? require : (process.mainModule && process.mainModule.require);
177
+ if (typeof req !== 'function') { seen.couldNotWatch.push('nothing at all — this app would not let us load a module'); return 'not watching'; }
178
+
179
+ const onThisMachine = (host) => {
180
+ const h = String(host || '').toLowerCase();
181
+ if (h === '' || h === 'localhost' || h === '::1' || h === '0.0.0.0') return true;
182
+ if (h.startsWith('127.')) return true;
183
+ if (h.startsWith('/') || h.startsWith('\\\\\\\\.')) return true; // a socket file, not a network
184
+ return false;
185
+ };
186
+ const note = (host, port, kind) => { seen.connects.push({ host: String(host), port: port == null ? null : Number(port), kind }); };
187
+
188
+ try {
189
+ const net = req('node:net');
190
+ const real = net.Socket.prototype.connect;
191
+ net.Socket.prototype.connect = function (...args) {
192
+ const first = args[0];
193
+ const host = (first && typeof first === 'object') ? (first.host || first.path || '') : (typeof args[1] === 'string' ? args[1] : '');
194
+ const port = (first && typeof first === 'object') ? first.port : (typeof first === 'number' ? first : null);
195
+ note(host || '127.0.0.1', port, 'socket');
196
+ if (!onThisMachine(host)) {
197
+ seen.refused.push(String(host));
198
+ const socket = this;
199
+ process.nextTick(() => { try { socket.destroy(Object.assign(new Error('refused by Stays Fixed: this would leave the machine'), { code: 'ECONNREFUSED' })); } catch (e) { void e; } });
200
+ return this;
201
+ }
202
+ return real.apply(this, args);
203
+ };
204
+ seen.watching.push('every outbound socket');
205
+ } catch (e) { seen.couldNotWatch.push('outbound sockets (' + e.message + ')'); }
206
+
207
+ try {
208
+ const cp = req('node:child_process');
209
+ const promisified = Symbol.for('nodejs.util.promisify.custom');
210
+ for (const name of ['spawn', 'exec', 'execFile', 'fork', 'spawnSync', 'execSync', 'execFileSync']) {
211
+ const real = cp[name];
212
+ if (typeof real !== 'function') continue;
213
+ const watched = function (...args) { seen.spawns.push(String(args[0])); return real.apply(this, args); };
214
+ // Everything hanging off the original function comes with it. This line is not
215
+ // housekeeping — leaving it out breaks the app being checked. "execFile" carries a
216
+ // hidden promise version on a symbol, and "util.promisify(execFile)" uses it to
217
+ // resolve { stdout, stderr }; a bare wrapper loses the symbol, promisify falls back to
218
+ // callback rules and resolves with stdout ALONE, and every caller that wrote
219
+ // "const { stdout } = await ..." gets undefined and throws. Measured on Terminal Deck
220
+ // 0.15.0: the window went blank with one React error and nothing said why.
221
+ for (const key of Reflect.ownKeys(real)) {
222
+ if (key === 'length' || key === 'name' || key === 'prototype') continue;
223
+ try { watched[key] = real[key]; } catch (e) { void e; }
224
+ }
225
+ const custom = real[promisified];
226
+ if (typeof custom === 'function') {
227
+ watched[promisified] = function (...args) { seen.spawns.push(String(args[0])); return custom.apply(this, args); };
228
+ }
229
+ cp[name] = watched;
230
+ }
231
+ seen.watching.push('every program it starts');
232
+ } catch (e) { seen.couldNotWatch.push('the programs it starts (' + e.message + ')'); }
233
+
234
+ try {
235
+ const real = g.fetch;
236
+ if (typeof real === 'function') {
237
+ g.fetch = function (input, ...rest) {
238
+ const url = String(input && typeof input === 'object' ? input.url : input);
239
+ note(url, null, 'fetch');
240
+ try { if (!onThisMachine(new URL(url).hostname)) { seen.refused.push(url); return Promise.reject(new TypeError('refused by Stays Fixed: this would leave the machine')); } } catch (e) { void e; }
241
+ return real.call(this, input, ...rest);
242
+ };
243
+ seen.watching.push('fetch in the main process');
244
+ }
245
+ } catch (e) { seen.couldNotWatch.push('fetch in the main process (' + e.message + ')'); }
246
+
247
+ try {
248
+ const electron = req('electron');
249
+ const real = electron.net.request;
250
+ electron.net.request = function (options, ...rest) {
251
+ let url = typeof options === 'string' ? options : String((options && options.url) || '');
252
+ if (!url && options && typeof options === 'object') {
253
+ url = String(options.protocol || 'https:') + '//' + String(options.hostname || options.host || '') + String(options.path || '');
254
+ }
255
+ note(url, null, 'electron net');
256
+ return real.call(this, options, ...rest);
257
+ };
258
+ if (electron.net.request !== real) seen.watching.push("requests through Electron's own network");
259
+ else seen.couldNotWatch.push("requests through Electron's own network (it would not let us)");
260
+ } catch (e) { seen.couldNotWatch.push("requests through Electron's own network (" + e.message + ')'); }
261
+
262
+ return seen.watching.join(', ');
263
+ })()`;
264
+ }
265
+
266
+ /**
267
+ * The expression that reads everything out of the main process, once it is up.
268
+ *
269
+ * Every part of it is wrapped on its own. An app with no application menu, or one that has
270
+ * not opened a window yet, must produce a shorter answer — never an exception that loses the
271
+ * other twelve things this asks for.
272
+ *
273
+ * The IPC list is the reason this file exists. Electron keeps the answering handlers in a
274
+ * Map on `ipcMain` and the listeners on the emitter, so both lists can be READ. Nothing is
275
+ * called. Nothing is knocked on.
276
+ *
277
+ * @returns {string}
278
+ */
279
+ export function mainReadScript() {
280
+ return `(() => {
281
+ const out = { problems: [] };
282
+ const safe = (name, fn) => { try { out[name] = fn(); } catch (e) { out.problems.push(name + ': ' + e.message); } };
283
+ const req = typeof require === 'function' ? require : (process.mainModule && process.mainModule.require);
284
+ const electron = req('electron');
285
+
286
+ safe('app', () => ({
287
+ name: electron.app.getName(),
288
+ version: electron.app.getVersion(),
289
+ packaged: electron.app.isPackaged === true,
290
+ userData: electron.app.getPath('userData'),
291
+ locale: electron.app.getLocale(),
292
+ }));
293
+
294
+ safe('ipc', () => {
295
+ const answers = electron.ipcMain._invokeHandlers ? [...electron.ipcMain._invokeHandlers.keys()].map(String) : null;
296
+ const listens = electron.ipcMain.eventNames().map(String);
297
+ return { answers, listens };
298
+ });
299
+
300
+ safe('windows', () => electron.BrowserWindow.getAllWindows().map((w) => {
301
+ const size = w.getSize();
302
+ return {
303
+ title: w.getTitle(),
304
+ visible: w.isVisible(),
305
+ width: size[0],
306
+ height: size[1],
307
+ resizable: w.isResizable(),
308
+ fullScreen: w.isFullScreen(),
309
+ alwaysOnTop: w.isAlwaysOnTop(),
310
+ url: w.webContents ? w.webContents.getURL() : '',
311
+ };
312
+ }));
313
+
314
+ safe('menu', () => {
315
+ const walk = (items, prefix) => {
316
+ const rows = [];
317
+ for (const item of items || []) {
318
+ const label = String(item.label || item.role || item.type || '');
319
+ const here = prefix ? prefix + ' > ' + label : label;
320
+ if (item.type !== 'separator') {
321
+ rows.push({ at: here, enabled: item.enabled !== false, visible: item.visible !== false, key: String(item.accelerator || ''), kind: String(item.type || 'normal') });
322
+ }
323
+ if (item.submenu && item.submenu.items) rows.push(...walk(item.submenu.items, here));
324
+ }
325
+ return rows;
326
+ };
327
+ const menu = electron.Menu.getApplicationMenu();
328
+ return menu ? walk(menu.items, '') : [];
329
+ });
330
+
331
+ safe('helpers', () => electron.app.getAppMetrics().map((m) => String(m.type || '') + (m.name ? ' (' + m.name + ')' : '')).sort());
332
+
333
+ safe('effects', () => {
334
+ const seen = globalThis.__staysFixed;
335
+ if (!seen) return null;
336
+ const tally = (list, key) => {
337
+ const counts = new Map();
338
+ for (const item of list) { const k = key(item); counts.set(k, (counts.get(k) || 0) + 1); }
339
+ return [...counts.entries()].sort().map(([what, times]) => ({ what, times }));
340
+ };
341
+ return {
342
+ connects: tally(seen.connects, (c) => c.port ? c.host + ':' + c.port : c.host),
343
+ spawns: tally(seen.spawns, (s) => s),
344
+ refused: [...new Set(seen.refused)].sort(),
345
+ watching: seen.watching,
346
+ couldNotWatch: seen.couldNotWatch,
347
+ };
348
+ });
349
+
350
+ return out;
351
+ })()`;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // Talking to a running app
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * @param {string} url
360
+ * @returns {Promise<any>}
361
+ */
362
+ async function askFor(url) {
363
+ const response = await fetch(url, { signal: AbortSignal.timeout(4000), headers: { Accept: 'application/json' } });
364
+ if (!response.ok) throw new Error(`answered ${response.status}`);
365
+ return response.json();
366
+ }
367
+
368
+ /**
369
+ * Wait for the main process to open its own debugging connection.
370
+ *
371
+ * Deliberately not the same call as the one used for the window. The window's port answers
372
+ * `/json/version` with a socket to connect to; the main process's port does not, and only
373
+ * `/json/list` has anything useful in it. Getting that wrong looks exactly like an app that
374
+ * never started.
375
+ *
376
+ * @param {number} port
377
+ * @param {object} opts
378
+ * @param {number} opts.timeoutMs
379
+ * @param {() => string|null} opts.died A sentence when the app has already quit, else null.
380
+ * @returns {Promise<{webSocketDebuggerUrl: string, title: string}>}
381
+ */
382
+ async function waitForMainProcess(port, opts) {
383
+ const until = Date.now() + opts.timeoutMs;
384
+ for (;;) {
385
+ const gone = opts.died();
386
+ if (gone) throw new Error(gone);
387
+ try {
388
+ const list = await askFor(`http://127.0.0.1:${port}/json/list`);
389
+ const target = Array.isArray(list) ? list.find((t) => t && typeof t.webSocketDebuggerUrl === 'string') : null;
390
+ if (target) return target;
391
+ } catch { /* not up yet */ }
392
+ if (Date.now() > until) break;
393
+ await rest(150);
394
+ }
395
+ throw new Error(`The app never opened its main-process debugging connection on port ${port}.`);
396
+ }
397
+
398
+ /**
399
+ * Wait for a real window to exist, and pick the one meant.
400
+ *
401
+ * An Electron window is blank for the first moment of its life, so asking again a beat later
402
+ * is the difference between reading the app and reading an empty rectangle.
403
+ *
404
+ * @param {number} port
405
+ * @param {string|undefined} match
406
+ * @param {object} opts
407
+ * @param {number} opts.timeoutMs
408
+ * @param {() => string|null} opts.died
409
+ * @returns {Promise<{id: string, title: string, url: string}>}
410
+ */
411
+ async function waitForWindow(port, match, opts) {
412
+ const until = Date.now() + opts.timeoutMs;
413
+ /** @type {any[]} */
414
+ let pages = [];
415
+ for (;;) {
416
+ const gone = opts.died();
417
+ if (gone) throw new Error(gone);
418
+ try {
419
+ const list = await askFor(`http://127.0.0.1:${port}/json/list`);
420
+ pages = (Array.isArray(list) ? list : []).filter((t) => t && t.type === 'page' && !String(t.url ?? '').startsWith('devtools://'));
421
+ const chosen = match
422
+ ? pages.find((t) => String(t.title ?? '').includes(match) || String(t.url ?? '').includes(match))
423
+ : pages.find((t) => String(t.url ?? '') !== '' && String(t.url ?? '') !== 'about:blank');
424
+ if (chosen) return { id: String(chosen.id), title: String(chosen.title ?? ''), url: String(chosen.url ?? '') };
425
+ } catch { /* not up yet */ }
426
+ if (Date.now() > until) break;
427
+ await rest(200);
428
+ }
429
+ if (!match && pages.length > 0) return { id: String(pages[0].id), title: String(pages[0].title ?? ''), url: String(pages[0].url ?? '') };
430
+ throw new Error(
431
+ match
432
+ ? `The app opened, but no window matches "${match}". Windows it has open: ${pages.map((p) => `"${p.title}"`).join(', ') || 'none'}.`
433
+ : 'The app started but never opened a window to look at.',
434
+ );
435
+ }
436
+
437
+ /**
438
+ * One app, open and readable.
439
+ *
440
+ * @typedef {object} OpenApp
441
+ * @property {import('./isolate.js').Isolation} isolation
442
+ * @property {import('./isolate.js').StartedApp} started
443
+ * @property {import('../../types.js').CdpSession} main The main process.
444
+ * @property {import('../../types.js').CdpSession} browser The window's connection.
445
+ * @property {string} sessionId Which window, on that connection.
446
+ * @property {{id: string, title: string, url: string}} window
447
+ * @property {string} watching Plain English: what the boundary is covering.
448
+ * @property {string[]} couldNotWatch
449
+ * @property {string[]} complaints Console errors and crashes, both halves, as they arrive.
450
+ * @property {{url: string, refused: boolean}[]} requests What the window asked the network for.
451
+ * @property {number} openedInMs
452
+ */
453
+
454
+ /**
455
+ * Open one app, alone, with the boundary in place before its own code runs.
456
+ *
457
+ * @param {object} opts
458
+ * @param {string} opts.binary
459
+ * @param {import('./isolate.js').Isolation} opts.isolation
460
+ * @param {string} [opts.windowMatch]
461
+ * @param {string[]} [opts.extraArgs]
462
+ * @param {number} [opts.timeoutMs]
463
+ * @param {AbortSignal} [opts.signal]
464
+ * @param {(message: string) => void} [opts.log]
465
+ * @returns {Promise<OpenApp>}
466
+ */
467
+ export async function openApp(opts) {
468
+ const timeoutMs = opts.timeoutMs ?? 60_000;
469
+ const isolation = opts.isolation;
470
+ const startedAt = Date.now();
471
+
472
+ const alone = await verifyAlone(isolation);
473
+ if (!alone.alone) throw new Error(`${alone.why} Nothing was started, because two copies of one app fight over the same lock and the same settings.`);
474
+
475
+ const started = startIsolated(isolation, { binary: opts.binary, extraArgs: opts.extraArgs, signal: opts.signal });
476
+ const died = () => {
477
+ const end = started.finished();
478
+ if (!end) return null;
479
+ return `The app quit before it was ready (${end.signal ? `it was stopped by ${end.signal}` : `exit code ${end.code}`}). The last thing it said: ${trimForStorage(started.said(), 1200).text || '(nothing)'}`;
480
+ };
481
+
482
+ // ---- the main process, paused at its first statement
483
+ const mainTarget = await waitForMainProcess(isolation.inspectPort, { timeoutMs, died });
484
+ const main = await connect(mainTarget.webSocketDebuggerUrl, { timeoutMs: 20_000 });
485
+ isolation.closeFirst(() => main.close());
486
+
487
+ /** @type {string[]} */
488
+ const complaints = [];
489
+ const complain = (/** @type {string} */ text) => {
490
+ const line = text.trim();
491
+ if (line && complaints.length < 50 && !complaints.includes(line)) complaints.push(line);
492
+ };
493
+ main.on('Runtime.exceptionThrown', (params) => {
494
+ complain(`the app itself threw: ${String(params?.exceptionDetails?.exception?.description ?? params?.exceptionDetails?.text ?? 'an error with no message')}`.split('\n')[0]);
495
+ });
496
+ main.on('Runtime.consoleAPICalled', (params) => {
497
+ if (params?.type !== 'error' && params?.type !== 'assert') return;
498
+ const text = (params.args ?? []).map((/** @type {any} */ a) => String(a?.value ?? a?.description ?? '')).join(' ');
499
+ complain(`the app printed an error: ${text}`.split('\n')[0]);
500
+ });
501
+
502
+ await main.send('Runtime.enable');
503
+ await main.send('Debugger.enable');
504
+
505
+ /** @type {string|null} */
506
+ let frameId = null;
507
+ const stopListening = main.on('Debugger.paused', (params) => {
508
+ if (frameId === null) frameId = String(params?.callFrames?.[0]?.callFrameId ?? '') || null;
509
+ });
510
+ await main.send('Runtime.runIfWaitingForDebugger');
511
+ for (let i = 0; i < 100 && frameId === null; i += 1) await rest(50);
512
+
513
+ let watching = '';
514
+ /** @type {string[]} */
515
+ let couldNotWatch = [];
516
+ if (frameId) {
517
+ const result = await main.send('Debugger.evaluateOnCallFrame', {
518
+ callFrameId: frameId,
519
+ expression: mainProbeScript(),
520
+ returnByValue: true,
521
+ includeCommandLineAPI: true,
522
+ });
523
+ watching = String(result?.result?.value ?? '');
524
+ if (result?.exceptionDetails) couldNotWatch.push(`the boundary could not be put in place: ${String(result.exceptionDetails.text ?? 'it threw')}`);
525
+ await main.send('Debugger.resume').catch(() => {});
526
+ } else {
527
+ couldNotWatch.push('the main process never stopped at its first line, so nothing was watching it from the inside');
528
+ }
529
+ stopListening();
530
+ await main.send('Debugger.disable').catch(() => {});
531
+ opts.log?.(watching ? `Watching ${watching}.` : 'Nothing is watching the main process from the inside.');
532
+
533
+ // ---- the window
534
+ const window = await waitForWindow(isolation.debugPort, opts.windowMatch, { timeoutMs, died });
535
+ const version = await askFor(`http://127.0.0.1:${isolation.debugPort}/json/version`);
536
+ const browser = await connect(String(version.webSocketDebuggerUrl), { timeoutMs: 20_000 });
537
+ isolation.closeFirst(() => browser.close());
538
+ const attached = await browser.send('Target.attachToTarget', { targetId: window.id, flatten: true });
539
+ const sessionId = String(attached.sessionId);
540
+
541
+ for (const domain of ['Runtime', 'Page', 'Log', 'DOM', 'Accessibility']) {
542
+ await browser.send(`${domain}.enable`, {}, sessionId).catch(() => {
543
+ couldNotWatch.push(`${domain.toLowerCase()} in the window`);
544
+ });
545
+ }
546
+
547
+ browser.on('Runtime.consoleAPICalled', (params, sid) => {
548
+ if (sid !== sessionId) return;
549
+ if (params?.type !== 'error' && params?.type !== 'assert') return;
550
+ const text = (params.args ?? []).map((/** @type {any} */ a) => String(a?.value ?? a?.description ?? '')).join(' ');
551
+ complain(`the window printed an error: ${text}`.split('\n')[0]);
552
+ });
553
+ browser.on('Log.entryAdded', (params, sid) => {
554
+ if (sid !== sessionId || params?.entry?.level !== 'error') return;
555
+ complain(`the window logged an error: ${String(params.entry.text ?? '')}`.split('\n')[0]);
556
+ });
557
+ browser.on('Runtime.exceptionThrown', (params, sid) => {
558
+ if (sid !== sessionId) return;
559
+ complain(`the window threw: ${String(params?.exceptionDetails?.exception?.description ?? params?.exceptionDetails?.text ?? '')}`.split('\n')[0]);
560
+ });
561
+ browser.on('Inspector.targetCrashed', (_params, sid) => {
562
+ if (sid === sessionId) complain('the window crashed');
563
+ });
564
+
565
+ // ---- the boundary in front of the window's own requests
566
+ /** @type {{url: string, refused: boolean}[]} */
567
+ const requests = [];
568
+ browser.on('Fetch.requestPaused', (params, sid) => {
569
+ if (sid !== sessionId) return;
570
+ const url = String(params?.request?.url ?? '');
571
+ const off = leavesTheMachine(url);
572
+ requests.push({ url, refused: off });
573
+ // Every paused request MUST be answered, whatever happens, or the app hangs on it.
574
+ const answer = off
575
+ ? browser.send('Fetch.failRequest', { requestId: params.requestId, errorReason: 'BlockedByClient' }, sessionId)
576
+ : browser.send('Fetch.continueRequest', { requestId: params.requestId }, sessionId);
577
+ answer.catch(() => {
578
+ browser.send('Fetch.continueRequest', { requestId: params.requestId }, sessionId).catch(() => {});
579
+ });
580
+ });
581
+ await browser.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] }, sessionId).catch(() => {
582
+ couldNotWatch.push("the window's own network requests");
583
+ });
584
+
585
+ return {
586
+ isolation,
587
+ started,
588
+ main,
589
+ browser,
590
+ sessionId,
591
+ window,
592
+ watching,
593
+ couldNotWatch,
594
+ complaints,
595
+ requests,
596
+ openedInMs: Date.now() - startedAt,
597
+ };
598
+ }
599
+
600
+ /**
601
+ * Would this request leave the machine?
602
+ *
603
+ * The app's own files, its own data URLs and anything on this computer are its business.
604
+ * Everything else is the world, and the world is where the irreversible things live.
605
+ *
606
+ * @param {string} url
607
+ * @returns {boolean}
608
+ */
609
+ export function leavesTheMachine(url) {
610
+ try {
611
+ const parsed = new URL(url);
612
+ if (['file:', 'data:', 'blob:', 'devtools:', 'chrome:', 'chrome-extension:', 'about:'].includes(parsed.protocol)) return false;
613
+ const host = parsed.hostname.toLowerCase();
614
+ return !(host === '' || host === 'localhost' || host === '::1' || host === '0.0.0.0' || host.startsWith('127.'));
615
+ } catch {
616
+ return false;
617
+ }
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // The meaning tree
622
+ // ---------------------------------------------------------------------------
623
+
624
+ /**
625
+ * Cut one part of an address down to something a path can hold.
626
+ *
627
+ * A command line, a file path or a menu trail can run to hundreds of characters, and an
628
+ * address has a limit. Cutting alone would merge two different things into one address — two
629
+ * different long commands becoming the same fact — so what is cut off leaves a short
630
+ * fingerprint behind. Long and different stays different; long and identical stays identical.
631
+ *
632
+ * @param {string} text
633
+ * @param {number} [limit]
634
+ * @returns {string}
635
+ */
636
+ export function asAddress(text, limit = 110) {
637
+ const clean = String(text).replace(/[\r\n\t]+/g, ' ').trim();
638
+ if (clean.length <= limit) return clean || '(nothing)';
639
+ const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
640
+ return `${clean.slice(0, limit - 12)}… (${mark})`;
641
+ }
642
+
643
+ /**
644
+ * The states worth writing down.
645
+ *
646
+ * Short, and it is short on purpose. These are the things a person can SEE about a control:
647
+ * whether they can use it, whether it is on, whether it is open. `focused` is deliberately
648
+ * absent — which control has the keyboard is different on every run and would report a
649
+ * difference every single time.
650
+ */
651
+ const STATES_THAT_MATTER = ['disabled', 'checked', 'pressed', 'expanded', 'selected', 'required', 'invalid', 'readonly', 'level', 'modal', 'multiselectable'];
652
+
653
+ /**
654
+ * Roles that carry no meaning on their own and would triple the size of every reading.
655
+ * A `generic` div is not a control; it is the box the control came in.
656
+ */
657
+ const ROLES_WORTH_NOTHING = new Set(['generic', 'none', 'presentation', 'InlineTextBox', 'LineBreak', 'Iframe', 'RootWebArea']);
658
+
659
+ /**
660
+ * Turn the window's accessibility tree into one plain reading: what is on screen, by what it
661
+ * IS and what it SAYS.
662
+ *
663
+ * This is the meaning channel, and the reason it is the meaning channel rather than the
664
+ * markup: a class name change, a wrapper div, a whole styling rewrite all leave this
665
+ * identical, while a button that lost its label, went missing or went grey all show up as
666
+ * exactly one difference each.
667
+ *
668
+ * @param {any[]} nodes Straight from Accessibility.getFullAXTree.
669
+ * @param {(text: string) => string} [tidy] Rubs our own footprint out of the names.
670
+ * @returns {{address: string, role: string, name: string, state: Record<string, string|number|boolean>}[]}
671
+ */
672
+ export function readMeaning(nodes, tidy = (t) => t) {
673
+ /** @type {Map<string, number>} */
674
+ const seen = new Map();
675
+ /** @type {{address: string, role: string, name: string, state: Record<string, string|number|boolean>}[]} */
676
+ const rows = [];
677
+
678
+ for (const node of nodes ?? []) {
679
+ if (!node || node.ignored) continue;
680
+ const role = String(node.role?.value ?? '');
681
+ if (!role || ROLES_WORTH_NOTHING.has(role)) continue;
682
+ const name = tidy(String(node.name?.value ?? '')).trim().replace(/\s+/g, ' ').slice(0, 120);
683
+
684
+ /** @type {Record<string, string|number|boolean>} */
685
+ const state = {};
686
+ for (const property of node.properties ?? []) {
687
+ const key = String(property?.name ?? '');
688
+ if (!STATES_THAT_MATTER.includes(key)) continue;
689
+ const value = property?.value?.value;
690
+ if (value === undefined || value === false || value === 'false') continue;
691
+ state[key] = typeof value === 'object' ? String(value) : value;
692
+ }
693
+ const own = node.value?.value;
694
+ if (own !== undefined && own !== null && String(own) !== '') state.value = tidy(String(own)).slice(0, 200);
695
+ if (node.description?.value) state.described = tidy(String(node.description.value)).slice(0, 200);
696
+
697
+ // A control with no name is only worth an address when it says something else about
698
+ // itself; an anonymous, stateless box is noise in every reading it appears in.
699
+ if (name === '' && Object.keys(state).length === 0) continue;
700
+
701
+ const base = `${role}: ${name || '(no name)'}`;
702
+ const times = (seen.get(base) ?? 0) + 1;
703
+ seen.set(base, times);
704
+ rows.push({
705
+ address: times === 1 ? base : `${base} #${times}`,
706
+ role,
707
+ name,
708
+ state,
709
+ });
710
+ }
711
+ return rows;
712
+ }
713
+
714
+ /**
715
+ * Read the window until it stops changing.
716
+ *
717
+ * Generalised from the picture check that reads until two frames agree: it works on any
718
+ * observation, and it is what stops an animation, a spinner or a list still loading from
719
+ * being reported as a difference. Whether it settled is itself written down, because a
720
+ * window that never stops moving is worth knowing about.
721
+ *
722
+ * @param {() => Promise<any[]>} read
723
+ * @param {{tries?: number, gapMs?: number}} [opts]
724
+ * @returns {Promise<{nodes: any[], settled: boolean, reads: number}>}
725
+ */
726
+ export async function settleTree(read, opts = {}) {
727
+ const tries = opts.tries ?? 8;
728
+ const gapMs = opts.gapMs ?? 350;
729
+ let previous = '';
730
+ /** @type {any[]} */
731
+ let nodes = [];
732
+ for (let attempt = 1; attempt <= tries; attempt += 1) {
733
+ nodes = await read();
734
+ const now = JSON.stringify(readMeaning(nodes));
735
+ if (attempt > 1 && now === previous) return { nodes, settled: true, reads: attempt };
736
+ previous = now;
737
+ await rest(gapMs);
738
+ }
739
+ return { nodes, settled: false, reads: tries };
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Driving it
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /** The handful of keys a journey ever names, with what CDP needs to send them. */
747
+ const KEYS = /** @type {Record<string, {key: string, code: string, keyCode: number}>} */ ({
748
+ Enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
749
+ Tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
750
+ Escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
751
+ Backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
752
+ ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
753
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
754
+ ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
755
+ ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
756
+ });
757
+
758
+ /**
759
+ * Do one step of a journey, and say in plain English what happened.
760
+ *
761
+ * Controls are found by what they SAY, never by a selector. A journey written against
762
+ * `#sidebar > div:nth-child(3)` breaks when somebody adds a div and reports it as a
763
+ * regression; a journey written against "New session" breaks only when the button called
764
+ * "New session" is gone, which is a real finding.
765
+ *
766
+ * @param {OpenApp} app
767
+ * @param {Record<string, any>} step
768
+ * @returns {Promise<{did: string, ok: boolean}>}
769
+ */
770
+ export async function takeStep(app, step) {
771
+ const act = String(step.act ?? '');
772
+ const say = (/** @type {string} */ did, /** @type {boolean} */ ok = true) => ({ did, ok });
773
+
774
+ if (act === 'wait') {
775
+ if (step.control) {
776
+ const until = Date.now() + Number(step.timeoutMs ?? 10_000);
777
+ for (;;) {
778
+ const tree = await app.browser.send('Accessibility.getFullAXTree', {}, app.sessionId).catch(() => ({ nodes: [] }));
779
+ if (readMeaning(tree.nodes ?? []).some((row) => row.name === String(step.control))) return say(`waited until "${step.control}" appeared`);
780
+ if (Date.now() > until) return say(`waited for "${step.control}", and it never appeared`, false);
781
+ await rest(200);
782
+ }
783
+ }
784
+ await rest(Number(step.ms ?? 500));
785
+ return say(`waited ${timeBucket(Number(step.ms ?? 500))}`);
786
+ }
787
+
788
+ if (act === 'click' || act === 'focus') {
789
+ const tree = await app.browser.send('Accessibility.getFullAXTree', {}, app.sessionId);
790
+ const wanted = String(step.control ?? step.name ?? '');
791
+ const node = (tree.nodes ?? []).find((/** @type {any} */ n) => {
792
+ if (!n || n.ignored || !n.backendDOMNodeId) return false;
793
+ const name = String(n.name?.value ?? '').trim();
794
+ if (name !== wanted) return false;
795
+ return step.role ? String(n.role?.value ?? '') === String(step.role) : true;
796
+ });
797
+ if (!node) return say(`could not find anything called "${wanted}" to ${act}`, false);
798
+ const resolved = await app.browser.send('DOM.resolveNode', { backendNodeId: node.backendDOMNodeId }, app.sessionId);
799
+ const objectId = resolved?.object?.objectId;
800
+ if (!objectId) return say(`found "${wanted}" but could not reach it`, false);
801
+ await app.browser.send('Runtime.callFunctionOn', {
802
+ objectId,
803
+ functionDeclaration: act === 'click'
804
+ ? 'function () { this.scrollIntoView({ block: "center" }); this.click ? this.click() : this.dispatchEvent(new MouseEvent("click", { bubbles: true })); }'
805
+ : 'function () { this.focus && this.focus(); }',
806
+ awaitPromise: false,
807
+ }, app.sessionId);
808
+ return say(`${act === 'click' ? 'clicked' : 'focused'} "${wanted}"`);
809
+ }
810
+
811
+ if (act === 'type') {
812
+ await app.browser.send('Input.insertText', { text: String(step.text ?? '') }, app.sessionId);
813
+ return say(`typed "${String(step.text ?? '').slice(0, 40)}"`);
814
+ }
815
+
816
+ if (act === 'press') {
817
+ const key = KEYS[String(step.key ?? '')];
818
+ if (!key) return say(`does not know how to press "${step.key}"`, false);
819
+ for (const type of /** @type {const} */ (['keyDown', 'keyUp'])) {
820
+ await app.browser.send('Input.dispatchKeyEvent', {
821
+ type,
822
+ key: key.key,
823
+ code: key.code,
824
+ windowsVirtualKeyCode: key.keyCode,
825
+ nativeVirtualKeyCode: key.keyCode,
826
+ }, app.sessionId);
827
+ }
828
+ return say(`pressed ${key.key}`);
829
+ }
830
+
831
+ if (act === 'evaluate') {
832
+ const result = await app.browser.send('Runtime.evaluate', {
833
+ expression: String(step.js ?? ''),
834
+ awaitPromise: true,
835
+ returnByValue: true,
836
+ }, app.sessionId);
837
+ if (result?.exceptionDetails) return say(`ran the project's own step and it threw: ${String(result.exceptionDetails.text ?? '')}`, false);
838
+ return say('ran the project\'s own step');
839
+ }
840
+
841
+ return say(`does not know how to "${act}"`, false);
842
+ }
843
+
844
+ /**
845
+ * Ask ONE named door to answer, on purpose.
846
+ *
847
+ * This is the only place anything is invoked rather than read, and it happens only for a
848
+ * channel the project itself named in its config. The handler is called inside the main
849
+ * process with the real window as its sender, so it behaves exactly as it would for the app
850
+ * — and the boundary in front of the network is still in force while it does.
851
+ *
852
+ * @param {OpenApp} app
853
+ * @param {string} channel
854
+ * @param {any[]} args
855
+ * @returns {Promise<{answered: boolean, value: unknown, why: string}>}
856
+ */
857
+ export async function exerciseChannel(app, channel, args) {
858
+ const expression = `(async () => {
859
+ const req = typeof require === 'function' ? require : process.mainModule.require;
860
+ const electron = req('electron');
861
+ const handler = electron.ipcMain._invokeHandlers && electron.ipcMain._invokeHandlers.get(${JSON.stringify(channel)});
862
+ if (!handler) return { answered: false, why: 'nothing is registered on that channel' };
863
+ const win = electron.BrowserWindow.getAllWindows()[0];
864
+ if (!win) return { answered: false, why: 'the app has no window to ask on behalf of' };
865
+ let reply, thrown;
866
+ const event = { sender: win.webContents, senderFrame: win.webContents.mainFrame, frameId: 0, processId: 0, _reply: (v) => { reply = v; }, _throw: (e) => { thrown = e; } };
867
+ // Electron has stored the handler two different ways across versions: one wraps the
868
+ // project's function and calls _reply, the other stores the function itself and hands
869
+ // the answer straight back. Both are read, so neither version reports "it answered
870
+ // nothing" when it answered perfectly well.
871
+ const returned = await handler(event, ...${JSON.stringify(args)});
872
+ if (reply === undefined && returned !== undefined) reply = returned;
873
+ if (thrown) return { answered: false, why: 'it threw: ' + String(thrown && thrown.message || thrown) };
874
+ return { answered: true, value: reply === undefined ? null : reply, why: 'it answered' };
875
+ })()`;
876
+ const result = await app.main.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
877
+ if (result?.exceptionDetails) {
878
+ return { answered: false, value: null, why: `asking it threw: ${String(result.exceptionDetails.text ?? '')}` };
879
+ }
880
+ const value = result?.result?.value ?? {};
881
+ return { answered: value.answered === true, value: value.value ?? null, why: String(value.why ?? '') };
882
+ }
883
+
884
+ // ---------------------------------------------------------------------------
885
+ // Turning one open app into observations
886
+ // ---------------------------------------------------------------------------
887
+
888
+ /**
889
+ * Everything the app is, right now, written down.
890
+ *
891
+ * Split fine on purpose, exactly as the CLI adapter is. One observation holding "here is the
892
+ * whole app" means any change anywhere reports as one enormous difference and an agent has to
893
+ * work out for itself which part moved. One observation per control, per door, per menu item,
894
+ * per file means the difference names itself.
895
+ *
896
+ * @param {object} input
897
+ * @param {OpenApp} input.app
898
+ * @param {Journey} input.journey
899
+ * @param {RunContext} input.ctx
900
+ * @param {any} input.reading What the main process said about itself.
901
+ * @param {any[]} input.axNodes
902
+ * @param {boolean} input.settled
903
+ * @param {import('./process.js').TreeSnapshot} input.before
904
+ * @param {import('./process.js').TreeSnapshot} input.after
905
+ * @param {string[]} input.declaredChannels IPC channels the source said exist.
906
+ * @param {string[]} input.did What the journey's steps actually managed.
907
+ * @param {string} [input.projectRoot]
908
+ * @returns {Observation[]}
909
+ */
910
+ export function describeApp(input) {
911
+ const { app, journey, reading, axNodes, settled, declaredChannels, did } = input;
912
+ const id = journey.name;
913
+ const tidy = (/** @type {string} */ text) => undoOurFootprint(text, {
914
+ dirs: [app.isolation.dir],
915
+ ports: [app.isolation.debugPort, app.isolation.inspectPort],
916
+ projectRoot: input.projectRoot,
917
+ });
918
+ /** @type {Observation[]} */
919
+ const out = [];
920
+
921
+ // ---- what the window says it offers
922
+ const meaning = readMeaning(axNodes, tidy);
923
+ for (const row of meaning) {
924
+ out.push(observation({
925
+ channel: 'meaning',
926
+ path: joinPath('screen', id, asAddress(row.address)),
927
+ value: Object.keys(row.state).length === 0 ? 'there, and nothing special about it' : row.state,
928
+ says: `The window offers ${row.role === 'StaticText' ? 'the words' : `a ${row.role}`} "${row.name || '(with no name)'}"${
929
+ Object.keys(row.state).length === 0 ? '.' : `, and it is ${Object.entries(row.state).map(([k, v]) => (v === true ? k : `${k}: ${v}`)).join(', ')}.`}`,
930
+ journey: id,
931
+ surface: 'electron',
932
+ }));
933
+ }
934
+ out.push(observation({
935
+ channel: 'counters',
936
+ path: joinPath('count', id, 'things on screen'),
937
+ value: countBucket(meaning.length),
938
+ says: `The window offers ${meaning.length} named things. A big drop here usually means a screen did not finish loading.`,
939
+ journey: id,
940
+ }));
941
+ out.push(observation({
942
+ channel: 'counters',
943
+ path: joinPath('count', id, 'window stopped moving'),
944
+ value: settled,
945
+ says: settled
946
+ ? 'The window was read twice in a row and said the same thing both times, so nothing was still animating when it was read.'
947
+ : 'The window never stopped changing, so what was read is one moment of something still moving. Anything that differs here may be timing rather than a real change.',
948
+ journey: id,
949
+ }));
950
+
951
+ // ---- the doors the running app answers on
952
+ const answers = reading?.ipc?.answers;
953
+ const listens = reading?.ipc?.listens;
954
+ if (Array.isArray(answers)) {
955
+ for (const channel of [...answers].sort()) {
956
+ out.push(observation({
957
+ channel: 'contract',
958
+ path: joinPath('ipc', asAddress(String(channel)), 'answering'),
959
+ value: 'registered, and answers when asked',
960
+ says: `The running app answers on the private channel "${channel}". If this stops being registered, everything in the window that used it stops working, and nothing on screen looks any different.`,
961
+ journey: id,
962
+ surface: 'electron',
963
+ }));
964
+ }
965
+ } else {
966
+ out.push(notCovered({
967
+ channel: 'contract',
968
+ path: joinPath('ipc', id, 'readable at all'),
969
+ reason: 'not supported here',
970
+ says: 'This version of Electron does not let the list of answering channels be read, so which private doors the running app opens is not known. The list the source declares is still checked.',
971
+ }));
972
+ }
973
+ if (Array.isArray(listens)) {
974
+ for (const channel of [...listens].sort()) {
975
+ out.push(observation({
976
+ channel: 'contract',
977
+ path: joinPath('ipc', asAddress(String(channel)), 'listening'),
978
+ value: 'registered, and listens',
979
+ says: `The running app listens on the private channel "${channel}" — it takes messages there but does not reply.`,
980
+ journey: id,
981
+ surface: 'electron',
982
+ }));
983
+ }
984
+ }
985
+ const registered = new Set([...(Array.isArray(answers) ? answers : []), ...(Array.isArray(listens) ? listens : [])].map(String));
986
+ out.push(observation({
987
+ channel: 'counters',
988
+ path: joinPath('count', id, 'doors the app answers on'),
989
+ value: registered.size,
990
+ says: `${registered.size} private channels are registered in the running app.`,
991
+ journey: id,
992
+ }));
993
+
994
+ // ---- the ledger: what the code declares against what the app actually registered
995
+ if (declaredChannels.length > 0 && registered.size > 0) {
996
+ const missing = declaredChannels.filter((name) => !registered.has(name));
997
+ const exercised = (journey.steps ?? []).filter((s) => String(/** @type {any} */ (s).act) === 'ipc').length;
998
+ out.push(observation({
999
+ channel: 'counters',
1000
+ path: joinPath('count', id, 'doors declared but not registered'),
1001
+ value: missing.length,
1002
+ says: missing.length === 0
1003
+ ? 'Every channel the code declares is registered in the running app.'
1004
+ : `${missing.length} channels are written in the code but not registered while the app runs — ${missing.slice(0, 5).join(', ')}${missing.length > 5 ? ', and more' : ''}. That is normal for channels only registered on some screens, and worth a look when it changes.`,
1005
+ journey: id,
1006
+ }));
1007
+ out.push(notCovered({
1008
+ channel: 'contract',
1009
+ path: joinPath('count', id, 'doors actually opened'),
1010
+ reason: 'not supported here',
1011
+ says: `${registered.size} doors are registered and ${exercised} of them were actually asked to answer. The rest are known to EXIST and are not known to WORK — opening one blindly could do anything, including something that cannot be undone, so a channel is only tried when the project names it in its config.`,
1012
+ }));
1013
+ }
1014
+
1015
+ // ---- its windows
1016
+ for (const window of reading?.windows ?? []) {
1017
+ out.push(observation({
1018
+ channel: 'meaning',
1019
+ path: joinPath('window', id, asAddress(String(window.title || '(untitled)'))),
1020
+ // Position is left out on purpose: where the operating system puts a window differs
1021
+ // every time and would report a difference on every run.
1022
+ value: {
1023
+ visible: window.visible === true,
1024
+ size: `${window.width} by ${window.height}`,
1025
+ resizable: window.resizable === true,
1026
+ fullScreen: window.fullScreen === true,
1027
+ },
1028
+ says: `The app has a window called "${window.title || '(untitled)'}", ${window.visible ? 'shown' : 'hidden'}, ${window.width} by ${window.height}. Where it sits on screen is not compared, because that differs every run.`,
1029
+ journey: id,
1030
+ surface: 'electron',
1031
+ }));
1032
+ }
1033
+ out.push(observation({
1034
+ channel: 'counters',
1035
+ path: joinPath('count', id, 'windows open'),
1036
+ value: (reading?.windows ?? []).length,
1037
+ says: `The app has ${(reading?.windows ?? []).length} window${(reading?.windows ?? []).length === 1 ? '' : 's'} open.`,
1038
+ journey: id,
1039
+ }));
1040
+
1041
+ // ---- its menus
1042
+ for (const item of reading?.menu ?? []) {
1043
+ out.push(observation({
1044
+ channel: 'meaning',
1045
+ path: joinPath('menu', id, asAddress(String(item.at))),
1046
+ value: { enabled: item.enabled === true, visible: item.visible === true, shortcut: String(item.key || 'none') },
1047
+ says: `The menu has "${item.at}"${item.key ? ` (${item.key})` : ''}, ${item.enabled ? 'usable' : 'greyed out'}. A menu item that quietly went grey is a control that stopped working with nothing on screen to say so.`,
1048
+ journey: id,
1049
+ surface: 'electron',
1050
+ }));
1051
+ }
1052
+
1053
+ // ---- what it wrote in its own settings folder
1054
+ const changes = compareTrees(input.before, input.after);
1055
+ for (const change of changes) {
1056
+ out.push(observation({
1057
+ channel: 'effects',
1058
+ path: joinPath('file', id, asAddress(change.file)),
1059
+ value: change.what === 'deleted' ? 'deleted' : { what: change.what, contents: change.now ?? '' },
1060
+ says: change.what === 'deleted'
1061
+ ? `The app deleted ${change.file} from its own settings folder.`
1062
+ : `The app ${change.what} ${change.file} in its own settings folder. Only the contents are compared, so rewriting the same bytes is not a change.`,
1063
+ journey: id,
1064
+ }));
1065
+ }
1066
+ out.push(observation({
1067
+ channel: 'counters',
1068
+ path: joinPath('count', id, 'files it wrote'),
1069
+ value: countBucket(changes.length),
1070
+ says: `The app created or changed ${changes.length} file${changes.length === 1 ? '' : 's'} in its own settings folder while this journey ran.`,
1071
+ journey: id,
1072
+ }));
1073
+
1074
+ // ---- what it started, and what it reached for
1075
+ const effects = reading?.effects;
1076
+ if (effects) {
1077
+ for (const spawned of effects.spawns ?? []) {
1078
+ out.push(observation({
1079
+ channel: 'effects',
1080
+ path: joinPath('proc', id, asAddress(tidy(String(spawned.what)))),
1081
+ value: spawned.times,
1082
+ says: `The app started ${tidy(String(spawned.what))}${spawned.times > 1 ? ` ${spawned.times} times` : ''}. A program appearing here that was not here before means the app started depending on something new.`,
1083
+ journey: id,
1084
+ }));
1085
+ }
1086
+ const refused = new Set((effects.refused ?? []).map(String));
1087
+ for (const attempt of effects.connects ?? []) {
1088
+ const where = tidy(String(attempt.what));
1089
+ const wasRefused = [...refused].some((r) => where.includes(r));
1090
+ out.push(observation({
1091
+ channel: 'effects',
1092
+ path: joinPath('net', id, asAddress(where)),
1093
+ value: wasRefused
1094
+ ? `tried ${attempt.times} time${attempt.times === 1 ? '' : 's'}, refused every time`
1095
+ : `tried ${attempt.times} time${attempt.times === 1 ? '' : 's'}, on this machine`,
1096
+ says: wasRefused
1097
+ ? `The app tried to reach ${where}, which is off this machine, and was refused. What it asked for is compared; whether it would have worked is not, because it was never allowed to happen.`
1098
+ : `The app connected to ${where}, which is on this machine, so it was allowed through.`,
1099
+ covered: wasRefused ? false : undefined,
1100
+ reason: wasRefused ? 'irreversible' : undefined,
1101
+ journey: id,
1102
+ }));
1103
+ }
1104
+ for (const hole of effects.couldNotWatch ?? []) {
1105
+ out.push(notCovered({
1106
+ channel: 'effects',
1107
+ path: joinPath('net', id, asAddress(`not watched: ${hole}`, 80)),
1108
+ reason: 'not supported here',
1109
+ says: `Nothing was watching ${hole}, so anything the app did that way was neither seen nor stopped.`,
1110
+ }));
1111
+ }
1112
+ } else {
1113
+ out.push(notCovered({
1114
+ channel: 'effects',
1115
+ path: joinPath('net', id, 'watched at all'),
1116
+ reason: 'not supported here',
1117
+ says: 'Nothing was watching the main process from the inside, so what the app started and what it reached for were not seen, and nothing was refused. Its window, its doors and its files were still read exactly.',
1118
+ }));
1119
+ }
1120
+
1121
+ // ---- what the window asked the network for
1122
+ const offMachine = app.requests.filter((r) => r.refused);
1123
+ if (offMachine.length > 0) {
1124
+ /** @type {Map<string, number>} */
1125
+ const hosts = new Map();
1126
+ for (const request of offMachine) {
1127
+ let host = request.url;
1128
+ try { host = new URL(request.url).host; } catch { /* keep the whole thing */ }
1129
+ hosts.set(host, (hosts.get(host) ?? 0) + 1);
1130
+ }
1131
+ for (const [host, times] of [...hosts].sort()) {
1132
+ out.push(observation({
1133
+ channel: 'effects',
1134
+ path: joinPath('net', id, asAddress(`window asked ${host}`)),
1135
+ value: `asked ${times} time${times === 1 ? '' : 's'}, refused every time`,
1136
+ says: `The window itself tried to reach ${host} and was refused. The ask is compared; the answer never happened.`,
1137
+ covered: false,
1138
+ reason: 'irreversible',
1139
+ journey: id,
1140
+ }));
1141
+ }
1142
+ }
1143
+
1144
+ // ---- what it complained about
1145
+ out.push(observation({
1146
+ channel: 'complaints',
1147
+ path: joinPath('app', id, 'errors'),
1148
+ value: app.complaints.length === 0 ? 'none' : app.complaints.map((c) => tidy(c)),
1149
+ says: app.complaints.length === 0
1150
+ ? 'Neither the window nor the main process complained about anything while this journey ran.'
1151
+ : `${app.complaints.length} error${app.complaints.length === 1 ? '' : 's'} came out of the app while this journey ran. A screen can look perfect and still be on fire.`,
1152
+ journey: id,
1153
+ }));
1154
+ const ended = app.started.finished();
1155
+ out.push(observation({
1156
+ channel: 'complaints',
1157
+ path: joinPath('app', id, 'still running at the end'),
1158
+ value: ended === null ? true : `it quit on its own: ${ended.signal ? `stopped by ${ended.signal}` : `exit code ${ended.code}`}`,
1159
+ says: ended === null
1160
+ ? 'The app was still running when the journey finished, which is what should happen.'
1161
+ : 'The app quit on its own before the journey finished. That is a crash, whatever it printed on the way out.',
1162
+ journey: id,
1163
+ }));
1164
+
1165
+ // ---- how it went
1166
+ out.push(observation({
1167
+ channel: 'results',
1168
+ path: joinPath('app', id, 'steps'),
1169
+ value: did.length === 0 ? 'just opened it and looked' : did,
1170
+ says: did.length === 0
1171
+ ? 'This journey opened the app and read it, without touching anything.'
1172
+ : `What this journey did, in order: ${did.join('; ')}.`,
1173
+ journey: id,
1174
+ }));
1175
+ out.push(observation({
1176
+ channel: 'counters',
1177
+ path: joinPath('count', id, 'time to open'),
1178
+ value: timeBucket(app.openedInMs),
1179
+ says: `The app took ${timeBucket(app.openedInMs)} to open and show a window. Deliberately rough: exact timings differ every run.`,
1180
+ journey: id,
1181
+ }));
1182
+ if (reading?.helpers) {
1183
+ out.push(observation({
1184
+ channel: 'counters',
1185
+ path: joinPath('count', id, 'helper processes'),
1186
+ value: reading.helpers.length,
1187
+ says: `The app is running as ${reading.helpers.length} processes: ${reading.helpers.join(', ')}. A desktop app is never one program.`,
1188
+ journey: id,
1189
+ }));
1190
+ }
1191
+
1192
+ for (const problem of reading?.problems ?? []) {
1193
+ out.push(notCovered({
1194
+ channel: 'meaning',
1195
+ path: joinPath('app', id, asAddress(`could not read ${String(problem).split(':')[0]}`, 80)),
1196
+ reason: 'crashed',
1197
+ says: `Part of the app could not be read: ${problem}`,
1198
+ }));
1199
+ }
1200
+
1201
+ return out;
1202
+ }
1203
+
1204
+ // ---------------------------------------------------------------------------
1205
+ // The adapter
1206
+ // ---------------------------------------------------------------------------
1207
+
1208
+ /**
1209
+ * What each prepared build needs to open itself, kept out of the engine's way.
1210
+ * @type {Map<string, {binary: string, config: Record<string, any>, projectRoot: string}>}
1211
+ */
1212
+ const recipes = new Map();
1213
+
1214
+ /**
1215
+ * Every app opened by this adapter that has not been closed yet.
1216
+ * @type {Set<OpenApp>}
1217
+ */
1218
+ const openNow = new Set();
1219
+
1220
+ /**
1221
+ * The IPC channels the source adapter read out of the code, remembered from `journeys` so
1222
+ * `run` can say how many of them the running app actually registered. The engine hands the
1223
+ * contract to `journeys` and not to `run`, so this is where it has to be caught.
1224
+ * @type {string[]}
1225
+ */
1226
+ let channelsFromTheCode = [];
1227
+
1228
+ /**
1229
+ * Close one app and prove it is gone. Used at the end of every journey, and again on the way
1230
+ * out of a check that threw.
1231
+ *
1232
+ * @param {OpenApp} app
1233
+ * @param {(message: string) => void} [log]
1234
+ * @returns {Promise<import('./isolate.js').TeardownReport>}
1235
+ */
1236
+ async function closeApp(app, log) {
1237
+ openNow.delete(app);
1238
+ const report = await releaseIsolation(app.isolation);
1239
+ log?.(report.why);
1240
+ return report;
1241
+ }
1242
+
1243
+ /**
1244
+ * The channels the source said this app opens, pulled out of the contract the source adapter
1245
+ * already read. Free, and it is what turns "421 doors" into "421 doors and here are the three
1246
+ * that stopped answering".
1247
+ *
1248
+ * @param {Observation[]} [contract]
1249
+ * @returns {string[]}
1250
+ */
1251
+ export function declaredChannels(contract) {
1252
+ /** @type {Set<string>} */
1253
+ const names = new Set();
1254
+ for (const item of contract ?? []) {
1255
+ const parts = splitPath(item.path);
1256
+ if (parts[0] === 'ipc' && parts.length >= 2 && parts[parts.length - 1] === 'registered') {
1257
+ names.add(parts.slice(1, -1).join('.'));
1258
+ }
1259
+ }
1260
+ return [...names].sort();
1261
+ }
1262
+
1263
+ export const electronAdapter = defineAdapter({
1264
+ name: 'electron',
1265
+ title: 'Desktop apps built with Electron',
1266
+ describe:
1267
+ 'Opens a built desktop app on its own — its own settings folder, its own ports, its own name — and reads both halves of it: every control in the window by what it says it is, every private channel the app has registered, its windows and menus, the files it writes, the programs it starts and everything it tries to reach. It never knocks on a door it was not told to knock on, and it refuses every connection that would leave this machine. It cannot see what a channel DOES unless a journey names it, and it cannot check an app that has not been built yet.',
1268
+ channels: ['meaning', 'effects', 'complaints', 'results', 'contract', 'counters', 'pixels'],
1269
+
1270
+ /** @param {import('./contract.js').AdapterProject} project */
1271
+ async detect(project) {
1272
+ const config = project.config ?? {};
1273
+ const found = await findAppBinary(project.root, config);
1274
+ /** @type {import('./contract.js').Missing[]} */
1275
+ const missing = [];
1276
+
1277
+ let usesElectron = false;
1278
+ try {
1279
+ const pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8'));
1280
+ usesElectron = Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron || pkg.build?.appId);
1281
+ } catch { /* a project with no package.json can still be a built app somebody pointed at */ }
1282
+
1283
+ if (!found.binary) {
1284
+ missing.push({
1285
+ what: 'the built app',
1286
+ unlocks: 'checking the desktop app at all — the window, its private channels, its menus and everything it writes',
1287
+ howToGet: usesElectron
1288
+ ? 'Build it the way you normally do (often `npm run build` then `npm run package`), then point "electron.binary" in the config at the result. On a Mac that is the .app; on Windows the .exe.'
1289
+ : 'Point "electron.binary" in the config at your built desktop app.',
1290
+ blocking: true,
1291
+ });
1292
+ }
1293
+ if (!config.identityEnv) {
1294
+ missing.push({
1295
+ what: 'the name of the setting this app uses to know who it is',
1296
+ unlocks: 'running two builds safely when the app signs in to something with a device id or takes a slot on a server',
1297
+ howToGet: 'Put {"identityEnv": {"YOUR_APP_DEVICE_ID": "{identity}"}} under "electron" in the config. Without it, two runs could both claim the same slot — which is the exact bug that cost a day on this machine on 2026-08-28.',
1298
+ });
1299
+ }
1300
+
1301
+ const applies = Boolean(found.binary) || usesElectron;
1302
+ return {
1303
+ applies,
1304
+ confidence: found.binary ? 1 : (usesElectron ? 0.5 : 0),
1305
+ why: found.binary
1306
+ ? `${found.why} It will be opened on its own, read, and closed again — one copy at a time, never two.`
1307
+ : usesElectron
1308
+ ? `This is an Electron project, but no built app was found, so there is nothing to open yet. ${found.why}`
1309
+ : 'This does not look like an Electron desktop app.',
1310
+ missing,
1311
+ notes: [
1312
+ 'The list of private channels is read out of the running app, never knocked on. Asking an unknown channel to answer could do anything, including something that cannot be undone.',
1313
+ 'Every connection that would leave this machine is refused, and refusals are reported as holes in what was checked — never as a pass.',
1314
+ 'The two builds are opened one after the other, never at the same time, and the first one is proved gone before the second starts.',
1315
+ ],
1316
+ };
1317
+ },
1318
+
1319
+ /** @param {import('./contract.js').AdapterProject} project */
1320
+ async journeys(project) {
1321
+ const config = project.config ?? {};
1322
+ channelsFromTheCode = declaredChannels(project.contract ?? config.contract);
1323
+ /** @type {Journey[]} */
1324
+ const journeys = [{
1325
+ name: 'open-the-app',
1326
+ describe: 'open the app and read everything it shows and everything it registers',
1327
+ source: 'code',
1328
+ surface: 'electron',
1329
+ channels: ['meaning', 'effects', 'complaints', 'results', 'contract', 'counters', 'pixels'],
1330
+ steps: [],
1331
+ }];
1332
+
1333
+ for (const written of config.journeys ?? []) {
1334
+ journeys.push({
1335
+ name: String(written.name ?? 'a journey with no name'),
1336
+ describe: String(written.describe ?? written.why ?? `walk ${written.name}`),
1337
+ source: 'recorded',
1338
+ surface: 'electron',
1339
+ from: 'the project config',
1340
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
1341
+ steps: Array.isArray(written.steps) ? written.steps : [],
1342
+ irreversible: written.irreversible === true,
1343
+ timeoutMs: written.timeoutMs,
1344
+ });
1345
+ }
1346
+
1347
+ // A channel the project has named as safe to ask gets its own journey, because a door
1348
+ // that is registered and a door that answers are two different facts and only one of
1349
+ // them is free.
1350
+ for (const asked of config.exercise ?? []) {
1351
+ const channel = typeof asked === 'string' ? asked : String(asked.channel ?? '');
1352
+ if (!channel) continue;
1353
+ journeys.push({
1354
+ name: `ask ${channel}`,
1355
+ describe: `ask the private channel "${channel}" to answer, and compare what it says`,
1356
+ source: 'code',
1357
+ surface: 'electron',
1358
+ from: 'the project config',
1359
+ channels: ['results', 'complaints', 'effects', 'counters'],
1360
+ steps: [{ act: 'ipc', channel, args: (typeof asked === 'object' && Array.isArray(asked.args)) ? asked.args : [] }],
1361
+ irreversible: typeof asked === 'object' && asked.irreversible === true,
1362
+ });
1363
+ }
1364
+
1365
+ return journeys;
1366
+ },
1367
+
1368
+ /**
1369
+ * Get one build ready. Nothing is opened here.
1370
+ *
1371
+ * The app is opened FRESH for every journey rather than once per build, and that is a
1372
+ * deliberate trade. Booting is normally the expensive part — but a desktop app measured
1373
+ * here opens in about a second, while a journey that clicked three things leaves the next
1374
+ * journey looking at a different app. Paying a second to have every journey start from the
1375
+ * same place is the better half of that bargain, and it makes "nothing is ever left
1376
+ * running" true by construction rather than by care.
1377
+ *
1378
+ * @param {import('./contract.js').Build} build
1379
+ * @param {RunContext} ctx
1380
+ */
1381
+ async prepare(build, ctx) {
1382
+ const config = ctx.config ?? {};
1383
+ const found = await findAppBinary(build.root, config);
1384
+ if (!found.binary) {
1385
+ return {
1386
+ build,
1387
+ root: build.root,
1388
+ ready: false,
1389
+ why: `${found.why} There is nothing to open, so this build cannot be walked. Build the app and point "electron.binary" at it.`,
1390
+ dispose: async () => {},
1391
+ };
1392
+ }
1393
+ recipes.set(build.id, { binary: found.binary, config, projectRoot: build.root });
1394
+ return {
1395
+ build,
1396
+ root: build.root,
1397
+ ready: true,
1398
+ why: `The app at ${found.binary} will be opened fresh for each journey, on its own settings folder and its own ports, and closed and proved gone afterwards.`,
1399
+ facts: { binary: found.binary },
1400
+ dispose: async () => {
1401
+ recipes.delete(build.id);
1402
+ for (const app of [...openNow]) await closeApp(app);
1403
+ },
1404
+ };
1405
+ },
1406
+
1407
+ /**
1408
+ * @param {Journey} journey
1409
+ * @param {import('./contract.js').PreparedBuild} build
1410
+ * @param {RunContext} ctx
1411
+ * @returns {Promise<Observation[]>}
1412
+ */
1413
+ async run(journey, build, ctx) {
1414
+ const recipe = recipes.get(build.build.id);
1415
+ if (!build.ready || !recipe) {
1416
+ return [notCovered({
1417
+ channel: 'meaning',
1418
+ path: joinPath('app', journey.name, 'opened at all'),
1419
+ reason: 'refused',
1420
+ says: `"${journey.describe}" was not walked: ${build.why}`,
1421
+ })];
1422
+ }
1423
+ if (journey.irreversible && ctx.allowIrreversible !== true) {
1424
+ return [notCovered({
1425
+ channel: 'effects',
1426
+ path: joinPath('app', journey.name, 'opened at all'),
1427
+ reason: 'irreversible',
1428
+ says: `"${journey.describe}" was left alone because it spends money, sends a message or destroys data. This is a hole in what was checked, not a pass.`,
1429
+ })];
1430
+ }
1431
+
1432
+ const config = recipe.config ?? {};
1433
+ const isolation = await reserveIsolation({
1434
+ scratchDir: ctx.scratchDir,
1435
+ label: build.build.label,
1436
+ appId: config.appId ?? recipe.binary,
1437
+ clock: ctx.clock,
1438
+ seed: ctx.seed,
1439
+ identityEnv: config.identityEnv,
1440
+ env: config.env,
1441
+ });
1442
+
1443
+ /** @type {OpenApp|null} */
1444
+ let app = null;
1445
+ let closed = false;
1446
+ try {
1447
+ app = await openApp({
1448
+ binary: recipe.binary,
1449
+ isolation,
1450
+ windowMatch: config.windowMatch,
1451
+ extraArgs: config.args,
1452
+ timeoutMs: config.startTimeoutMs ?? 60_000,
1453
+ signal: ctx.signal,
1454
+ log: ctx.log,
1455
+ });
1456
+ openNow.add(app);
1457
+ ctx.log?.(describeIsolation(isolation));
1458
+
1459
+ const before = await snapshotTree(isolation.userDataDir);
1460
+
1461
+ // ---- do whatever this journey does
1462
+ /** @type {string[]} */
1463
+ const did = [];
1464
+ /** @type {Observation[]} */
1465
+ const extra = [];
1466
+ for (const step of journey.steps ?? []) {
1467
+ const asAny = /** @type {any} */ (step);
1468
+ if (String(asAny.act) === 'ipc') {
1469
+ const asked = await exerciseChannel(app, String(asAny.channel), Array.isArray(asAny.args) ? asAny.args : []);
1470
+ did.push(`asked "${asAny.channel}" to answer — ${asked.why}`);
1471
+ extra.push(observation({
1472
+ channel: 'results',
1473
+ path: joinPath('ipc', asAddress(String(asAny.channel)), 'answers'),
1474
+ value: asked.answered ? asked.value : `did not answer — ${asked.why}`,
1475
+ says: asked.answered
1476
+ ? `The private channel "${asAny.channel}" was asked to answer, and this is what it said. This is the only kind of door whose BEHAVIOUR is checked; the rest are only known to be registered.`
1477
+ : `The private channel "${asAny.channel}" was asked to answer and did not: ${asked.why}.`,
1478
+ journey: journey.name,
1479
+ surface: 'electron',
1480
+ }));
1481
+ continue;
1482
+ }
1483
+ const outcome = await takeStep(app, asAny);
1484
+ did.push(outcome.ok ? outcome.did : `${outcome.did} — and that is a failure, not a skip`);
1485
+ if (!outcome.ok) {
1486
+ extra.push(observation({
1487
+ channel: 'results',
1488
+ path: joinPath('app', journey.name, asAddress(`step: ${String(asAny.act)}`, 60)),
1489
+ value: `could not: ${outcome.did}`,
1490
+ says: `A step of "${journey.describe}" could not be carried out: ${outcome.did}. Everything after it in this journey happened somewhere the journey did not mean to be.`,
1491
+ journey: journey.name,
1492
+ }));
1493
+ }
1494
+ }
1495
+
1496
+ // ---- read it, once it has stopped moving
1497
+ const opened = app;
1498
+ const settledTree = await settleTree(async () => {
1499
+ const tree = await opened.browser.send('Accessibility.getFullAXTree', {}, opened.sessionId).catch(() => ({ nodes: [] }));
1500
+ return tree?.nodes ?? [];
1501
+ }, { tries: config.settleTries ?? 8, gapMs: config.settleGapMs ?? 350 });
1502
+
1503
+ const read = await app.main.send('Runtime.evaluate', { expression: mainReadScript(), returnByValue: true, awaitPromise: true });
1504
+ const reading = read?.result?.value ?? { problems: ['the main process would not say anything about itself'] };
1505
+ if (Array.isArray(app.couldNotWatch) && app.couldNotWatch.length > 0) {
1506
+ reading.effects = reading.effects ?? {};
1507
+ reading.effects.couldNotWatch = [...(reading.effects.couldNotWatch ?? []), ...app.couldNotWatch];
1508
+ }
1509
+
1510
+ const after = await snapshotTree(isolation.userDataDir);
1511
+
1512
+ const observations = describeApp({
1513
+ app,
1514
+ journey,
1515
+ ctx,
1516
+ reading,
1517
+ axNodes: settledTree.nodes,
1518
+ settled: settledTree.settled,
1519
+ before,
1520
+ after,
1521
+ declaredChannels: channelsFromTheCode,
1522
+ did,
1523
+ projectRoot: recipe.projectRoot,
1524
+ });
1525
+ observations.push(...extra);
1526
+
1527
+ // ---- and last of all, the picture
1528
+ observations.push(await takePicture(app, journey, ctx));
1529
+
1530
+ const teardown = await closeApp(app, ctx.log);
1531
+ closed = true;
1532
+ observations.push(observation({
1533
+ channel: 'counters',
1534
+ path: joinPath('app', journey.name, 'closed cleanly'),
1535
+ value: teardown.proved,
1536
+ says: teardown.why,
1537
+ journey: journey.name,
1538
+ }));
1539
+ return observations;
1540
+ } finally {
1541
+ // A check that threw must never leave somebody's screen with a scratch copy of their
1542
+ // own app sitting on it. Opening can fail halfway — a process started and no window —
1543
+ // so the isolation is released either way, and only ever once.
1544
+ if (!closed) {
1545
+ if (app) await closeApp(app, ctx.log);
1546
+ else await releaseIsolation(isolation).catch(() => {});
1547
+ }
1548
+ }
1549
+ },
1550
+
1551
+ async teardown() {
1552
+ for (const app of [...openNow]) await closeApp(app);
1553
+ recipes.clear();
1554
+ await releaseEverything();
1555
+ },
1556
+ });
1557
+
1558
+ /**
1559
+ * One picture, written to the evidence folder and pointed at.
1560
+ *
1561
+ * Channel seven, and it behaves like channel seven: what is COMPARED is a fingerprint and the
1562
+ * size, so a screen that really did change says so — and the picture itself is only ever
1563
+ * there so a person can look at what the other six channels already said.
1564
+ *
1565
+ * @param {OpenApp} app
1566
+ * @param {Journey} journey
1567
+ * @param {RunContext} ctx
1568
+ * @returns {Promise<Observation>}
1569
+ */
1570
+ async function takePicture(app, journey, ctx) {
1571
+ try {
1572
+ const shot = await app.browser.send('Page.captureScreenshot', { format: 'png' }, app.sessionId);
1573
+ const bytes = Buffer.from(String(shot?.data ?? ''), 'base64');
1574
+ if (bytes.length === 0) throw new Error('the app sent back an empty picture');
1575
+ const file = path.join(ctx.evidenceDir, `${journey.name.replace(/[^a-zA-Z0-9._-]+/g, '-').slice(0, 60)}.png`);
1576
+ await fsp.mkdir(ctx.evidenceDir, { recursive: true });
1577
+ await fsp.writeFile(file, bytes);
1578
+ return observation({
1579
+ channel: 'pixels',
1580
+ path: joinPath('screen', journey.name, 'picture'),
1581
+ value: { looks: crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 16), size: sizeBucket(bytes.length) },
1582
+ says: 'What the window looked like. This is evidence for a finding one of the other channels already made — never the accusation itself.',
1583
+ evidence: file,
1584
+ journey: journey.name,
1585
+ });
1586
+ } catch (e) {
1587
+ return notCovered({
1588
+ channel: 'pixels',
1589
+ path: joinPath('screen', journey.name, 'picture'),
1590
+ reason: 'crashed',
1591
+ says: `No picture could be taken of the window: ${e instanceof Error ? e.message : String(e)}. Everything else about the window was still read.`,
1592
+ });
1593
+ }
1594
+ }