staysfixed 0.11.0 → 0.12.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.
@@ -0,0 +1,1324 @@
1
+ /**
2
+ * Native Linux desktop applications, driven over the machine somebody already has.
3
+ *
4
+ * This is the sibling of `windows.js`. Same problem — a window that can only be read from the
5
+ * operating system it is running on — solved the same way, and costed the same way before a
6
+ * line of it was written. Read the top of `linux-driver.js` for the measurements; this file is
7
+ * about what they MEAN.
8
+ *
9
+ * ── WHAT WAS FOUND BEFORE ANY OF THIS WAS WRITTEN ──────────────────────────────────────────
10
+ *
11
+ * Checked against a real Ubuntu 24.04 machine on 2026-08-31, with NOTHING installed for the
12
+ * purpose. Four measurements settled the design.
13
+ *
14
+ * 1. THE MACHINE ALREADY HAS EVERYTHING. `at-spi2-core` — the accessibility service every
15
+ * screen reader on Linux reads — and `gdbus` and `python3` and the `python3-dbus` module
16
+ * are on a stock desktop install. Nothing is copied to that machine and nothing is
17
+ * installed on it. The probe is Python, sent down the ssh connection at the start of a
18
+ * run, living only in the memory of the process running it.
19
+ * 2. ONE PROGRAM DOES THE WHOLE WALK. In one Python process: 189 controls with their names,
20
+ * roles, states, actions and values in 562-707 ms across four runs; 260 controls of a
21
+ * bigger app in about the same. Doing the same walk by running `gdbus` once per property
22
+ * instead: 30 things in 1343 ms, which is not slower but unusable. For scale, the Windows
23
+ * adapter reads 148 controls in 303 ms — the two platforms cost about the same.
24
+ * 3. THE SESSION IS FOUND, NOT ASSUMED. An ssh login has no screen, no session bus and no
25
+ * accessibility bus, because all three belong to the desktop the person logged in to. The
26
+ * probe reads them out of `/proc`, from a process that person's own session started.
27
+ * Proved on the demo box: with no configuration at all it found `DISPLAY=:99` and the
28
+ * session bus, and read the window.
29
+ * 4. THE OBVIOUS FAST PATH IS A LIAR AND IS NOT USED. Every application on the bus offers
30
+ * `org.a11y.atspi.Collection.GetMatches`, which is supposed to hand back a whole subtree
31
+ * in one call. It returned ZERO objects, in ZERO milliseconds, in all five of its modes,
32
+ * while a plain walk of the same app found 189 controls. An adapter that trusted it would
33
+ * have written "this app has no controls" into the reference, and every run after that
34
+ * would have compared zero against zero and agreed nothing had changed. That is the exact
35
+ * failure this product exists to prevent, and it is why every read here is cross-checked.
36
+ *
37
+ * ── WHAT IT WATCHES ────────────────────────────────────────────────────────────────────────
38
+ *
39
+ * meaning The window tree from the accessibility bus: what each control IS (a button, a
40
+ * check box, a list), what it is CALLED, whether it is on, off, ticked, chosen,
41
+ * open or closed, what it is set to, and what it says it can DO. This is the
42
+ * channel that answers "what does the screen say this control now does", and it
43
+ * is the reason the platform is worth covering at all.
44
+ * effects Programs it started, and files that changed in the folders it was told to
45
+ * watch, and the connections it had open while it ran.
46
+ * complaints Everything it wrote to its error output, and whether it exited — including
47
+ * which signal killed it, which is how a crash is recognised here.
48
+ * results What it printed, and the titles of the windows it opened.
49
+ * counters How many windows, how many controls and how long things took, all in buckets.
50
+ * pixels A picture of each window, as evidence for something another channel found.
51
+ *
52
+ * ── WHAT IT CANNOT DO, SAID PLAINLY ────────────────────────────────────────────────────────
53
+ *
54
+ * TWO BUILDS CANNOT RUN AT ONCE. Not "should not" — cannot. A desktop session has one screen
55
+ * and one accessibility bus, and both builds would appear on it as two applications with the
56
+ * same name, indistinguishable to anything reading the bus. So runs are strictly sequential,
57
+ * and the same-machine guarantee is weaker here than anywhere else: that desktop is shared
58
+ * with whatever else the person has open, and a notification or an update prompt appearing
59
+ * mid-run is a real difference this cannot tell from a real one. Running twice subtracts some
60
+ * of it. It does not subtract all of it.
61
+ *
62
+ * A MACHINE WITH NO DESKTOP SESSION HAS NO ACCESSIBILITY BUS AT ALL, and that is reported as
63
+ * exactly that. A server, or a machine nobody has logged in to, is not a desktop with an empty
64
+ * screen — there is nothing there to read. Reporting it as "no controls found" would put a
65
+ * confident zero into the reference and every later run would agree with it. So this adapter
66
+ * refuses to walk at all and says which of the two it is: no session, or a session with a
67
+ * bus that would not answer.
68
+ *
69
+ * AN APP THAT DOES NOT PUBLISH AN ACCESSIBILITY TREE IS INVISIBLE HERE, AND THAT IS A HOLE.
70
+ * Anything drawing its own widgets — a game, an Electron app with accessibility switched off,
71
+ * some Qt builds, anything on a canvas — appears on the bus as a window with nothing in it, or
72
+ * does not appear at all. The probe sets the four environment switches that turn the bridge on
73
+ * for GTK and for Qt before it starts the app, which is what fixes most of them. When a window
74
+ * still comes back empty it is recorded as UNCHECKED with that explanation, never as an app
75
+ * with no controls.
76
+ *
77
+ * PIXELS NEED ONE PACKAGE AND EVERYTHING ELSE NEEDS NONE. A picture is taken through the
78
+ * desktop's own toolkit, which needs `gir1.2-gtk-3.0` present. On the machine this was built
79
+ * against it was missing, every other channel worked perfectly, and the pixels channel said so
80
+ * and named the one-line install. Pictures are evidence for something another channel already
81
+ * found; they are never the thing compared, so a machine without it loses nothing that decides
82
+ * a release.
83
+ *
84
+ * ON WAYLAND THERE ARE NO PIXELS AT ALL, and the meaning channel does not care. The
85
+ * accessibility bus is the same on Wayland and X11, so every control is read exactly as well.
86
+ * Screen capture is not: Wayland refuses it to any program that has not gone through the
87
+ * desktop's own permission dialogue, which nothing unattended can answer. The picture is
88
+ * reported as missing with that reason.
89
+ *
90
+ * NOTHING IRREVERSIBLE IS WALKED. There is no way here to let a compiled program ask to reach
91
+ * the internet and then refuse it — the CLI adapter can, because it loads a watcher inside a
92
+ * Node child, and there is no equivalent for a GTK binary without root on somebody's desktop.
93
+ * So a journey marked irreversible is REFUSED OUTRIGHT, reported as missing coverage, and
94
+ * never run.
95
+ *
96
+ * MOST LINUX DESKTOP PRODUCTS DO NOT NEED THIS. If the Linux build is Electron — and most
97
+ * desktop products are, including the one this tool was written alongside — it is already
98
+ * covered from any machine over its debug port by the Electron adapter, in full, with two
99
+ * builds able to run side by side. This adapter DECLINES an app whose toolkit says Chromium
100
+ * and says where to go instead.
101
+ */
102
+
103
+ import fsp from 'node:fs/promises';
104
+ import path from 'node:path';
105
+ import { spawn } from 'node:child_process';
106
+ import {
107
+ countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
108
+ trimForStorage,
109
+ } from './contract.js';
110
+ import { RemoteLinkLost, remoteRunner } from '../remote.js';
111
+ import { endOfChild, letGoOf } from './process.js';
112
+ import {
113
+ COMPARED_STATES, MAX_SHOT_BYTES, MAX_TREE_NODES, askLinux, gdbusProbeCommand, readDesktopProbe,
114
+ } from './linux-driver.js';
115
+
116
+ /** @typedef {import('./contract.js').Build} Build */
117
+ /** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
118
+ /** @typedef {import('./contract.js').RunContext} RunContext */
119
+ /** @typedef {import('./contract.js').AdapterProject} AdapterProject */
120
+ /** @typedef {import('./contract.js').Detection} Detection */
121
+ /** @typedef {import('./contract.js').Missing} Missing */
122
+ /** @typedef {import('../types.js').Journey} Journey */
123
+ /** @typedef {import('../types.js').Observation} Observation */
124
+
125
+ /**
126
+ * Toolkit names that mean "this is not really a native Linux app".
127
+ *
128
+ * Every application tells the accessibility bus what drew it, and an Electron or Chrome window
129
+ * says Chromium. Seeing one is not a failure — it is this adapter finding out that a better
130
+ * tool for the job is already in the box and saying so. It is also the exact Linux equivalent
131
+ * of the Chromium window class the Windows adapter looks for, which is not a coincidence:
132
+ * these two adapters are the same adapter twice.
133
+ */
134
+ export const CHROMIUM_TOOLKITS = ['chromium', 'chrome', 'blink'];
135
+
136
+ /** How long to let an app get its first window onto the bus before calling it a no-show. */
137
+ const WINDOW_WAIT_MS = 20_000;
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Turning replies into observations
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /**
144
+ * @typedef {object} TreeNode
145
+ * @property {string} role
146
+ * @property {string} name
147
+ * @property {string} [describe]
148
+ * @property {string} [id]
149
+ * @property {number} depth
150
+ * @property {string[]} states
151
+ * @property {string[]} can
152
+ * @property {number|null} [value]
153
+ * @property {string|null} [text]
154
+ * @property {boolean} [secret]
155
+ * @property {number} [kids]
156
+ * @property {number} [claimed]
157
+ * @property {string} [unreadable]
158
+ */
159
+
160
+ /**
161
+ * The address one control lives at.
162
+ *
163
+ * Built from what the control IS and what it is CALLED, never from where it sits in the tree.
164
+ * A position would be stable right up until somebody adds a control above it, at which point
165
+ * every control below it would report as changed and the one real difference would be buried
166
+ * in the noise. GTK and Qt both leave most containers unnamed — 90 of the 189 controls read on
167
+ * the demo box had no name at all — so an unnamed control falls back to its role and its depth
168
+ * plus a number, which at least moves only when the shape of the tree really moves.
169
+ *
170
+ * @param {TreeNode} node
171
+ * @param {number} index Only used when a control has no identity of its own.
172
+ * @returns {string}
173
+ */
174
+ export function controlAddress(node, index) {
175
+ const called = (node.id || node.name || '').trim();
176
+ if (called !== '') return `${node.role}:${called}`;
177
+ return `${node.role}@${node.depth}#${index}`;
178
+ }
179
+
180
+ /**
181
+ * What one control says it is, in one line, and it is the only thing compared.
182
+ *
183
+ * Deliberately excludes where it is on screen. A window that opens two pixels lower is not a
184
+ * difference anybody wants reported, and a control that MOVED without changing what it is or
185
+ * does is a pixel finding, not a meaning one. What IS included is what would make somebody say
186
+ * the product behaves differently: what it is, what it is called, whether it works, whether it
187
+ * is ticked or chosen or open, what it is set to, and what it can be asked to do.
188
+ *
189
+ * The states are filtered through `COMPARED_STATES` — see the note beside that list for which
190
+ * ones are left out and why.
191
+ *
192
+ * @param {TreeNode} node
193
+ * @returns {string}
194
+ */
195
+ export function controlMeaning(node) {
196
+ if (node.unreadable) return `${node.role}, could not be read: ${node.unreadable}`;
197
+ const parts = [node.role];
198
+ if (node.name) parts.push(`called "${node.name}"`);
199
+ const states = node.states.filter((s) => COMPARED_STATES.has(s));
200
+ if (states.length > 0) parts.push(states.join(' '));
201
+ if (node.value !== null && node.value !== undefined) parts.push(`set to ${node.value}`);
202
+ // A password box is compared on its LENGTH, never its contents. The probe replaces the text
203
+ // with a count before it leaves that machine, and this is the second half of the same
204
+ // promise: somebody else's password must never end up in a reference stored in a git repo.
205
+ if (node.text) parts.push(node.secret ? `holding ${node.text}` : `saying "${node.text}"`);
206
+ if (node.can && node.can.length > 0) parts.push(`can ${node.can.join(', ')}`);
207
+ return parts.join(', ');
208
+ }
209
+
210
+ /**
211
+ * Turn one window's tree into observations, or into a hole if the read cannot be trusted.
212
+ *
213
+ * THE CROSS-CHECK IS THE IMPORTANT PART. `walked` is how many nodes the walk actually visited
214
+ * and `shapeDisagreed` is how many of them said they had a different number of children from
215
+ * the number they handed over. Those are two code paths inside the accessibility bridge
216
+ * answering the same question, and when they disagree the tree is lying about its own shape.
217
+ * Recording its answer anyway would put something wrong into the reference and every later run
218
+ * would compare against it and agree.
219
+ *
220
+ * AND AN EMPTY TREE IS NEVER A PASS. A window that comes back with nothing in it is a window
221
+ * that has not been checked — usually an app that draws its own widgets, or one whose toolkit
222
+ * never switched its accessibility bridge on. Writing "no controls" would make the next run
223
+ * compare zero against zero and call it unchanged, which is the one outcome this tool must
224
+ * never produce.
225
+ *
226
+ * @param {object} spec
227
+ * @param {Journey} spec.journey
228
+ * @param {string} spec.window Plain name of the window, for the path.
229
+ * @param {TreeNode[]} spec.nodes
230
+ * @param {number} spec.walked
231
+ * @param {number} [spec.shapeDisagreed]
232
+ * @param {number} [spec.unreadable]
233
+ * @param {boolean} [spec.hitLimit]
234
+ * @param {boolean} [spec.settled] Did two reads in a row agree.
235
+ * @param {string} [spec.toolkit] What drew it, when the bus said.
236
+ * @returns {Observation[]}
237
+ */
238
+ export function meaningFromTree(spec) {
239
+ const { journey, window: windowName, nodes } = spec;
240
+ const head = ['screen', windowName];
241
+ const disagreed = spec.shapeDisagreed ?? 0;
242
+
243
+ if (nodes.length === 0) {
244
+ return [notCovered({
245
+ channel: 'meaning',
246
+ path: joinPath(...head, 'controls'),
247
+ reason: 'not supported here',
248
+ says: `The window "${windowName}" reported no controls at all${spec.toolkit ? ` (it was drawn with ${spec.toolkit})` : ''}. `
249
+ + 'Either it draws its own widgets and tells the accessibility bus nothing about them, or its toolkit '
250
+ + 'never switched its accessibility bridge on. Either way this window is UNCHECKED, not empty — recording '
251
+ + '"no controls" would make the next run compare nothing against nothing and agree that nothing changed.',
252
+ })];
253
+ }
254
+
255
+ if (disagreed > 0) {
256
+ return [notCovered({
257
+ channel: 'meaning',
258
+ path: joinPath(...head, 'controls'),
259
+ reason: 'not supported here',
260
+ says: `The window "${windowName}" contradicted itself: ${disagreed} of its ${nodes.length} controls said they `
261
+ + 'had a different number of children from the number they then handed over. Two ways of asking the same '
262
+ + 'question got two answers, so the tree cannot be trusted and nothing was recorded from it. This usually '
263
+ + 'means the window was still being built while it was read.',
264
+ })];
265
+ }
266
+
267
+ /** @type {Observation[]} */
268
+ const out = [];
269
+ /** @type {Map<string, number>} */
270
+ const usedNames = new Map();
271
+ nodes.forEach((node, index) => {
272
+ let address = controlAddress(node, index);
273
+ // Two controls can honestly share a name — two "Close" buttons in two panels. Number the
274
+ // repeats rather than let the second quietly overwrite the first.
275
+ const seen = usedNames.get(address) ?? 0;
276
+ usedNames.set(address, seen + 1);
277
+ if (seen > 0) address = `${address}~${seen + 1}`;
278
+ out.push(observation({
279
+ channel: 'meaning',
280
+ path: joinPath(...head, address),
281
+ value: controlMeaning(node),
282
+ says: `On "${windowName}", ${controlMeaning(node)}.`,
283
+ journey: journey.name,
284
+ surface: 'linux',
285
+ }));
286
+ });
287
+
288
+ out.push(observation({
289
+ channel: 'counters',
290
+ path: joinPath('count', windowName, 'controls'),
291
+ value: countBucket(nodes.length),
292
+ says: `"${windowName}" is showing ${nodes.length} control${nodes.length === 1 ? '' : 's'}.`,
293
+ journey: journey.name,
294
+ surface: 'linux',
295
+ }));
296
+
297
+ if ((spec.unreadable ?? 0) > 0) {
298
+ out.push(notCovered({
299
+ channel: 'meaning',
300
+ path: joinPath(...head, 'some of it'),
301
+ reason: 'not supported here',
302
+ says: `${spec.unreadable} control${spec.unreadable === 1 ? '' : 's'} on "${windowName}" would not answer when `
303
+ + 'asked what they were. They are in the report by position but their meaning is unchecked, which usually '
304
+ + 'means they were removed from the window between being listed and being read.',
305
+ }));
306
+ }
307
+ if (spec.settled === false) {
308
+ out.push(notCovered({
309
+ channel: 'meaning',
310
+ path: joinPath(...head, 'settled'),
311
+ reason: 'timed out',
312
+ says: `"${windowName}" never held still: two readings in a row never matched. What was recorded is one `
313
+ + 'snapshot of something still moving, so a difference found in it may be the movement rather than the change.',
314
+ }));
315
+ }
316
+ if (spec.hitLimit) {
317
+ out.push(notCovered({
318
+ channel: 'meaning',
319
+ path: joinPath(...head, 'all of it'),
320
+ reason: 'too big',
321
+ says: `"${windowName}" has more than ${MAX_TREE_NODES} controls, so only the first ${MAX_TREE_NODES} were `
322
+ + 'recorded. Anything past that is unchecked.',
323
+ }));
324
+ }
325
+ return out;
326
+ }
327
+
328
+ /**
329
+ * What an exit code means in plain English, and whether it is a crash.
330
+ *
331
+ * A shell reports a program killed by a signal as 128 plus the signal number, and that is the
332
+ * only way a crash is visible here: 139 is a segmentation fault, 134 is an abort, 136 is a
333
+ * floating point error. 143 is the polite one — that is this tool itself asking the app to
334
+ * close at the end of a walk, and it must never be reported as a crash.
335
+ *
336
+ * @param {string|null|undefined} code
337
+ * @returns {{crashed: boolean, says: string, value: string}}
338
+ */
339
+ export function exitMeaning(code) {
340
+ if (code === null || code === undefined || code === '') {
341
+ return { crashed: false, says: 'It was still running when the check finished, which is what a window app should do.', value: 'still running' };
342
+ }
343
+ const n = Number(code);
344
+ if (!Number.isFinite(n)) return { crashed: false, says: `It ended, and the machine recorded "${code}".`, value: String(code) };
345
+ if (n === 0) return { crashed: false, says: 'It closed on its own without an error.', value: 'closed cleanly' };
346
+ if (n === 143) return { crashed: false, says: 'It closed when this check asked it to.', value: 'closed when asked' };
347
+ if (n === 137) return { crashed: false, says: 'It had to be forced to close — it did not go when asked.', value: 'forced to close' };
348
+ if (n > 128) {
349
+ const names = { 131: 'quit', 134: 'abort', 136: 'a floating point error', 139: 'a segmentation fault', 141: 'a broken pipe' };
350
+ const what = /** @type {Record<number, string>} */ (names)[n] ?? `signal ${n - 128}`;
351
+ return { crashed: true, says: `It CRASHED: ${what} killed it.`, value: `crashed (${what})` };
352
+ }
353
+ return { crashed: true, says: `It exited with an error, code ${n}.`, value: `exited ${n}` };
354
+ }
355
+
356
+ /**
357
+ * Programs the app started, as observations.
358
+ *
359
+ * Everything carrying this run's marker in its environment is something running because this
360
+ * app ran. The command line is kept and compared, because "it now launches the updater with a
361
+ * different flag" is exactly the kind of change no screenshot has ever caught.
362
+ *
363
+ * @param {Journey} journey
364
+ * @param {{name: string, pid: number, ppid: number, cmd: string}[]} procs
365
+ * @param {number} wrapperPid The little shell this tool put around the app.
366
+ * @param {number|null} appPid The app itself.
367
+ * @returns {Observation[]}
368
+ */
369
+ export function spawnedObservations(journey, procs, wrapperPid, appPid) {
370
+ // The wrapper is ours and the app is the thing under test. Neither is "a program it
371
+ // started", and counting them would report the same two every run as if they were findings.
372
+ const children = procs.filter((p) => p.pid !== wrapperPid && p.pid !== appPid);
373
+ /** @type {Observation[]} */
374
+ const out = children
375
+ .map((p) => ({ name: p.name, cmd: p.cmd || '(no command line visible)' }))
376
+ .sort((a, b) => (a.name + a.cmd < b.name + b.cmd ? -1 : 1))
377
+ .map((p, index) => observation({
378
+ channel: 'effects',
379
+ path: joinPath('proc', journey.name, `${p.name}#${index}`),
380
+ value: p.cmd,
381
+ says: `It started ${p.name}. That is a program running because this app ran.`,
382
+ journey: journey.name,
383
+ surface: 'linux',
384
+ }));
385
+ out.push(observation({
386
+ channel: 'counters',
387
+ path: joinPath('count', journey.name, 'programs started'),
388
+ value: countBucket(children.length),
389
+ says: `It started ${children.length} other program${children.length === 1 ? '' : 's'}.`,
390
+ journey: journey.name,
391
+ surface: 'linux',
392
+ }));
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * What changed on disk in the folders we were told to watch.
398
+ *
399
+ * Sizes rather than contents, because reading every file back over an ssh connection would
400
+ * cost more than the whole rest of the run. A file that changed size changed; a file rewritten
401
+ * with the same length is missed, and that is said out loud rather than hidden.
402
+ *
403
+ * @param {Journey} journey
404
+ * @param {Record<string, Record<string, number>>} before
405
+ * @param {Record<string, Record<string, number>>} after
406
+ * @returns {Observation[]}
407
+ */
408
+ export function fileObservations(journey, before, after) {
409
+ /** @type {Observation[]} */
410
+ const out = [];
411
+ for (const dir of Object.keys(after).sort()) {
412
+ const was = before[dir] ?? {};
413
+ const now = after[dir] ?? {};
414
+ const names = [...new Set([...Object.keys(was), ...Object.keys(now)])].sort();
415
+ let touched = 0;
416
+ for (const name of names) {
417
+ const oldSize = was[name];
418
+ const newSize = now[name];
419
+ if (oldSize === newSize) continue;
420
+ touched++;
421
+ out.push(observation({
422
+ channel: 'effects',
423
+ path: joinPath('file', journey.name, name),
424
+ value: newSize === undefined
425
+ ? 'deleted'
426
+ : oldSize === undefined ? `written, ${sizeBucket(newSize)}` : `changed to ${sizeBucket(newSize)}`,
427
+ says: newSize === undefined
428
+ ? `It deleted ${name}.`
429
+ : oldSize === undefined
430
+ ? `It wrote ${name}, ${sizeBucket(newSize)}.`
431
+ : `It changed ${name}; it is now ${sizeBucket(newSize)}.`,
432
+ journey: journey.name,
433
+ surface: 'linux',
434
+ }));
435
+ }
436
+ out.push(observation({
437
+ channel: 'counters',
438
+ path: joinPath('count', journey.name, 'files touched'),
439
+ value: countBucket(touched),
440
+ says: `${touched} file${touched === 1 ? '' : 's'} changed under ${dir}.`,
441
+ journey: journey.name,
442
+ surface: 'linux',
443
+ }));
444
+ }
445
+ out.push(notCovered({
446
+ channel: 'effects',
447
+ path: joinPath('file', journey.name, 'everywhere else'),
448
+ reason: 'missing tool',
449
+ says: 'Only the folders this check was told to watch were compared. Watching everything a program writes on '
450
+ + 'Linux needs a kernel audit rule or a filesystem trace, and both need root on somebody\'s own desktop, '
451
+ + 'so a file written anywhere else was not seen. That is a hole, not a clean result.',
452
+ }));
453
+ return out;
454
+ }
455
+
456
+ /**
457
+ * Everything it wrote to its error output, and how it ended.
458
+ *
459
+ * A Linux desktop app does not have a Windows event log to complain into; it complains to its
460
+ * own error output, which this tool captures because it started the app. That is more useful
461
+ * than the system log and it needs no privileges to read.
462
+ *
463
+ * @param {Journey} journey
464
+ * @param {object} spec
465
+ * @param {string} spec.complained What went to its error output.
466
+ * @param {boolean} [spec.cut] Was that truncated.
467
+ * @param {string|null} [spec.exit] Exit code, when it has ended.
468
+ * @returns {Observation[]}
469
+ */
470
+ export function complaintObservations(journey, spec) {
471
+ const text = (spec.complained ?? '').trim();
472
+ const lines = text === '' ? [] : text.split('\n');
473
+ const ending = exitMeaning(spec.exit);
474
+ /** @type {Observation[]} */
475
+ const out = [];
476
+
477
+ out.push(observation({
478
+ channel: 'complaints',
479
+ path: joinPath('log', journey.name, 'complained'),
480
+ value: trimForStorage(text, 16 * 1024).text,
481
+ says: lines.length === 0
482
+ ? 'It complained about nothing while it ran.'
483
+ : `It wrote ${lines.length} line${lines.length === 1 ? '' : 's'} to its error output while it ran.`,
484
+ journey: journey.name,
485
+ surface: 'linux',
486
+ }));
487
+ out.push(observation({
488
+ channel: 'counters',
489
+ path: joinPath('count', journey.name, 'complaints'),
490
+ value: countBucket(lines.length),
491
+ says: `It complained ${lines.length} time${lines.length === 1 ? '' : 's'}.`,
492
+ journey: journey.name,
493
+ surface: 'linux',
494
+ }));
495
+ out.push(observation({
496
+ channel: 'complaints',
497
+ path: joinPath('proc', journey.name, 'how it ended'),
498
+ value: ending.value,
499
+ says: ending.says,
500
+ journey: journey.name,
501
+ surface: 'linux',
502
+ }));
503
+ if (spec.cut) {
504
+ out.push(notCovered({
505
+ channel: 'complaints',
506
+ path: joinPath('log', journey.name, 'the rest of it'),
507
+ reason: 'too big',
508
+ says: 'It complained more than was kept, so only the first part is compared. A message that only appears '
509
+ + 'further down was not looked at.',
510
+ }));
511
+ }
512
+ out.push(notCovered({
513
+ channel: 'complaints',
514
+ path: joinPath('log', journey.name, 'core dumps'),
515
+ reason: 'missing tool',
516
+ says: 'A crash is recognised here by the signal that killed the program, which is reliable. The core dump '
517
+ + 'file itself is not collected: where Linux puts one is a machine-wide setting this check is not allowed '
518
+ + 'to read on somebody else\'s desktop.',
519
+ }));
520
+ return out;
521
+ }
522
+
523
+ /**
524
+ * What it printed, as observations.
525
+ *
526
+ * @param {Journey} journey
527
+ * @param {string} printed
528
+ * @param {boolean} [cut]
529
+ * @returns {Observation[]}
530
+ */
531
+ export function printedObservations(journey, printed, cut = false) {
532
+ /** @type {Observation[]} */
533
+ const out = [observation({
534
+ channel: 'results',
535
+ path: joinPath('printed', journey.name),
536
+ value: trimForStorage(printed ?? '', 16 * 1024).text,
537
+ says: (printed ?? '').trim() === ''
538
+ ? 'It printed nothing, which is normal for a window app.'
539
+ : 'This is what it printed while it was open.',
540
+ journey: journey.name,
541
+ surface: 'linux',
542
+ })];
543
+ if (cut) {
544
+ out.push(notCovered({
545
+ channel: 'results',
546
+ path: joinPath('printed', journey.name, 'the rest of it'),
547
+ reason: 'too big',
548
+ says: 'It printed more than was kept, so only the first part is compared.',
549
+ }));
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /**
555
+ * Connections the app had open, plus the honest note about what sampling misses.
556
+ *
557
+ * @param {Journey} journey
558
+ * @param {{remote: string, state: string}[]} conns
559
+ * @returns {Observation[]}
560
+ */
561
+ export function networkObservations(journey, conns) {
562
+ const reachable = conns
563
+ .map((c) => c.remote)
564
+ .filter((where) => !where.startsWith('0.0.0.0:') && !where.startsWith('127.0.0.1:') && !where.startsWith('[00000000'))
565
+ .sort();
566
+ const unique = [...new Set(reachable)];
567
+ /** @type {Observation[]} */
568
+ const out = unique.map((where, index) => observation({
569
+ channel: 'effects',
570
+ path: joinPath('net', journey.name, String(index)),
571
+ value: where,
572
+ says: `While it was running it had a connection open to ${where}.`,
573
+ journey: journey.name,
574
+ surface: 'linux',
575
+ }));
576
+ out.push(notCovered({
577
+ channel: 'effects',
578
+ path: joinPath('net', journey.name, 'everything it asked for'),
579
+ reason: 'missing tool',
580
+ says: 'Connections were sampled while the app ran, not captured. A request that opened and finished between '
581
+ + 'two samples was not seen, and nothing here could have stopped one — refusing a compiled program\'s '
582
+ + 'network call needs root on that desktop, which this does not have and should not want.',
583
+ }));
584
+ return out;
585
+ }
586
+
587
+ // ---------------------------------------------------------------------------
588
+ // Finding the app
589
+ // ---------------------------------------------------------------------------
590
+
591
+ /**
592
+ * Where the Linux build is, and whether it is already on the far machine.
593
+ *
594
+ * Two honest modes, and which one a project is in changes what a run costs by minutes:
595
+ *
596
+ * `there` The config names a path that already exists on the Linux machine. Free.
597
+ * `push` The build is here and has to be copied over. Real, and reported with the time it
598
+ * took, because a person who does not know that a run copies 200 megabytes over ssh
599
+ * every time will reasonably conclude the tool is broken when it takes four minutes.
600
+ *
601
+ * @param {AdapterProject} project
602
+ * @returns {{mode: 'there'|'push'|'none', exe: string|null, local: string|null, why: string}}
603
+ */
604
+ export function findLinuxBuild(project) {
605
+ const config = project.config ?? {};
606
+ if (typeof config.remoteExe === 'string' && config.remoteExe.trim() !== '') {
607
+ return {
608
+ mode: 'there',
609
+ exe: config.remoteExe,
610
+ local: null,
611
+ why: `The Linux build is already on that machine at ${config.remoteExe}, so nothing is copied.`,
612
+ };
613
+ }
614
+ if (typeof config.exe === 'string' && config.exe.trim() !== '') {
615
+ const local = path.isAbsolute(config.exe) ? config.exe : path.join(project.root, config.exe);
616
+ return {
617
+ mode: 'push',
618
+ exe: null,
619
+ local,
620
+ why: `The Linux build is here at ${local} and has to be copied to the Linux machine before each run.`,
621
+ };
622
+ }
623
+ return {
624
+ mode: 'none',
625
+ exe: null,
626
+ local: null,
627
+ why: 'No Linux build was named, so there is nothing to open.',
628
+ };
629
+ }
630
+
631
+ /**
632
+ * Is this really a native app, or a Chromium shell wearing a Linux window frame.
633
+ * @param {string} toolkit What the accessibility bus said drew it.
634
+ * @returns {boolean}
635
+ */
636
+ export function isChromiumToolkit(toolkit) {
637
+ return CHROMIUM_TOOLKITS.includes(String(toolkit ?? '').trim().toLowerCase());
638
+ }
639
+
640
+ /**
641
+ * Copy a build over to the far machine, and say how long it took.
642
+ *
643
+ * Streamed through tar rather than scp so it is one connection and one pass, and so a folder
644
+ * of thousands of small files does not become thousands of round trips at a second each. This
645
+ * is the same shape as the Windows adapter's copy and for the same reasons; both are here
646
+ * rather than shared because the two land the build in different places and only one of them
647
+ * has to worry about a UNC path.
648
+ *
649
+ * @param {string} host
650
+ * @param {string} localDir
651
+ * @param {string} remoteDir
652
+ * @returns {Promise<{ok: boolean, ms: number, why: string}>}
653
+ */
654
+ export async function pushBuild(host, localDir, remoteDir) {
655
+ const started = Date.now();
656
+ const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
657
+ const ssh = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host,
658
+ `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
659
+ let trouble = '';
660
+ tar.stdout.pipe(ssh.stdin);
661
+ ssh.stderr.on('data', (d) => { trouble += String(d); });
662
+ tar.stderr.on('data', (d) => { trouble += String(d); });
663
+ // Nothing wants what ssh prints on its way through, and that is exactly why it has to be
664
+ // read. A pipe nobody empties fills up, and a full pipe blocks its writer for ever.
665
+ ssh.stdout?.resume();
666
+ ssh.on('error', () => { try { tar.kill('SIGKILL'); } catch { /* already gone */ } });
667
+ tar.on('error', () => { try { ssh.kill('SIGKILL'); } catch { /* already gone */ } });
668
+
669
+ const ended = await endOfChild(ssh, { limitMs: 30 * 60_000, what: `the copy of this build to ${host}` });
670
+ try { tar.kill('SIGKILL'); } catch { /* it finished on its own */ }
671
+ letGoOf(tar);
672
+
673
+ const ms = Date.now() - started;
674
+ if (ended.gaveUp) return { ok: false, ms, why: `${ended.why} ${trouble.trim().slice(0, 200)}`.trim() };
675
+ if (ended.code === 0) return { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` };
676
+ return { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `the copy ended with ${ended.code ?? ended.signal}`}` };
677
+ }
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // The adapter
681
+ // ---------------------------------------------------------------------------
682
+
683
+ /** The one connection this adapter holds while a run is going on. */
684
+ let link = /** @type {import('../remote.js').RemoteRunner|null} */ (null);
685
+
686
+ /** Everything this run started over there, so teardown can put it back and nothing else. */
687
+ /** @type {{pid: number, run: string, exitFile: string, folder: string}[]} */
688
+ let startedHere = [];
689
+
690
+ /**
691
+ * Open the Linux machine, once, and keep it.
692
+ * @param {string} host
693
+ * @param {(m: string) => void} [log]
694
+ */
695
+ async function connect(host, log) {
696
+ if (link && link.alive) return link;
697
+ link = remoteRunner({ host, kind: 'posix', surface: 'linux', log });
698
+ await link.open();
699
+ return link;
700
+ }
701
+
702
+ /**
703
+ * What the probe should be told about the desktop, out of the project's config.
704
+ * @param {Record<string, any>} config
705
+ * @returns {{env?: Record<string, string>, envFile?: string}}
706
+ */
707
+ function sessionFrom(config) {
708
+ /** @type {{env?: Record<string, string>, envFile?: string}} */
709
+ const out = {};
710
+ /** @type {Record<string, string>} */
711
+ const env = {};
712
+ if (typeof config.display === 'string') env.DISPLAY = config.display;
713
+ if (typeof config.sessionBus === 'string') env.DBUS_SESSION_BUS_ADDRESS = config.sessionBus;
714
+ if (Object.keys(env).length > 0) out.env = env;
715
+ if (typeof config.envFile === 'string') out.envFile = config.envFile;
716
+ return out;
717
+ }
718
+
719
+ export const linuxAdapter = defineAdapter({
720
+ name: 'linux',
721
+ title: 'native Linux desktop apps',
722
+ describe:
723
+ 'Opens a native Linux program on a real desktop reached over ssh, reads what every control on screen says it '
724
+ + 'is and does through the accessibility bus every screen reader already uses, and watches what it starts, '
725
+ + 'writes, prints and complains about. It cannot run two builds at once — a desktop has one screen — and it '
726
+ + 'declines Electron apps, which are covered better and in pairs over their debug port.',
727
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
728
+
729
+ /**
730
+ * @param {AdapterProject} project
731
+ * @returns {Promise<Detection>}
732
+ */
733
+ async detect(project) {
734
+ const config = project.config ?? {};
735
+ const host = typeof config.host === 'string' ? config.host : null;
736
+ const build = findLinuxBuild(project);
737
+ /** @type {Missing[]} */
738
+ const missing = [];
739
+
740
+ if (!host) {
741
+ missing.push({
742
+ what: 'the name of a machine with a Linux desktop session on it',
743
+ unlocks: 'checking a native Linux app at all — a Linux window can only be read from the desktop it is on',
744
+ howToGet: 'Put {"host": "the-ssh-host-name"} under "linux" in the config. Any ssh host that gets you a shell '
745
+ + 'on a Linux machine where somebody is logged in to a desktop works. Nothing needs installing on it.',
746
+ blocking: true,
747
+ });
748
+ }
749
+ if (build.mode === 'none') {
750
+ missing.push({
751
+ what: 'the built Linux program',
752
+ unlocks: 'opening the app and reading what is on its screen',
753
+ howToGet: 'Either put {"remoteExe": "/opt/yourapp/yourapp"} under "linux" in the config if the build already '
754
+ + 'lives on that machine — much faster — or {"exe": "dist/linux/yourapp"} to have it copied over before '
755
+ + 'each run.',
756
+ blocking: true,
757
+ });
758
+ }
759
+ if (!Array.isArray(config.watchDirs) || config.watchDirs.length === 0) {
760
+ missing.push({
761
+ what: 'the folders this app writes into',
762
+ unlocks: 'seeing what it saved, which is otherwise invisible — watching the whole disk needs root on that '
763
+ + 'desktop, which this does not have and should not want',
764
+ howToGet: 'Put {"watchDirs": ["/home/you/.config/yourapp"]} under "linux" in the config.',
765
+ });
766
+ }
767
+
768
+ let electronish = false;
769
+ try {
770
+ const pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8'));
771
+ electronish = Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron || pkg.build?.appId);
772
+ } catch { /* a built app somebody pointed at need not have a package.json */ }
773
+
774
+ if (electronish) {
775
+ return {
776
+ applies: false,
777
+ confidence: 0,
778
+ why: 'This is an Electron app, so the Electron adapter covers its Linux build properly — over the debug '
779
+ + 'port, from any machine, with two builds able to run side by side. This adapter would be strictly '
780
+ + 'worse: one build at a time, on one shared desktop, and switching on Chromium\'s accessibility engine '
781
+ + 'to read the window would change the timing of the very thing being measured.',
782
+ missing: [],
783
+ notes: ['Nothing is missing. There is simply a better tool for this app already in the box.'],
784
+ };
785
+ }
786
+
787
+ const applies = Boolean(host) && build.mode !== 'none';
788
+ return {
789
+ applies,
790
+ confidence: applies ? 0.9 : 0,
791
+ why: applies
792
+ ? `${build.why} It will be opened on the desktop behind "${host}", read through the accessibility bus, and `
793
+ + 'closed again — one build at a time, because a desktop has one screen and two cannot be up at once.'
794
+ : 'A native Linux app needs a machine with a desktop session and a built program, and one of those is not '
795
+ + 'named yet.',
796
+ missing,
797
+ notes: [
798
+ 'Nothing is installed on the Linux machine. The program that reads the screen is sent down the ssh '
799
+ + 'connection each run and disappears when it closes.',
800
+ 'The screen, the session bus and the accessibility bus are found by reading them out of the desktop session '
801
+ + 'already running there, so no configuration is needed on a machine somebody is logged in to.',
802
+ 'Two builds can never run at the same time here. That is a property of a desktop, not of this tool, and it '
803
+ + 'makes the same-machine comparison weaker on this platform than on any other.',
804
+ 'A machine with nobody logged in to a desktop has no accessibility bus at all. That is reported as nothing '
805
+ + 'to read, never as an app with no controls.',
806
+ 'Nothing irreversible can be stopped here, so a journey marked irreversible is refused outright rather than '
807
+ + 'walked carefully.',
808
+ ],
809
+ };
810
+ },
811
+
812
+ /**
813
+ * @param {AdapterProject} project
814
+ * @returns {Promise<Journey[]>}
815
+ */
816
+ async journeys(project) {
817
+ const config = project.config ?? {};
818
+ const build = findLinuxBuild(project);
819
+ if (build.mode === 'none') return [];
820
+
821
+ /** @type {Journey[]} */
822
+ const journeys = [{
823
+ name: 'open-the-app',
824
+ describe: 'open the Linux app and read every control it puts on screen',
825
+ source: 'code',
826
+ surface: 'linux',
827
+ from: 'the built program named in the config',
828
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
829
+ steps: [{ act: 'launch' }, { act: 'settle' }, { act: 'read' }],
830
+ timeoutMs: 120_000,
831
+ }];
832
+
833
+ // Anything else has to be described by somebody who knows the app. Read out of the config
834
+ // rather than invented here: an adapter that guesses which buttons to press on an unknown
835
+ // native program is an adapter that will one day press "Delete account".
836
+ for (const extra of Array.isArray(config.journeys) ? config.journeys : []) {
837
+ if (!extra || typeof extra.name !== 'string') continue;
838
+ journeys.push({
839
+ name: extra.name,
840
+ describe: typeof extra.describe === 'string' ? extra.describe : `walk "${extra.name}"`,
841
+ source: 'recorded',
842
+ surface: 'linux',
843
+ from: 'the project config',
844
+ channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
845
+ steps: Array.isArray(extra.steps) ? extra.steps : [],
846
+ irreversible: Boolean(extra.irreversible),
847
+ timeoutMs: 120_000,
848
+ });
849
+ }
850
+ return journeys;
851
+ },
852
+
853
+ /**
854
+ * @param {Build} build
855
+ * @param {RunContext} ctx
856
+ * @returns {Promise<PreparedBuild>}
857
+ */
858
+ async prepare(build, ctx) {
859
+ const config = ctx.config ?? {};
860
+ const host = typeof config.host === 'string' ? config.host : null;
861
+ if (!host) {
862
+ return { build, root: build.root, ready: false, why: 'No Linux machine is named in the config, so there is nowhere to open this.', dispose: async () => {} };
863
+ }
864
+ const where = findLinuxBuild({ root: build.root, config });
865
+ if (where.mode === 'none') {
866
+ return { build, root: build.root, ready: false, why: where.why, dispose: async () => {} };
867
+ }
868
+
869
+ let runner;
870
+ try {
871
+ runner = await connect(host, ctx.log);
872
+ } catch (error) {
873
+ return {
874
+ build,
875
+ root: build.root,
876
+ ready: false,
877
+ why: error instanceof RemoteLinkLost
878
+ ? `${error.message}. Nothing was checked on Linux.`
879
+ : `Could not reach ${host}: ${String(error)}`,
880
+ dispose: async () => {},
881
+ };
882
+ }
883
+
884
+ // Ask the machine what it has before anything is copied to it. A machine with no desktop
885
+ // session has no accessibility bus, and finding that out after a four-minute copy is a
886
+ // waste of somebody's afternoon.
887
+ let hello;
888
+ try {
889
+ hello = await askLinux(runner, 'hello', sessionFrom(config), { timeoutMs: 45_000 });
890
+ } catch (error) {
891
+ return {
892
+ build,
893
+ root: build.root,
894
+ ready: false,
895
+ why: `${host} answered, but nothing there could tell us about its desktop: `
896
+ + `${error instanceof Error ? error.message : String(error)}.`,
897
+ dispose: async () => {},
898
+ };
899
+ }
900
+
901
+ if (hello.screenFound === false) {
902
+ return {
903
+ build,
904
+ root: build.root,
905
+ ready: false,
906
+ why: `${host} has a user session on it but no screen, so there is no desktop there to open anything on. `
907
+ + 'That is not the same as an app with nothing on its screen, and it must never be reported as one: a '
908
+ + 'machine like this still answers when asked for an accessibility bus, and starts an empty one to do it. '
909
+ + 'Log in to a desktop on that machine and leave the session running, or name a machine that has one.',
910
+ dispose: async () => {},
911
+ };
912
+ }
913
+ if (!hello.session?.DBUS_SESSION_BUS_ADDRESS) {
914
+ return {
915
+ build,
916
+ root: build.root,
917
+ ready: false,
918
+ why: `There is no desktop session on ${host}. A Linux window can only be read from a desktop somebody is `
919
+ + 'logged in to, and this machine has none — so there is nothing here to check, which is different from '
920
+ + 'an app with nothing on its screen. Log in on that machine once and leave the session running, or name '
921
+ + 'a machine that has one.',
922
+ dispose: async () => {},
923
+ };
924
+ }
925
+ if (hello.dbusModule === false) {
926
+ return {
927
+ build,
928
+ root: build.root,
929
+ ready: false,
930
+ why: `${host} has a desktop, but the small Python module this reads the screen with is not installed there. `
931
+ + 'One line fixes it, on that machine: sudo apt-get install -y python3-dbus (or the equivalent for that '
932
+ + 'distribution). Nothing else is needed.',
933
+ dispose: async () => {},
934
+ };
935
+ }
936
+ if (!hello.bus) {
937
+ return {
938
+ build,
939
+ root: build.root,
940
+ ready: false,
941
+ why: `${host} has a desktop session but its accessibility bus would not answer: ${hello.why ?? 'no reason given'}. `
942
+ + 'On most desktops it starts on its own the first time something asks for it; on a few it has to be '
943
+ + 'switched on once, with: gsettings set org.gnome.desktop.interface toolkit-accessibility true',
944
+ dispose: async () => {},
945
+ };
946
+ }
947
+
948
+ let exe = where.exe;
949
+ /** @type {string[]} */
950
+ const notes = [];
951
+ if (where.mode === 'push' && where.local) {
952
+ const remoteDir = `/tmp/staysfixed-${build.id}`;
953
+ const pushed = await pushBuild(host, path.dirname(where.local), remoteDir);
954
+ if (!pushed.ok) {
955
+ return { build, root: build.root, ready: false, why: pushed.why, dispose: async () => {} };
956
+ }
957
+ notes.push(pushed.why);
958
+ exe = path.posix.join(remoteDir, path.basename(path.dirname(where.local)), path.basename(where.local));
959
+ notes.push('Starting a program from a copied folder is slower than one already on that machine. Naming '
960
+ + '"remoteExe" instead, once, removes this from every run.');
961
+ }
962
+
963
+ return {
964
+ build,
965
+ root: build.root,
966
+ ready: Boolean(exe),
967
+ why: exe ? `${where.why} ${notes.join(' ')}`.trim() : 'The Linux program could not be placed on that machine.',
968
+ facts: {
969
+ exe: exe ?? undefined,
970
+ host,
971
+ desktop: typeof hello.screen === 'string' ? hello.screen : undefined,
972
+ wayland: Boolean(hello.wayland),
973
+ distro: typeof hello.distro === 'string' ? hello.distro : undefined,
974
+ },
975
+ dispose: async () => { /* nothing was installed, so there is nothing to undo */ },
976
+ };
977
+ },
978
+
979
+ /**
980
+ * @param {Journey} journey
981
+ * @param {PreparedBuild} prepared
982
+ * @param {RunContext} ctx
983
+ * @returns {Promise<Observation[]>}
984
+ */
985
+ async run(journey, prepared, ctx) {
986
+ const config = ctx.config ?? {};
987
+ const exe = String(prepared.facts?.exe ?? '');
988
+ const host = String(prepared.facts?.host ?? '');
989
+ const session = sessionFrom(config);
990
+
991
+ if (!prepared.ready || !exe) {
992
+ return [notCovered({
993
+ channel: 'meaning',
994
+ path: joinPath('screen', journey.name, 'anything at all'),
995
+ reason: 'missing tool',
996
+ says: `"${journey.describe}" was not walked: ${prepared.why}`,
997
+ })];
998
+ }
999
+
1000
+ // Refused outright rather than walked carefully. There is no wire boundary here without
1001
+ // root on somebody's own desktop, so "watch it ask and stop it" is not available, and a
1002
+ // careful walk of an irreversible journey is a walk that really does the irreversible thing.
1003
+ if (journey.irreversible) {
1004
+ return [notCovered({
1005
+ channel: 'effects',
1006
+ path: joinPath('screen', journey.name, 'refused'),
1007
+ reason: 'irreversible',
1008
+ says: `"${journey.describe}" would spend money, send a message or destroy data, and on a Linux desktop there `
1009
+ + 'is no way to let it ask and then stop it — that needs root on a machine somebody else is using. It was '
1010
+ + 'not run at all. This is a hole in what was checked, not a pass.',
1011
+ })];
1012
+ }
1013
+
1014
+ let runner;
1015
+ try {
1016
+ runner = await connect(host, ctx.log);
1017
+ } catch (error) {
1018
+ return [notCovered({
1019
+ channel: 'meaning',
1020
+ path: joinPath('screen', journey.name, 'anything at all'),
1021
+ reason: 'timed out',
1022
+ says: `"${journey.describe}" was not walked: ${error instanceof Error ? error.message : String(error)}.`,
1023
+ })];
1024
+ }
1025
+
1026
+ /** @type {Observation[]} */
1027
+ const seen = [];
1028
+ /** @type {string[]} */
1029
+ const watchDirs = Array.isArray(config.watchDirs) ? config.watchDirs.map(String) : [];
1030
+ // One marker per journey per build, so two runs of the same product on the same desktop
1031
+ // can never claim each other's processes.
1032
+ const runId = `${prepared.build.id}-${journey.name}`.replace(/[^a-z0-9-]+/gi, '-').slice(0, 60);
1033
+ /** @type {Record<string, any>|null} */
1034
+ let launched = null;
1035
+
1036
+ try {
1037
+ const before = watchDirs.length > 0
1038
+ ? await askLinux(runner, 'snap', { ...session, dirs: watchDirs }, { timeoutMs: 90_000 })
1039
+ : { dirs: {} };
1040
+
1041
+ launched = await askLinux(runner, 'launch', {
1042
+ ...session,
1043
+ run: runId,
1044
+ exe,
1045
+ args: Array.isArray(config.args) ? config.args : [],
1046
+ cwd: typeof config.cwd === 'string' ? config.cwd : undefined,
1047
+ extraEnv: typeof config.env === 'object' && config.env ? config.env : undefined,
1048
+ }, { timeoutMs: 60_000 });
1049
+ if (!launched.ok) {
1050
+ return [notCovered({
1051
+ channel: 'meaning',
1052
+ path: joinPath('screen', journey.name, 'anything at all'),
1053
+ reason: 'crashed',
1054
+ says: `The app would not start on ${host}: ${launched.error}.`,
1055
+ })];
1056
+ }
1057
+ startedHere.push({
1058
+ pid: Number(launched.pid), run: runId,
1059
+ exitFile: String(launched.exitFile), folder: String(launched.folder),
1060
+ });
1061
+
1062
+ // Wait for a window rather than sleeping a fixed time. A machine under load takes longer,
1063
+ // and a fixed sleep would turn that into a difference in the report.
1064
+ /** @type {{bus: string, path: string, role: string, name: string, app: string, toolkit: string, pid: number, box: {x: number, y: number, w: number, h: number}|null, showing: boolean}[]} */
1065
+ let windows = [];
1066
+ const deadline = Date.now() + WINDOW_WAIT_MS;
1067
+ while (Date.now() < deadline) {
1068
+ const reply = await askLinux(runner, 'windows', session, { timeoutMs: 45_000 });
1069
+ const rows = Array.isArray(reply.windows) ? reply.windows : [];
1070
+ // Only the windows belonging to what this run started. Somebody else's editor is open
1071
+ // on that desktop and it is not part of this product.
1072
+ const ours = await askLinux(runner, 'after', {
1073
+ ...session, run: runId, pid: launched.pid, exitFile: launched.exitFile,
1074
+ }, { timeoutMs: 45_000 });
1075
+ const mine = new Set((ours.procs ?? []).map((/** @type {any} */ p) => Number(p.pid)));
1076
+ windows = rows.filter((/** @type {any} */ w) => mine.has(Number(w.pid)) && w.showing);
1077
+ if (windows.length > 0) break;
1078
+ await new Promise((r) => setTimeout(r, 500));
1079
+ }
1080
+
1081
+ if (windows.length === 0) {
1082
+ seen.push(notCovered({
1083
+ channel: 'meaning',
1084
+ path: joinPath('screen', journey.name, 'a window'),
1085
+ reason: 'timed out',
1086
+ says: `The app started on ${host} but put no window on the accessibility bus within `
1087
+ + `${timeBucket(WINDOW_WAIT_MS)}. Nothing about its screen was checked. Either it is a background `
1088
+ + 'program, or it failed silently, or it draws its own widgets and tells the desktop nothing about '
1089
+ + 'them — and this cannot tell those apart, so all three are unchecked rather than empty.',
1090
+ }));
1091
+ }
1092
+
1093
+ const chromium = windows.filter((w) => isChromiumToolkit(w.toolkit));
1094
+ if (chromium.length > 0 && chromium.length === windows.length) {
1095
+ seen.push(notCovered({
1096
+ channel: 'meaning',
1097
+ path: joinPath('screen', journey.name, 'controls'),
1098
+ reason: 'not supported here',
1099
+ says: 'Every window this app opened says it was drawn by Chromium, so it is an Electron app after all. It '
1100
+ + 'is not read here: the Electron adapter covers it properly over its debug port, from any machine, '
1101
+ + 'with two builds able to run at once.',
1102
+ }));
1103
+ } else {
1104
+ for (const window of windows.filter((w) => !isChromiumToolkit(w.toolkit))) {
1105
+ const label = window.name || `${window.app} ${window.role}` || 'a window with no title';
1106
+ const tree = await askLinux(runner, 'settle', {
1107
+ ...session, bus: window.bus, path: window.path, limit: MAX_TREE_NODES,
1108
+ }, { timeoutMs: 120_000 });
1109
+ if (!tree.ok) {
1110
+ seen.push(notCovered({
1111
+ channel: 'meaning',
1112
+ path: joinPath('screen', label, 'controls'),
1113
+ reason: 'crashed',
1114
+ says: `"${label}" could not be read: ${tree.error}.`,
1115
+ }));
1116
+ continue;
1117
+ }
1118
+ seen.push(...meaningFromTree({
1119
+ journey,
1120
+ window: label,
1121
+ nodes: Array.isArray(tree.nodes) ? tree.nodes : [],
1122
+ walked: Number(tree.walked ?? 0),
1123
+ shapeDisagreed: Number(tree.shapeDisagreed ?? 0),
1124
+ unreadable: Number(tree.unreadable ?? 0),
1125
+ hitLimit: Boolean(tree.hitLimit),
1126
+ settled: Boolean(tree.agreed),
1127
+ toolkit: window.toolkit,
1128
+ }));
1129
+ seen.push(observation({
1130
+ channel: 'results',
1131
+ path: joinPath('screen', label, 'title'),
1132
+ value: window.name,
1133
+ says: window.name
1134
+ ? `A window is open called "${window.name}".`
1135
+ : `A ${window.role} is open with no title on it.`,
1136
+ journey: journey.name,
1137
+ surface: 'linux',
1138
+ }));
1139
+
1140
+ // Pixels last, and only as evidence. A picture is written to the evidence folder and
1141
+ // pointed at; it is never the thing compared. Three ways out of here and all three
1142
+ // say something: a cap is a decision and has to be visible, a failure is a hole and
1143
+ // has to be named, and neither is a reason to lose the rest of the walk.
1144
+ const shot = await askLinux(runner, 'shot', { ...session, box: window.box ?? {} }, { timeoutMs: 60_000 });
1145
+ const bytes = shot.png ? Math.floor(String(shot.png).length * 0.75) : 0;
1146
+ const tooBig = shot.ok === true && bytes > MAX_SHOT_BYTES;
1147
+ if (!shot.ok || !shot.png || tooBig) {
1148
+ seen.push(notCovered({
1149
+ channel: 'pixels',
1150
+ path: joinPath('screen', label, 'picture'),
1151
+ reason: tooBig ? 'too big' : 'missing tool',
1152
+ says: tooBig
1153
+ ? `The picture of "${label}" came back at ${sizeBucket(bytes)}, over the ${sizeBucket(MAX_SHOT_BYTES)} this keeps, so it was not stored. Every other channel still looked at that window; only the picture is missing.`
1154
+ : `No picture of "${label}" could be taken: ${shot.why ?? 'the desktop would not say why'}. `
1155
+ + `${prepared.facts?.wayland ? 'This desktop is Wayland, which refuses screen capture to anything that has not gone through its own permission dialogue, and nothing unattended can answer one. ' : 'One line on that machine fixes it: sudo apt-get install -y gir1.2-gtk-3.0. '}`
1156
+ + 'Every other channel still looked at that window; only the picture is missing, and a picture is '
1157
+ + 'evidence for a finding rather than a finding of its own.',
1158
+ }));
1159
+ } else {
1160
+ const file = path.join(ctx.evidenceDir, `linux-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
1161
+ await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
1162
+ seen.push(observation({
1163
+ channel: 'pixels',
1164
+ path: joinPath('screen', label, 'looks like'),
1165
+ value: `${shot.w} by ${shot.h}`,
1166
+ says: `A picture of "${label}" was kept as evidence. It is not compared — it is there to show a person `
1167
+ + 'something another channel already found.',
1168
+ evidence: file,
1169
+ journey: journey.name,
1170
+ surface: 'linux',
1171
+ }));
1172
+ if (Number(shot.lit) === 0) {
1173
+ seen.push(notCovered({
1174
+ channel: 'pixels',
1175
+ path: joinPath('screen', label, 'picture is usable'),
1176
+ reason: 'not supported here',
1177
+ says: 'The picture came back completely black, which is what a locked or blanked screen gives. '
1178
+ + 'Every other channel still works; only the picture is lost.',
1179
+ }));
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ seen.push(observation({
1186
+ channel: 'counters',
1187
+ path: joinPath('count', journey.name, 'windows'),
1188
+ value: countBucket(windows.length),
1189
+ says: `It put ${windows.length} window${windows.length === 1 ? '' : 's'} on screen.`,
1190
+ journey: journey.name,
1191
+ surface: 'linux',
1192
+ }));
1193
+
1194
+ const after = await askLinux(runner, 'after', {
1195
+ ...session, run: runId, pid: launched.pid, exitFile: launched.exitFile, dirs: watchDirs,
1196
+ }, { timeoutMs: 90_000 });
1197
+
1198
+ seen.push(...spawnedObservations(
1199
+ journey,
1200
+ Array.isArray(after.procs) ? after.procs : [],
1201
+ Number(launched.pid),
1202
+ after.appPid === null || after.appPid === undefined ? null : Number(after.appPid),
1203
+ ));
1204
+ seen.push(...networkObservations(journey, Array.isArray(after.conns) ? after.conns : []));
1205
+ seen.push(...printedObservations(journey, String(after.printed ?? ''), Boolean(after.printedCut)));
1206
+ seen.push(...complaintObservations(journey, {
1207
+ complained: String(after.complained ?? ''),
1208
+ cut: Boolean(after.complainedCut),
1209
+ exit: after.running ? null : (after.exit ?? null),
1210
+ }));
1211
+
1212
+ if (watchDirs.length > 0) {
1213
+ seen.push(...fileObservations(journey, before.dirs ?? {}, after.dirs ?? {}));
1214
+ } else {
1215
+ seen.push(notCovered({
1216
+ channel: 'effects',
1217
+ path: joinPath('file', journey.name, 'anything written'),
1218
+ reason: 'needs a sample',
1219
+ says: 'Nothing was watched on disk, because no folders were named. Add "watchDirs" under "linux" in the '
1220
+ + 'config and what this app saves becomes visible.',
1221
+ }));
1222
+ }
1223
+
1224
+ return seen;
1225
+ } catch (error) {
1226
+ // The machine went away part way through. Keep everything really seen, and say plainly
1227
+ // that the rest is unchecked. Never let a short run look like a clean one.
1228
+ return [...seen, notCovered({
1229
+ channel: 'meaning',
1230
+ path: joinPath('screen', journey.name, 'the rest of it'),
1231
+ reason: 'timed out',
1232
+ says: `"${journey.describe}" stopped part way through on ${host}: `
1233
+ + `${error instanceof Error ? error.message : String(error)}. Everything after that point is unchecked, `
1234
+ + 'not unchanged.',
1235
+ })];
1236
+ } finally {
1237
+ if (launched?.ok && link && link.alive) {
1238
+ const pid = Number(launched.pid);
1239
+ try {
1240
+ await askLinux(link, 'stop', {
1241
+ ...session, run: runId, pid, exitFile: launched.exitFile, folder: launched.folder,
1242
+ }, { timeoutMs: 30_000 });
1243
+ } catch { /* the link is gone; teardown says so */ }
1244
+ startedHere = startedHere.filter((p) => p.pid !== pid);
1245
+ }
1246
+ }
1247
+ },
1248
+
1249
+ /**
1250
+ * Put the machine back the way it was found.
1251
+ *
1252
+ * Only ever stops what this run started. The probe refuses any process that does not carry
1253
+ * this run's marker in its own environment, and that refusal is the last line of defence for
1254
+ * somebody's real work sitting on that desktop.
1255
+ */
1256
+ async teardown() {
1257
+ if (link) {
1258
+ for (const started of startedHere.slice()) {
1259
+ try {
1260
+ await askLinux(link, 'stop', {
1261
+ run: started.run, pid: started.pid, exitFile: started.exitFile, folder: started.folder,
1262
+ }, { timeoutMs: 15_000 });
1263
+ } catch { /* going away anyway */ }
1264
+ }
1265
+ startedHere = [];
1266
+ try { await link.close(); } catch { /* already closed */ }
1267
+ link = null;
1268
+ }
1269
+ },
1270
+ });
1271
+
1272
+ /**
1273
+ * One paragraph about what this adapter can do on a given machine, for `doctor` and for an
1274
+ * agent reading the tool's own description of itself.
1275
+ *
1276
+ * `desktop` is what `readDesktopProbe` made of the cheap `gdbus` probe — the one that needs
1277
+ * nothing installed. Passing it is optional so doctor can say something useful about a machine
1278
+ * before it has paid for the deeper look.
1279
+ *
1280
+ * @param {import('../remote.js').RemoteDescription} remote
1281
+ * @param {import('./linux-driver.js').DesktopProbe} [desktop]
1282
+ * @returns {string}
1283
+ */
1284
+ export function describeLinuxDesktop(remote, desktop) {
1285
+ if (!remote.reachable) {
1286
+ return `Nothing answers through "${remote.host}", so a native Linux app cannot be checked from here. If the `
1287
+ + 'Linux product is Electron — most desktop products are — it is already covered over its debug port and '
1288
+ + 'nothing is missing.';
1289
+ }
1290
+ if (remote.windows) {
1291
+ return `"${remote.host}" is a Windows machine, not a Linux desktop. A native Linux window can only be read from `
1292
+ + 'the desktop it is running on.';
1293
+ }
1294
+ if (!desktop) {
1295
+ return `"${remote.host}" answers over ssh. Whether it has a desktop session on it — which is what publishes the `
1296
+ + 'accessibility bus a native Linux app is read through — has not been looked at yet.';
1297
+ }
1298
+ if (!desktop.hasBus) {
1299
+ return `"${remote.host}" answers, but there is no desktop session on it. ${desktop.why} A Linux window can only `
1300
+ + 'be read from a desktop somebody is logged in to; logging in there once and leaving the session running is '
1301
+ + 'what turns this on. Locking the screen afterwards is fine — every control still reads correctly, only the '
1302
+ + 'pictures come back black.';
1303
+ }
1304
+ return `${desktop.why} Nothing has to be installed on it: the program that reads the screen is sent down the ssh `
1305
+ + 'connection each run and disappears when it closes. One build at a time, always — a desktop has one screen '
1306
+ + 'and two cannot be up at once.';
1307
+ }
1308
+
1309
+ /**
1310
+ * The cheap "is there a desktop here" probe, run over a connection somebody already has.
1311
+ *
1312
+ * Needs only `gdbus`, which every desktop Linux has, so it answers on a machine that is
1313
+ * missing the Python module the full read needs. Detect, never ask.
1314
+ *
1315
+ * @param {import('../remote.js').RemoteRunner} runner
1316
+ * @param {{display?: string, sessionBus?: string}} [env]
1317
+ * @returns {Promise<import('./linux-driver.js').DesktopProbe>}
1318
+ */
1319
+ export async function probeDesktop(runner, env = {}) {
1320
+ const result = await runner.shell(gdbusProbeCommand(env), { timeoutMs: 30_000 });
1321
+ return readDesktopProbe(result.stdout, result.stderr);
1322
+ }
1323
+
1324
+ export default linuxAdapter;