staysfixed 0.3.1 → 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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
|
@@ -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
|
+
}
|