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.
- package/CHANGELOG.md +102 -0
- package/README.md +77 -19
- package/docs/design-v2.md +8 -7
- package/docs/getting-started.md +5 -3
- package/docs/how-v2-works.md +33 -10
- package/docs/mcp.md +6 -4
- package/docs/settings.md +11 -2
- package/package.json +1 -1
- package/src/guard/api.js +115 -36
- package/src/v2/adapters/android.js +220 -11
- package/src/v2/adapters/extension.js +1988 -0
- package/src/v2/adapters/ios-driver.js +95 -12
- package/src/v2/adapters/ios.js +220 -10
- package/src/v2/adapters/linux-driver.js +1028 -0
- package/src/v2/adapters/linux.js +1324 -0
- package/src/v2/adapters/macos-driver.js +913 -0
- package/src/v2/adapters/macos.js +1374 -0
- package/src/v2/browsers.js +41 -2
- package/src/v2/check.js +133 -13
- package/src/v2/cli.js +2 -0
- package/src/v2/coverage.js +1 -1
- package/src/v2/detect.js +5 -2
- package/src/v2/doctor.js +164 -20
- package/src/v2/init.js +21 -3
- package/src/v2/journeys/index.js +3 -3
- package/src/v2/journeys/record-session.js +839 -0
- package/src/v2/journeys/record.js +12 -0
- package/src/v2/mcp/tools.js +8 -15
- package/src/v2/types.js +1 -1
- package/src/v2/watch/events.js +6 -0
package/src/v2/browsers.js
CHANGED
|
@@ -532,6 +532,37 @@ function installGuards() {
|
|
|
532
532
|
}
|
|
533
533
|
}
|
|
534
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Take the throwaway profile away, and keep taking it away until it stays gone.
|
|
537
|
+
*
|
|
538
|
+
* One `rm` is a snapshot. A browser is not one process: the parent exiting says nothing about
|
|
539
|
+
* its renderers, and while they are being reaped they are still writing into the profile — so
|
|
540
|
+
* the sweep starts, a file appears behind it, the folder is not empty, and the profile
|
|
541
|
+
* outlives the run. That is the one thing "nothing it opened outlives the run" promises.
|
|
542
|
+
*
|
|
543
|
+
* The last-resort path (`killNow`) learned this and this one did not, so the polite close —
|
|
544
|
+
* which is the one every ordinary run uses — swallowed the failure with a bare `.catch()` and
|
|
545
|
+
* left the folder behind. It passed on macOS and on an idle Linux box, and failed on a loaded
|
|
546
|
+
* CI runner, which is exactly how a race behaves. Measured 2026-08-31.
|
|
547
|
+
*
|
|
548
|
+
* @param {string} home
|
|
549
|
+
* @returns {Promise<void>}
|
|
550
|
+
*/
|
|
551
|
+
async function removeStubbornly(home) {
|
|
552
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
553
|
+
try {
|
|
554
|
+
await fsp.rm(home, { recursive: true, force: true });
|
|
555
|
+
if (!fs.existsSync(home)) return;
|
|
556
|
+
} catch {
|
|
557
|
+
// A file recreated a millisecond after the sweep began. Wait for whoever wrote it to
|
|
558
|
+
// finish dying, and go round again.
|
|
559
|
+
}
|
|
560
|
+
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
|
|
561
|
+
}
|
|
562
|
+
// Still there. Untidy rather than harmful, and exactly what `staysfixed browsers --clean`
|
|
563
|
+
// is for — but never reported as success.
|
|
564
|
+
}
|
|
565
|
+
|
|
535
566
|
/**
|
|
536
567
|
* The last-resort cleanup: no promises, no awaiting, no politeness.
|
|
537
568
|
* @param {number|null} pid
|
|
@@ -648,10 +679,18 @@ function argsFor(ctx) {
|
|
|
648
679
|
// Nothing pops up, and the browser itself talks to nobody. The second half
|
|
649
680
|
// matters more than it looks: without it a run depends on somebody else's
|
|
650
681
|
// server being awake.
|
|
682
|
+
// `--disable-extensions` is dropped when the caller is deliberately loading one. Chrome
|
|
683
|
+
// accepts both flags without a word and simply loads nothing, so an extension surface
|
|
684
|
+
// asked for through this function would have walked a browser with no extension in it and
|
|
685
|
+
// reported everything about it as unchanged — a clean answer about nothing, which is the
|
|
686
|
+
// one result this tool must never produce. Written on 2026-08-31, the day the extension
|
|
687
|
+
// surface landed, before anybody could route through here and be caught by it.
|
|
688
|
+
const loadingAnExtension = (ctx.extra ?? []).some((flag) => String(flag).startsWith('--load-extension'));
|
|
689
|
+
|
|
651
690
|
args.push(
|
|
652
691
|
'--no-first-run',
|
|
653
692
|
'--no-default-browser-check',
|
|
654
|
-
'--disable-extensions',
|
|
693
|
+
...(loadingAnExtension ? [] : ['--disable-extensions']),
|
|
655
694
|
'--disable-background-networking',
|
|
656
695
|
'--disable-component-update',
|
|
657
696
|
'--disable-default-apps',
|
|
@@ -900,7 +939,7 @@ export async function openBrowser(opts = {}) {
|
|
|
900
939
|
closing ??= (async () => {
|
|
901
940
|
live.delete(id);
|
|
902
941
|
await stopProcess(child, GRACE_MS);
|
|
903
|
-
await
|
|
942
|
+
await removeStubbornly(home);
|
|
904
943
|
})();
|
|
905
944
|
return closing;
|
|
906
945
|
};
|
package/src/v2/check.js
CHANGED
|
@@ -56,6 +56,7 @@ import { sourceAdapter } from './adapters/source.js';
|
|
|
56
56
|
import { httpAdapter } from './adapters/http.js';
|
|
57
57
|
import { webAdapter } from './adapters/web.js';
|
|
58
58
|
import { electronAdapter } from './adapters/electron.js';
|
|
59
|
+
import { extensionAdapter } from './adapters/extension.js';
|
|
59
60
|
|
|
60
61
|
const exec = promisify(execFile);
|
|
61
62
|
|
|
@@ -124,7 +125,7 @@ const exec = promisify(execFile);
|
|
|
124
125
|
*/
|
|
125
126
|
|
|
126
127
|
/** The adapters compiled into every copy, in the order the engine trusts them. Reading the code is free, so it is first. */
|
|
127
|
-
const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter];
|
|
128
|
+
const BUILT_IN = [sourceAdapter, processAdapter, httpAdapter, webAdapter, electronAdapter, extensionAdapter];
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
131
|
* The platforms that arrive as a file of their own.
|
|
@@ -158,6 +159,20 @@ const SEPARATE_ADAPTERS = [
|
|
|
158
159
|
missing:
|
|
159
160
|
'This copy has no native-Windows adapter in it. That is usually fine: a Windows product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
160
161
|
},
|
|
162
|
+
{
|
|
163
|
+
surface: 'macos',
|
|
164
|
+
file: './adapters/macos.js',
|
|
165
|
+
exports: ['macosAdapter', 'adapter', 'default'],
|
|
166
|
+
missing:
|
|
167
|
+
'This copy has no native-Mac adapter in it, so nothing here can open a Swift or Objective-C app and read what is on its screen. That is usually fine: a Mac product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
surface: 'linux',
|
|
171
|
+
file: './adapters/linux.js',
|
|
172
|
+
exports: ['linuxAdapter', 'adapter', 'default'],
|
|
173
|
+
missing:
|
|
174
|
+
'This copy has no native-Linux adapter in it. That is usually fine: a Linux product built with Electron is driven over its own debugging port by the Electron adapter and needs nothing else.',
|
|
175
|
+
},
|
|
161
176
|
];
|
|
162
177
|
|
|
163
178
|
/**
|
|
@@ -193,9 +208,12 @@ export const ADAPTER_FOR_SURFACE = {
|
|
|
193
208
|
server: 'http',
|
|
194
209
|
web: 'web',
|
|
195
210
|
electron: 'electron',
|
|
211
|
+
extension: 'extension',
|
|
196
212
|
android: 'android',
|
|
197
213
|
ios: 'ios',
|
|
198
214
|
windows: 'windows',
|
|
215
|
+
linux: 'linux',
|
|
216
|
+
macos: 'macos',
|
|
199
217
|
};
|
|
200
218
|
|
|
201
219
|
/**
|
|
@@ -2377,6 +2395,65 @@ async function walkOne(req, where) {
|
|
|
2377
2395
|
};
|
|
2378
2396
|
}
|
|
2379
2397
|
|
|
2398
|
+
/**
|
|
2399
|
+
* Something that can walk one journey in this project, right now, without a whole check.
|
|
2400
|
+
*
|
|
2401
|
+
* A check is the only thing that walked a journey until 2026-08-31, and that left the
|
|
2402
|
+
* recording command with a choice between running a full check to find out whether a fresh
|
|
2403
|
+
* recording repeats — minutes, a store write, a verdict nobody asked for — or writing a
|
|
2404
|
+
* second, simpler walker of its own, which would then be the walker that never gets fixed
|
|
2405
|
+
* when the real one is. Neither is acceptable, so the walk is handed out instead: the same
|
|
2406
|
+
* adapters, the same scratch-copy-per-walk rule, and the same normalisation a real check
|
|
2407
|
+
* applies, so what a recording is judged on is exactly what a later check will see.
|
|
2408
|
+
*
|
|
2409
|
+
* The caller closes it. Everything it made lives in one throwaway folder and `close` takes
|
|
2410
|
+
* that folder away.
|
|
2411
|
+
*
|
|
2412
|
+
* @param {{cwd?: string, root?: string, configFile?: string, config?: Record<string, any>}} [options]
|
|
2413
|
+
* @returns {Promise<{root: string, config: Record<string, any>, walk: (req: WalkRequest) => Promise<Capture>, close: () => Promise<void>}>}
|
|
2414
|
+
*/
|
|
2415
|
+
export async function walkerFor(options = {}) {
|
|
2416
|
+
await loadAdapters();
|
|
2417
|
+
const root = projectRootFor(options);
|
|
2418
|
+
const config = options.config ?? (await readConfig(options.configFile ?? findConfigFile(root)));
|
|
2419
|
+
const scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-walk-'));
|
|
2420
|
+
const evidenceDir = path.join(scratch, 'evidence');
|
|
2421
|
+
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
2422
|
+
// The same rewriting a check does, and for the same reason: every walk gets its own
|
|
2423
|
+
// throwaway folder, so a product that prints where it is running from would otherwise
|
|
2424
|
+
// look different on every single walk — including the two walks that are meant to prove a
|
|
2425
|
+
// recording repeats, which would then never repeat and no recording would ever be
|
|
2426
|
+
// accepted.
|
|
2427
|
+
const rules = mergeRules(DEFAULT_RULES, [
|
|
2428
|
+
...pathRules({ root, scratch }),
|
|
2429
|
+
...(await loadRules(path.join(root, '.staysfixed', 'rules.json'))),
|
|
2430
|
+
]);
|
|
2431
|
+
return {
|
|
2432
|
+
root,
|
|
2433
|
+
config,
|
|
2434
|
+
walk: async (req) => normaliseCapture(await walkOne(req, { root, scratch, evidenceDir, config }), rules),
|
|
2435
|
+
close: async () => {
|
|
2436
|
+
await fsp.rm(scratch, { recursive: true, force: true }).catch(() => {});
|
|
2437
|
+
},
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* This project's settings, found the way a check finds them.
|
|
2443
|
+
*
|
|
2444
|
+
* Exported so that nothing else has to re-implement "walk up from here looking for a config
|
|
2445
|
+
* file, and read it whether it is JSON or a module". Two readers of one settings file that
|
|
2446
|
+
* disagree about where it is, is a bug that only shows up in somebody else's repository.
|
|
2447
|
+
*
|
|
2448
|
+
* @param {{cwd?: string, root?: string, configFile?: string}} [options]
|
|
2449
|
+
* @returns {Promise<{root: string, configFile: string|null, config: Record<string, any>}>}
|
|
2450
|
+
*/
|
|
2451
|
+
export async function settingsFor(options = {}) {
|
|
2452
|
+
const root = projectRootFor(options);
|
|
2453
|
+
const configFile = options.configFile ?? findConfigFile(root) ?? null;
|
|
2454
|
+
return { root, configFile, config: await readConfig(configFile) };
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2380
2457
|
/**
|
|
2381
2458
|
* @param {Journey} journey
|
|
2382
2459
|
* @returns {Adapter|null}
|
|
@@ -2409,19 +2486,58 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2409
2486
|
const gaps = [];
|
|
2410
2487
|
|
|
2411
2488
|
const named =
|
|
2412
|
-
options.journeys && !['code', 'config', 'suite'].includes(options.journeys) ? options.journeys : null;
|
|
2413
|
-
|
|
2414
|
-
//
|
|
2415
|
-
// the
|
|
2416
|
-
//
|
|
2489
|
+
options.journeys && !['code', 'config', 'suite', 'recorded'].includes(options.journeys) ? options.journeys : null;
|
|
2490
|
+
|
|
2491
|
+
// Sessions somebody actually performed, read back off the disk and walked like anything
|
|
2492
|
+
// else. This is the one source that knows how a person really uses the product — the four
|
|
2493
|
+
// screens they open every morning, in that order — and no amount of reading the source can
|
|
2494
|
+
// work that out, because the source only says which doors exist, never which ones anybody
|
|
2495
|
+
// opens. Until 2026-08-31 asking for it threw: the code to make a recording existed, and
|
|
2496
|
+
// nothing on the check path ever read one.
|
|
2497
|
+
//
|
|
2498
|
+
// A run that was ASKED for recorded sessions and found none stops and says so. Carrying on
|
|
2499
|
+
// with the journeys read out of the code would walk something the person did not ask for
|
|
2500
|
+
// and then report "nothing that worked has broken" — a clean answer about the wrong steps,
|
|
2501
|
+
// which is the one shape of reply this tool may never produce.
|
|
2417
2502
|
if (options.journeys === 'recorded') {
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2503
|
+
const { RECORDINGS_DIR, loadJourneyFolder, whatWillNotReplay } = await import('./journeys/record.js');
|
|
2504
|
+
const dir = path.join(root, RECORDINGS_DIR);
|
|
2505
|
+
const loaded = await loadJourneyFolder(dir);
|
|
2506
|
+
if (loaded.journeys.length === 0) {
|
|
2507
|
+
throw new StaysFixedError(
|
|
2508
|
+
`You asked for recorded sessions and there are none in ${shortPath(dir)}, so nothing was checked.`,
|
|
2509
|
+
{
|
|
2510
|
+
hint:
|
|
2511
|
+
'Make one: `staysfixed record <a-name-for-it>` opens your product, follows what you do, walks it twice to prove it repeats, and writes it there. ' +
|
|
2512
|
+
`${loaded.problems.length > 0 ? `Something is already in that folder and could not be read: ${loaded.problems.join(' ')} ` : ''}` +
|
|
2513
|
+
'Or leave --journeys out to use the steps each adapter reads from your source.',
|
|
2514
|
+
},
|
|
2515
|
+
);
|
|
2516
|
+
}
|
|
2517
|
+
for (const journey of loaded.journeys) {
|
|
2518
|
+
// Said before it is walked, not after it fails. A recording rots quietly: the ids,
|
|
2519
|
+
// ports and timestamps captured on the afternoon somebody made it go stale, and the
|
|
2520
|
+
// replay then fails for a reason that has nothing to do with the product.
|
|
2521
|
+
const willNotReplay = whatWillNotReplay(journey);
|
|
2522
|
+
if (willNotReplay.length > 0) {
|
|
2523
|
+
gaps.push({
|
|
2524
|
+
what: `The recorded session "${journey.name}" may not replay.`,
|
|
2525
|
+
why: willNotReplay.join(' '),
|
|
2526
|
+
unlockedBy: 'Record it again with `staysfixed record`, or reach the same thing from the code or the test suite, where nothing goes stale.',
|
|
2527
|
+
surface: journey.surface,
|
|
2528
|
+
});
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
for (const problem of loaded.problems) {
|
|
2532
|
+
gaps.push({
|
|
2533
|
+
what: 'A file in the recordings folder was not walked.',
|
|
2534
|
+
why: problem,
|
|
2535
|
+
unlockedBy: 'Fix that file, or record the session again. A recording nothing can read is a hole, not a pass.',
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
journeys.push(...loaded.journeys);
|
|
2424
2539
|
}
|
|
2540
|
+
|
|
2425
2541
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
2426
2542
|
|
|
2427
2543
|
// The project's own test suite, when somebody asked for it in those words and never
|
|
@@ -2501,7 +2617,11 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
2501
2617
|
continue;
|
|
2502
2618
|
}
|
|
2503
2619
|
if (!detection.applies) continue;
|
|
2504
|
-
|
|
2620
|
+
// A journeys file and a recorded session both name exactly what to walk, so no adapter
|
|
2621
|
+
// adds journeys of its own on top of them. The source reader is the exception: it runs
|
|
2622
|
+
// nothing, it cannot break anything, and it is the only channel that sees a door nobody
|
|
2623
|
+
// has ever walked through.
|
|
2624
|
+
if (adapter !== sourceAdapter && (named || options.journeys === 'recorded')) continue;
|
|
2505
2625
|
try {
|
|
2506
2626
|
journeys.push(...(await adapter.journeys(project)));
|
|
2507
2627
|
} catch (e) {
|
package/src/v2/cli.js
CHANGED
|
@@ -39,6 +39,7 @@ import { escalationBlock, escalationsFor, productFor, writeEscalations } from '.
|
|
|
39
39
|
// module is still being evaluated.
|
|
40
40
|
import { watchFlags } from '../cli/watch-flags.js';
|
|
41
41
|
import { INIT_COMMANDS } from './init.js';
|
|
42
|
+
import { RECORD_COMMANDS } from './journeys/record-session.js';
|
|
42
43
|
import { whatWasNotChecked } from './check.js';
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -143,6 +144,7 @@ export const V2_COMMANDS = {
|
|
|
143
144
|
// the new one actually does.
|
|
144
145
|
...INIT_COMMANDS,
|
|
145
146
|
...SHIP_COMMANDS,
|
|
147
|
+
...RECORD_COMMANDS,
|
|
146
148
|
|
|
147
149
|
check: {
|
|
148
150
|
summary: 'Prove nothing that already worked has changed. This is the one you run.',
|
package/src/v2/coverage.js
CHANGED
|
@@ -1223,7 +1223,7 @@ function howToCover(kind, never, harvested = false) {
|
|
|
1223
1223
|
case 'export':
|
|
1224
1224
|
return harvested
|
|
1225
1225
|
? `The project's own tests have already been harvested and they do not reach these, so nothing existing covers them. Either they are dead code worth deleting, or they need a test that calls them — starting with "${first}".`
|
|
1226
|
-
: `Write a journeys file that calls them and pass it with --journeys — starting with "${first}". (
|
|
1226
|
+
: `Write a journeys file that calls them and pass it with --journeys — starting with "${first}". (Or ask for a source that answers this for free: --journeys suite harvests the project's own tests.)`;
|
|
1227
1227
|
default:
|
|
1228
1228
|
return `Add a journey that reaches "${first}" and the ones beside it.`;
|
|
1229
1229
|
}
|
package/src/v2/detect.js
CHANGED
|
@@ -60,7 +60,10 @@ export const PRODUCT_KINDS = Object.freeze({
|
|
|
60
60
|
electron: { name: 'a desktop app built with Electron', surface: 'electron', adapter: 'electron', what: 'A desktop app. Watched by opening the built app on its own, reading its window, its menus and every private channel it registers.' },
|
|
61
61
|
ios: { name: 'an iPhone or iPad app', surface: 'ios', adapter: 'ios', what: 'An Apple app. Driven on the simulator; a real device in your hand can never be compared side by side.' },
|
|
62
62
|
android: { name: 'an Android app', surface: 'android', adapter: 'android', what: 'An Android app. Driven on an emulator against the stored record.' },
|
|
63
|
-
desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on.' },
|
|
63
|
+
desktopNative: { name: 'a native desktop app', surface: 'windows', adapter: 'windows', what: 'A desktop app that is not Electron — Swift, WinUI, Tauri, Qt. Only readable from the operating system it runs on — Windows here; see desktopNativeLinux for Linux.' },
|
|
64
|
+
extension: { name: 'a browser extension', surface: 'extension', adapter: 'extension', what: 'Something you install in a browser. Watched by reading its manifest as a contract, opening its own pages, and comparing a page with the extension loaded against the same page without it.' },
|
|
65
|
+
macNative: { name: 'a native Mac app', surface: 'macos', adapter: 'macos', what: 'A Mac app that is not Electron — Swift or Objective-C, AppKit or SwiftUI. Readable only on a Mac, one build at a time, and one person has to allow it once under Privacy & Security.' },
|
|
66
|
+
desktopNativeLinux: { name: 'a native Linux desktop app', surface: 'linux', adapter: 'linux', what: 'A Linux desktop app that is not Electron — GTK, Qt, Tauri. Read through the accessibility bus every screen reader already uses, on a machine somebody is logged in to.' },
|
|
64
67
|
container: { name: 'a containerised service', surface: 'server', adapter: 'http', what: 'A service that ships as a container. Watched the same way as any server, once there is a command that starts it.' },
|
|
65
68
|
other: { name: 'a product in a language this tool cannot drive yet', surface: 'cli', adapter: null, what: 'Recognised, named, and honestly not drivable here. It is listed so a clean run is never mistaken for full coverage.' },
|
|
66
69
|
});
|
|
@@ -596,7 +599,7 @@ async function productsIn(input) {
|
|
|
596
599
|
...(gradlew || gradle ? { buildWith: `${gradlew ? './gradlew' : 'gradle'} ${folder('app') ? ':app:assembleDebug' : 'assembleDebug'}` } : {}),
|
|
597
600
|
},
|
|
598
601
|
blockers: available.has('android')
|
|
599
|
-
? ['It runs on an emulator.
|
|
602
|
+
? ['It runs on an emulator. A snapshot restore was measured on 2026-08-31 and repeats — 301 of 309 addresses agreed across five pairs, and the eight that moved were the app\'s own identity code — so a paired run is offered. It needs a kept copy of the old build\'s APK ("reference" under "android"), because a checkout of the old commit contains no build output.']
|
|
600
603
|
: ['Nothing in this copy of the tool can drive an Android app yet. When it can, it will run on an emulator against the stored record.'],
|
|
601
604
|
});
|
|
602
605
|
}
|
package/src/v2/doctor.js
CHANGED
|
@@ -39,6 +39,8 @@ import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
|
|
|
39
39
|
import { POWERSHELL_PATHS, describeRemote } from './remote.js';
|
|
40
40
|
import { deviceToMake } from './adapters/android.js';
|
|
41
41
|
import { describeWindows } from './adapters/windows.js';
|
|
42
|
+
import { describeLinuxDesktop } from './adapters/linux.js';
|
|
43
|
+
import { describeMacos } from './adapters/macos.js';
|
|
42
44
|
import { messageOf, EXIT } from '../core/errors.js';
|
|
43
45
|
import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
|
|
44
46
|
import { loadPlaywright } from './adapters/web-driver.js';
|
|
@@ -898,9 +900,15 @@ function findDesktopApp(cwd) {
|
|
|
898
900
|
* install thirty gigabytes of Xcode is asking for work that changes nothing, and the whole
|
|
899
901
|
* design turns on never doing that.
|
|
900
902
|
*
|
|
903
|
+
* Windows is answered here too, for the same reason and by the same rule: a repository with
|
|
904
|
+
* no Windows program in it does not need a Windows machine, and saying "no Windows desktop
|
|
905
|
+
* can be reached from here" about one sends somebody looking for a machine they will never
|
|
906
|
+
* use. Only the settings can answer it — a native Windows build is not something this file
|
|
907
|
+
* can go and find in a folder.
|
|
908
|
+
*
|
|
901
909
|
* @param {string} root
|
|
902
910
|
* @param {string|null} settingsText
|
|
903
|
-
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
|
|
911
|
+
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}>}
|
|
904
912
|
*/
|
|
905
913
|
async function phoneApps(root, settingsText) {
|
|
906
914
|
// Comments taken away first, for the same reason `findDesktopApp` does it: a
|
|
@@ -917,15 +925,35 @@ async function phoneApps(root, settingsText) {
|
|
|
917
925
|
};
|
|
918
926
|
|
|
919
927
|
/**
|
|
920
|
-
* A named key
|
|
921
|
-
*
|
|
928
|
+
* A named key looked for INSIDE one settings block, never across the whole file.
|
|
929
|
+
*
|
|
930
|
+
* `remoteExe` means "the built program on that machine" under `windows:` and exactly the
|
|
931
|
+
* same thing under `linux:`, so a flat search finds one and reports it as the other — and
|
|
932
|
+
* doctor would tell somebody with a GTK app that they have a native Windows program.
|
|
933
|
+
* Written the day the Linux surface landed, 2026-08-31, before it could be true.
|
|
934
|
+
*
|
|
935
|
+
* @param {string} block
|
|
922
936
|
* @param {string} key
|
|
923
|
-
* @param {(value: string) => boolean} looksRight
|
|
937
|
+
* @param {(value: string) => boolean} [looksRight]
|
|
924
938
|
* @returns {FoundApp|null}
|
|
925
939
|
*/
|
|
926
|
-
const
|
|
927
|
-
const
|
|
928
|
-
|
|
940
|
+
const inBlock = (block, key, looksRight) => {
|
|
941
|
+
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(settings);
|
|
942
|
+
if (!at) return null;
|
|
943
|
+
let depth = 0;
|
|
944
|
+
let end = at.index + at[0].length;
|
|
945
|
+
for (; end < settings.length; end += 1) {
|
|
946
|
+
if (settings[end] === '{') depth += 1;
|
|
947
|
+
else if (settings[end] === '}') {
|
|
948
|
+
if (depth === 0) break;
|
|
949
|
+
depth -= 1;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
const inside = settings.slice(at.index + at[0].length, end);
|
|
953
|
+
const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(inside);
|
|
954
|
+
if (!found) return null;
|
|
955
|
+
if (looksRight && !looksRight(found[1])) return null;
|
|
956
|
+
return { where: found[1], how: `your settings name it under ${block}.${key}` };
|
|
929
957
|
};
|
|
930
958
|
|
|
931
959
|
/**
|
|
@@ -968,7 +996,11 @@ async function phoneApps(root, settingsText) {
|
|
|
968
996
|
// name one" about a project whose settings named one. Measured 2026-08-31 against a real
|
|
969
997
|
// TerminalDeck.app. The value has to end in `.app` so a bare `app:` belonging to some
|
|
970
998
|
// other block can never be mistaken for this one.
|
|
971
|
-
|
|
999
|
+
// Scoped to the `ios` block, not searched across the whole file. `app` means an iPhone
|
|
1000
|
+
// bundle under `ios:` and a Mac bundle under `macos:` — both end in `.app`, so a flat
|
|
1001
|
+
// search finds one and announces the other. Written the day the Mac surface landed,
|
|
1002
|
+
// 2026-08-31, before it could be true.
|
|
1003
|
+
inBlock('ios', 'app', (v) => v.endsWith('.app')) ??
|
|
972
1004
|
named('xcworkspace') ??
|
|
973
1005
|
built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
|
|
974
1006
|
(there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
|
|
@@ -977,7 +1009,11 @@ async function phoneApps(root, settingsText) {
|
|
|
977
1009
|
? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
|
|
978
1010
|
: null);
|
|
979
1011
|
|
|
980
|
-
|
|
1012
|
+
const windows = inBlock('windows', 'remoteExe') ?? inBlock('windows', 'exe', (v) => /\.exe$/i.test(v));
|
|
1013
|
+
const linux = inBlock('linux', 'remoteExe') ?? inBlock('linux', 'exe');
|
|
1014
|
+
const macos = inBlock('macos', 'app', (v) => v.endsWith('.app'));
|
|
1015
|
+
|
|
1016
|
+
return { android, ios, windows, linux, macos };
|
|
981
1017
|
}
|
|
982
1018
|
|
|
983
1019
|
/**
|
|
@@ -1063,9 +1099,9 @@ function couldNotAsk(name, why) {
|
|
|
1063
1099
|
/**
|
|
1064
1100
|
* The platforms that arrive as an adapter of their own, and know their own requirements.
|
|
1065
1101
|
* The built-in five are described by hand above, because they are older than this
|
|
1066
|
-
* mechanism and their wording is tested; these
|
|
1102
|
+
* mechanism and their wording is tested; these four answer for themselves.
|
|
1067
1103
|
*/
|
|
1068
|
-
const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
|
|
1104
|
+
const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows', 'linux', 'macos'];
|
|
1069
1105
|
|
|
1070
1106
|
/**
|
|
1071
1107
|
* Ask each separate adapter what IT is missing, in its own words.
|
|
@@ -1181,7 +1217,7 @@ async function whatThisCopyCanDrive() {
|
|
|
1181
1217
|
// file that would not load, so a check on this copy is not running at all — and both
|
|
1182
1218
|
// the command line and the MCP surface say that in their own words already.
|
|
1183
1219
|
const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
|
|
1184
|
-
for (const surface of ['android', 'ios', 'windows']) out.push({ surface, present: false, why });
|
|
1220
|
+
for (const surface of ['android', 'ios', 'windows', 'linux', 'macos']) out.push({ surface, present: false, why });
|
|
1185
1221
|
}
|
|
1186
1222
|
return out;
|
|
1187
1223
|
}
|
|
@@ -1542,6 +1578,7 @@ function settingsFromText(text) {
|
|
|
1542
1578
|
// as a result: it could not see the machine the settings named, nor the built program,
|
|
1543
1579
|
// so it asked for both while both were sitting in the file.
|
|
1544
1580
|
windows: ['host', 'remoteExe', 'exe'],
|
|
1581
|
+
linux: ['host', 'remoteExe', 'exe'],
|
|
1545
1582
|
};
|
|
1546
1583
|
for (const [block, keys] of Object.entries(wanted)) {
|
|
1547
1584
|
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
|
|
@@ -1671,7 +1708,7 @@ async function findReference(root) {
|
|
|
1671
1708
|
* @param {import('./browsers.js').BrowserSurvey} browsers
|
|
1672
1709
|
* @param {{where: string, how: string}|null} desktopApp
|
|
1673
1710
|
* @param {DriverReport[]} drivers What this copy of the tool can drive at all.
|
|
1674
|
-
* @param {{android: FoundApp|null, ios: FoundApp|null}} phones
|
|
1711
|
+
* @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null, linux: FoundApp|null, macos: FoundApp|null}} phones
|
|
1675
1712
|
* @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
|
|
1676
1713
|
* @param {{commands: number, imports: number}} [wires]
|
|
1677
1714
|
* What this project's own settings wire for the command-line surface. A surface with
|
|
@@ -1853,7 +1890,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1853
1890
|
: !canDrive('android')
|
|
1854
1891
|
? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
|
|
1855
1892
|
: androidReady
|
|
1856
|
-
? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether
|
|
1893
|
+
? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether a snapshot restore repeats was measured on 2026-08-31: one build walked ten times with a restore between every walk, 301 of 309 addresses agreeing in all five pairs, and the eight that moved were the app's own freshly-made identity code — ordinary wobble, which this tool already subtracts. So a paired run is offered. It also needs a copy of the OLD build's package, because an APK is a build output that no checkout of the old commit contains: name it with {"reference": "path/to/the-old.apk"} under "android" in the settings. Without it the run falls back to the stored record and says so.`
|
|
1857
1894
|
: androidPartly
|
|
1858
1895
|
? `Most of your Android app can be checked: every screen another app can reach is opened and read. What is missing is ${plainList(androidMissing)}, and without ${androidMissing.length === 1 ? 'it' : 'them'} nothing is typed, pressed or saved — so a clean result covers the screens and not what the app DOES.`
|
|
1859
1896
|
: `An Android app is here (${phones.android.how}), and ${plainList(androidMissing)} ${androidMissing.length === 1 ? 'is' : 'are'} still missing. ${androidWants.every((n) => n.automatic) ? `${androidMissing.length === 1 ? 'It installs' : 'They all install'} without anybody clicking anything, so nobody needs to be asked.` : 'Some of it needs a person, and each one says what it is and what it unlocks.'}`,
|
|
@@ -1884,7 +1921,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1884
1921
|
const iosBlocked = iosWants.some(blocks);
|
|
1885
1922
|
const iosReady = iosMachine && canDrive('ios') && phones.ios !== null && iosWants.length === 0;
|
|
1886
1923
|
const iosPartly = iosMachine && canDrive('ios') && phones.ios !== null && !iosReady && !iosBlocked;
|
|
1887
|
-
|
|
1924
|
+
// THE PROJECT IS ASKED BEFORE THE MACHINE, and the order is the whole point. "There is no
|
|
1925
|
+
// iPhone app in this repository" and "this machine cannot run one" are different sentences
|
|
1926
|
+
// with different things to do about them, and a machine reason given for a project that has
|
|
1927
|
+
// no iPhone app in it sends somebody to install thirty gigabytes of Xcode for nothing. On a
|
|
1928
|
+
// Mac this was already right; on Linux the platform test came first, so every project on
|
|
1929
|
+
// every Linux machine was told its non-existent iPhone app was out of reach. Caught by CI
|
|
1930
|
+
// on 2026-08-31 — the Mac suite was green and said nothing about it.
|
|
1931
|
+
if (phones.ios === null) {
|
|
1932
|
+
notInThisProject.add('ios');
|
|
1933
|
+
impossible.set(
|
|
1934
|
+
'ios',
|
|
1935
|
+
'This project has no iPhone app in it, so there is nothing for a simulator to run. If yours is built somewhere else, name the built .app in your settings under ios.app.'
|
|
1936
|
+
);
|
|
1937
|
+
} else if (!onAMac) {
|
|
1888
1938
|
impossible.set('ios', 'An iPhone build can only be run on a Mac. Everything else on this list is unaffected — check the iPhone app from a Mac, and let this machine cover the rest.');
|
|
1889
1939
|
} else if (phones.ios === null) {
|
|
1890
1940
|
notInThisProject.add('ios');
|
|
@@ -1902,10 +1952,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1902
1952
|
id: 'ios',
|
|
1903
1953
|
name: 'iPhone apps, on the simulator',
|
|
1904
1954
|
status: iosReady ? 'ready' : iosPartly ? 'partial' : 'unavailable',
|
|
1905
|
-
summary:
|
|
1906
|
-
? '
|
|
1907
|
-
:
|
|
1908
|
-
? '
|
|
1955
|
+
summary: phones.ios === null
|
|
1956
|
+
? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
|
|
1957
|
+
: !onAMac
|
|
1958
|
+
? 'Cannot run here: iOS needs a Mac.'
|
|
1909
1959
|
: !canDrive('ios')
|
|
1910
1960
|
? `An iPhone app is here (${phones.ios.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('ios')}`
|
|
1911
1961
|
: !iosMachine
|
|
@@ -1949,6 +1999,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1949
1999
|
// is "detect rather than ask" at its sharpest: a runner that already answers must never
|
|
1950
2000
|
// be presented as something to go and set up.
|
|
1951
2001
|
const windowsDriver = canDrive('windows');
|
|
2002
|
+
// Same rule as iPhone and Android: the project is asked before the machine. A repository
|
|
2003
|
+
// with no native Windows program in it does not need a Windows machine, and "no Windows
|
|
2004
|
+
// desktop is reachable from here" about one is a machine reason given for a project fact.
|
|
2005
|
+
// Caught by CI on 2026-08-31, where a Linux runner reported native Windows apps as out of
|
|
2006
|
+
// reach for a project that contains none.
|
|
2007
|
+
if (phones.windows === null) {
|
|
2008
|
+
notInThisProject.add('windows');
|
|
2009
|
+
impossible.set(
|
|
2010
|
+
'windows',
|
|
2011
|
+
'This project has no native Windows program named in its settings, so there is nothing to open on a Windows desktop. '
|
|
2012
|
+
+ 'If yours is built somewhere else, name it under windows.remoteExe (already on that machine) or windows.exe (copied over each run). '
|
|
2013
|
+
+ 'Most Windows products are Electron, and those are covered over their debug port from any machine.'
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
1952
2016
|
// A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
|
|
1953
2017
|
// — no windows, no controls — so calling it "partly covered" would be the exact over-claim
|
|
1954
2018
|
// this file exists to prevent. The question can only be asked when the runner started
|
|
@@ -1959,7 +2023,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1959
2023
|
id: 'windows',
|
|
1960
2024
|
name: 'native Windows apps',
|
|
1961
2025
|
status: windowsUsable ? 'partial' : 'unavailable',
|
|
1962
|
-
summary:
|
|
2026
|
+
summary: phones.windows === null
|
|
2027
|
+
? 'Nothing to check: this project names no native Windows program in its settings. Most Windows products are Electron, and those are covered over their debug port from any machine.'
|
|
2028
|
+
: !windowsHost
|
|
1963
2029
|
? nobodyWasDialled
|
|
1964
2030
|
// Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
|
|
1965
2031
|
// The two were the same sentence until 2026-08-31, and it stated as a fact about the
|
|
@@ -2002,6 +2068,84 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
2002
2068
|
if (windowsHost && !windowsDriver) {
|
|
2003
2069
|
impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
|
|
2004
2070
|
}
|
|
2071
|
+
|
|
2072
|
+
// ── native Mac apps ───────────────────────────────────────────────────────────────────
|
|
2073
|
+
//
|
|
2074
|
+
// No remote option here, unlike Windows and Linux: the Accessibility API only answers
|
|
2075
|
+
// inside a signed-in graphical session on the machine itself, so a Mac app is checked on
|
|
2076
|
+
// the Mac it is on or not at all. And one person has to allow it once — macOS will not let
|
|
2077
|
+
// any program grant itself permission to read another app's window.
|
|
2078
|
+
const macDriver = canDrive('macos');
|
|
2079
|
+
const onAMacHere = process.platform === 'darwin';
|
|
2080
|
+
const macNeeds = asked.get('macos') ?? [];
|
|
2081
|
+
const macAllowed = onAMacHere && macDriver && !macNeeds.some((n) => /permission/i.test(String(n.what)));
|
|
2082
|
+
const macUsable = phones.macos !== null && macAllowed;
|
|
2083
|
+
surfaces.push({
|
|
2084
|
+
id: 'macos',
|
|
2085
|
+
name: 'native Mac apps',
|
|
2086
|
+
status: macUsable ? 'ready' : 'unavailable',
|
|
2087
|
+
summary: phones.macos === null
|
|
2088
|
+
? 'Nothing to check: this project names no native Mac app in its settings. Most Mac products are Electron, and those are covered over their debug port from any machine.'
|
|
2089
|
+
: !onAMacHere
|
|
2090
|
+
? 'Cannot run here: a native Mac window can only be read from a Mac.'
|
|
2091
|
+
: !macDriver
|
|
2092
|
+
? `A Mac app is named in the settings, and this copy of Stays Fixed cannot drive one. ${noDriver('macos')}`
|
|
2093
|
+
// The adapter's own paragraph, not a second one written here. It knows whether this
|
|
2094
|
+
// Mac has been allowed to read another app's window, and that changes the answer
|
|
2095
|
+
// completely.
|
|
2096
|
+
: describeMacos({ darwin: true, allowed: macAllowed }),
|
|
2097
|
+
canCheck: macUsable ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
2098
|
+
cannotCheck: macUsable ? [] : CHANNELS.map((c) => c.id),
|
|
2099
|
+
needs: onAMacHere && macDriver && phones.macos !== null ? macNeeds : [],
|
|
2100
|
+
});
|
|
2101
|
+
if (phones.macos === null) {
|
|
2102
|
+
notInThisProject.add('macos');
|
|
2103
|
+
impossible.set('macos', 'This project names no native Mac app in its settings, so there is nothing to open. If yours is built somewhere else, name the built .app under macos.app. Most Mac products are Electron, and those are covered over their debug port from any machine.');
|
|
2104
|
+
} else if (!onAMacHere) {
|
|
2105
|
+
impossible.set('macos', 'A native Mac window can only be read from a Mac. Everything else on this list is unaffected — check the Mac app from a Mac, and let this machine cover the rest.');
|
|
2106
|
+
} else if (!macDriver) {
|
|
2107
|
+
impossible.set('macos', `${noDriver('macos')} Nothing you install on this machine changes it. Update Stays Fixed to a copy that has it.`);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
// ── native Linux desktop apps ─────────────────────────────────────────────────────────
|
|
2111
|
+
//
|
|
2112
|
+
// The project is asked before the machine, the same as everywhere else: a repository with
|
|
2113
|
+
// no native Linux program named in it does not need a Linux desktop, and telling somebody
|
|
2114
|
+
// to go and find one is work that changes nothing.
|
|
2115
|
+
const linuxDriver = canDrive('linux');
|
|
2116
|
+
const linuxHost = hosts.find((h) => h.reachable && h.windows !== true);
|
|
2117
|
+
const linuxUsable = phones.linux !== null && linuxHost !== undefined && linuxDriver;
|
|
2118
|
+
surfaces.push({
|
|
2119
|
+
id: 'linux',
|
|
2120
|
+
name: 'native Linux desktop apps',
|
|
2121
|
+
status: linuxUsable ? 'partial' : 'unavailable',
|
|
2122
|
+
summary: phones.linux === null
|
|
2123
|
+
? 'Nothing to check: this project names no native Linux program in its settings. Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
|
|
2124
|
+
: !linuxDriver
|
|
2125
|
+
? `A native Linux program is named (${phones.linux.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('linux')}`
|
|
2126
|
+
: !linuxHost
|
|
2127
|
+
? nobodyWasDialled
|
|
2128
|
+
? 'No machine was dialled, so whether a Linux desktop can be reached from here is unknown. Name one under `linux: { host: "..." }` in your settings and it is asked every time, or run `staysfixed doctor --machines`.'
|
|
2129
|
+
: 'No Linux desktop is reachable from here. A native Linux window can only be read from the desktop it is running on.'
|
|
2130
|
+
// The adapter's own paragraph, never a second one written here — it knows whether
|
|
2131
|
+
// that machine has a desktop session at all, and a summary kept in this file could
|
|
2132
|
+
// only guess at it.
|
|
2133
|
+
: linuxHost.detail
|
|
2134
|
+
? describeLinuxDesktop(linuxHost.detail)
|
|
2135
|
+
: `A Linux machine answers through "${linuxHost.name}". A native Linux app is read through the accessibility bus every screen reader already uses, and nothing has to be installed there.`,
|
|
2136
|
+
canCheck: linuxUsable ? withoutADriver : [],
|
|
2137
|
+
cannotCheck: linuxUsable ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
|
|
2138
|
+
needs: linuxUsable ? (asked.get('linux') ?? []) : [],
|
|
2139
|
+
});
|
|
2140
|
+
if (phones.linux === null) {
|
|
2141
|
+
notInThisProject.add('linux');
|
|
2142
|
+
impossible.set(
|
|
2143
|
+
'linux',
|
|
2144
|
+
'This project has no native Linux program named in its settings, so there is nothing to open on a Linux desktop. '
|
|
2145
|
+
+ 'If yours is built somewhere else, name it under linux.remoteExe (already on that machine) or linux.exe (copied over each run). '
|
|
2146
|
+
+ 'Most Linux desktop products are Electron, and those are covered over their debug port from any machine.'
|
|
2147
|
+
);
|
|
2148
|
+
}
|
|
2005
2149
|
if (!windowsHost) {
|
|
2006
2150
|
impossible.set(
|
|
2007
2151
|
'windows',
|