staysfixed 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every browser this tool opens, and every one it has to close again.
|
|
3
|
+
*
|
|
4
|
+
* This file exists because of a real incident on the owner's Mac, and the rule it
|
|
5
|
+
* encodes is the strongest one in the repository: A TOOL MUST NEVER MAKE THE MACHINE
|
|
6
|
+
* IT RUNS ON WORSE.
|
|
7
|
+
*
|
|
8
|
+
* What happened. macOS hands each application ONE slot. Clicking a Dock icon does not
|
|
9
|
+
* start a program, it activates whatever is already running under that application's
|
|
10
|
+
* identity. Every browser this tool used to open was the person's own Google Chrome
|
|
11
|
+
* binary — the same identity, `com.google.Chrome`. So when he clicked his own Chrome
|
|
12
|
+
* icon, macOS dutifully woke one of our invisible instances instead, and nothing
|
|
13
|
+
* appeared to happen. His browser stopped opening while our tool was running.
|
|
14
|
+
*
|
|
15
|
+
* The fix is not a flag. It is to open a DIFFERENT browser: Chrome for Testing, which
|
|
16
|
+
* is the same engine under a different identity (`com.google.chrome.for.testing`) and
|
|
17
|
+
* therefore a different slot, or a standalone Chromium, or Chrome's headless shell,
|
|
18
|
+
* which has no application bundle at all and so cannot take a slot from anybody. His
|
|
19
|
+
* everyday browser is the last resort, taken only when this machine has nothing else,
|
|
20
|
+
* and the run says so out loud rather than quietly borrowing his.
|
|
21
|
+
*
|
|
22
|
+
* Four promises this file keeps, in the order they matter:
|
|
23
|
+
*
|
|
24
|
+
* 1. NEVER HIS BROWSER IF THERE IS ANY OTHER. And when there is not, say it.
|
|
25
|
+
* 2. NEVER HIS PROFILE. A throwaway user-data-dir, made fresh, deleted after — never
|
|
26
|
+
* his cookies, his extensions, his open tabs. And never port 9333, which another
|
|
27
|
+
* session on this machine already owns.
|
|
28
|
+
* 3. NOTHING WE OPENED OUTLIVES THE RUN. Every instance is registered the moment it
|
|
29
|
+
* starts. `closeEverything()` shuts them all. It also runs on a throw, on Ctrl-C,
|
|
30
|
+
* and on the way out of the process, where the last resort is a synchronous kill,
|
|
31
|
+
* because an asynchronous cleanup at `exit` never gets to run.
|
|
32
|
+
* 4. NEVER KILL WHAT WE DID NOT START. His own Chrome may be open; so may another
|
|
33
|
+
* session's. Nothing is ever killed on a name match. It is killed only when its
|
|
34
|
+
* command line contains a throwaway profile path that this tool created, which
|
|
35
|
+
* no other program on the machine can be holding.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import fsp from 'node:fs/promises';
|
|
40
|
+
import os from 'node:os';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
import crypto from 'node:crypto';
|
|
43
|
+
import { spawn, execFile } from 'node:child_process';
|
|
44
|
+
import { promisify } from 'node:util';
|
|
45
|
+
|
|
46
|
+
import { StaysFixedError, isExpected } from '../core/errors.js';
|
|
47
|
+
import { findChrome, freePort, resolveElectronBinary } from '../drive/find.js';
|
|
48
|
+
import { waitForEndpoint } from '../drive/cdp.js';
|
|
49
|
+
import { keepOutput, stopProcess } from '../drive/browser.js';
|
|
50
|
+
|
|
51
|
+
const exec = promisify(execFile);
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Facts that are policy, not preference
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The one port this tool may never take. Another session on the owner's machine
|
|
59
|
+
* drives a signed-in browser on 9333 and has done for months; binding it would
|
|
60
|
+
* take his logged-in browser away from him mid-run. The operating system hands
|
|
61
|
+
* out free ports for free — there is no reason ever to want a fixed one.
|
|
62
|
+
*/
|
|
63
|
+
export const PORT_NEVER_USE = 9333;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where every throwaway profile lives, and the only string this tool will kill
|
|
67
|
+
* a process over. It is inside the temporary folder on purpose: nothing here is
|
|
68
|
+
* meant to survive a reboot, and a leftover profile costs nothing.
|
|
69
|
+
*/
|
|
70
|
+
export const SCRATCH_ROOT = path.join(os.tmpdir(), 'staysfixed-browsers');
|
|
71
|
+
|
|
72
|
+
/** How long a browser gets to answer on its debugging port before we give up. */
|
|
73
|
+
const START_MS = 60_000;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* How long a `--version` probe gets. A browser that will not answer in this is not
|
|
77
|
+
* usable, and doctor is the first call an agent makes — it must never be the slow one.
|
|
78
|
+
*/
|
|
79
|
+
const PROBE_MS = 5_000;
|
|
80
|
+
|
|
81
|
+
/** Politeness before SIGKILL, in milliseconds. */
|
|
82
|
+
const GRACE_MS = 3_000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The kinds of browser, best first, and why each one is ranked where it is.
|
|
86
|
+
*
|
|
87
|
+
* The ranking is about ONE question and nothing else: whose application slot does
|
|
88
|
+
* this take? Not speed, not fidelity, not version. A browser that steals the
|
|
89
|
+
* person's own is last however good it is.
|
|
90
|
+
*
|
|
91
|
+
* @type {{kind: BrowserKind, name: string, why: string}[]}
|
|
92
|
+
*/
|
|
93
|
+
const RANK = [
|
|
94
|
+
{
|
|
95
|
+
kind: 'chrome-for-testing',
|
|
96
|
+
name: 'Chrome for Testing',
|
|
97
|
+
why: 'The same engine as Chrome under a different identity, so opening it cannot take over the browser you use.',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
kind: 'chromium',
|
|
101
|
+
name: 'Chromium',
|
|
102
|
+
why: 'A separate application from your everyday browser, so opening it leaves yours alone.',
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
kind: 'headless-shell',
|
|
106
|
+
name: 'Chrome’s headless shell',
|
|
107
|
+
why: 'Not an application at all — it has no icon and no window, so it cannot take a slot from anything. It can only run invisibly.',
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
kind: 'given',
|
|
111
|
+
name: 'the browser you named',
|
|
112
|
+
why: 'You pointed at this one yourself, so it is used as asked.',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
kind: 'everyday',
|
|
116
|
+
name: 'your everyday browser',
|
|
117
|
+
why: 'Last resort. It shares an application slot with the browser you use, so while a check is running, clicking your own browser icon may wake this invisible copy instead of opening your window.',
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
/** @typedef {'chrome-for-testing'|'chromium'|'headless-shell'|'given'|'everyday'} BrowserKind */
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* One browser found on this machine.
|
|
125
|
+
*
|
|
126
|
+
* @typedef {object} BrowserFound
|
|
127
|
+
* @property {BrowserKind} kind
|
|
128
|
+
* @property {string} name What a person would call it.
|
|
129
|
+
* @property {string} binary The executable, not the app folder.
|
|
130
|
+
* @property {boolean} everyday True when opening it competes with the browser the person uses.
|
|
131
|
+
* @property {boolean} usable It answered `--version` with a version. A file that exists is not a browser.
|
|
132
|
+
* @property {string} [version]
|
|
133
|
+
* @property {string} [broken] Filled in when it is on disk but does not run, with what it said.
|
|
134
|
+
* @property {boolean} [headlessOnly] It cannot show a window at all.
|
|
135
|
+
* @property {string} why Plain English: why this one is ranked where it is.
|
|
136
|
+
*/
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What this machine can open, and what it will open.
|
|
140
|
+
*
|
|
141
|
+
* @typedef {object} BrowserSurvey
|
|
142
|
+
* @property {BrowserFound[]} found Everything found, best first, usable and not.
|
|
143
|
+
* @property {BrowserFound|null} chosen What a run would open now.
|
|
144
|
+
* @property {boolean} borrowingHis True when the only choice is the person's own browser.
|
|
145
|
+
* @property {string} note One plain sentence, safe to repeat to a person.
|
|
146
|
+
* @property {string|null} install The exact command that would fix it, when one would.
|
|
147
|
+
*/
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* One browser this tool has open right now.
|
|
151
|
+
*
|
|
152
|
+
* @typedef {object} OpenBrowser
|
|
153
|
+
* @property {string} id
|
|
154
|
+
* @property {BrowserFound} browser
|
|
155
|
+
* @property {number} port
|
|
156
|
+
* @property {string} endpoint http://127.0.0.1:<port> — where its debugging port answers.
|
|
157
|
+
* @property {string} webSocketDebuggerUrl
|
|
158
|
+
* @property {string} userDataDir Throwaway. Deleted on close.
|
|
159
|
+
* @property {number|null} pid
|
|
160
|
+
* @property {boolean} headless
|
|
161
|
+
* @property {string[]} notes Anything the caller should repeat, in plain English.
|
|
162
|
+
* @property {() => Promise<void>} close
|
|
163
|
+
*/
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A browser some earlier run left behind, found by its throwaway profile.
|
|
167
|
+
*
|
|
168
|
+
* @typedef {object} Stray
|
|
169
|
+
* @property {string} id
|
|
170
|
+
* @property {number|null} pid
|
|
171
|
+
* @property {boolean} running
|
|
172
|
+
* @property {boolean} inUseByAnotherRun A different Stays Fixed is alive and still owns this.
|
|
173
|
+
* @property {number|null} owner The process id of the run that opened it.
|
|
174
|
+
* @property {string} userDataDir
|
|
175
|
+
* @property {string} binary
|
|
176
|
+
* @property {string} startedAt
|
|
177
|
+
*/
|
|
178
|
+
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// Finding a browser that is not his
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Directories that hold a browser somebody downloaded FOR testing rather than
|
|
185
|
+
* one they use. Anything found here is safe to drive; anything found in
|
|
186
|
+
* /Applications or Program Files belongs to the person.
|
|
187
|
+
*
|
|
188
|
+
* @returns {string[]}
|
|
189
|
+
*/
|
|
190
|
+
function testingBrowserRoots() {
|
|
191
|
+
const home = os.homedir();
|
|
192
|
+
/** @type {string[]} */
|
|
193
|
+
const roots = [];
|
|
194
|
+
if (process.env.PLAYWRIGHT_BROWSERS_PATH) roots.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
|
|
195
|
+
if (process.platform === 'darwin') {
|
|
196
|
+
roots.push(path.join(home, 'Library', 'Caches', 'ms-playwright'));
|
|
197
|
+
roots.push(path.join(home, '.cache', 'puppeteer'));
|
|
198
|
+
} else if (process.platform === 'win32') {
|
|
199
|
+
if (process.env.LOCALAPPDATA) roots.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
|
|
200
|
+
roots.push(path.join(home, '.cache', 'puppeteer'));
|
|
201
|
+
} else {
|
|
202
|
+
roots.push(path.join(home, '.cache', 'ms-playwright'));
|
|
203
|
+
roots.push(path.join(home, '.cache', 'puppeteer'));
|
|
204
|
+
}
|
|
205
|
+
return roots.filter((dir) => dir && exists(dir));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {string} p
|
|
210
|
+
* @returns {boolean}
|
|
211
|
+
*/
|
|
212
|
+
function exists(p) {
|
|
213
|
+
try {
|
|
214
|
+
return fs.existsSync(p);
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* @param {string} dir
|
|
222
|
+
* @returns {string[]} entries, newest-looking first
|
|
223
|
+
*/
|
|
224
|
+
function entriesNewestFirst(dir) {
|
|
225
|
+
/** @type {string[]} */
|
|
226
|
+
let names = [];
|
|
227
|
+
try {
|
|
228
|
+
names = fs.readdirSync(dir);
|
|
229
|
+
} catch {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
// Playwright names its folders `chromium-1234`. Sorting on the number rather
|
|
233
|
+
// than the string is what stops `chromium-999` beating `chromium-1234`.
|
|
234
|
+
return names.sort((a, b) => {
|
|
235
|
+
const na = Number(/(\d+)\s*$/.exec(a)?.[1] ?? 0);
|
|
236
|
+
const nb = Number(/(\d+)\s*$/.exec(b)?.[1] ?? 0);
|
|
237
|
+
if (na !== nb) return nb - na;
|
|
238
|
+
return b.localeCompare(a);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every browser that is not the person's, found by walking the folders where
|
|
244
|
+
* downloaded test browsers live. Nothing here is probed yet — that costs a
|
|
245
|
+
* process each and only the ones we might actually use are worth it.
|
|
246
|
+
*
|
|
247
|
+
* @returns {{kind: BrowserKind, binary: string, headlessOnly?: boolean}[]}
|
|
248
|
+
*/
|
|
249
|
+
function testingBrowsers() {
|
|
250
|
+
/** @type {{kind: BrowserKind, binary: string, headlessOnly?: boolean}[]} */
|
|
251
|
+
const out = [];
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {BrowserKind} kind
|
|
255
|
+
* @param {string} binary
|
|
256
|
+
* @param {boolean} [headlessOnly]
|
|
257
|
+
*/
|
|
258
|
+
const take = (kind, binary, headlessOnly) => {
|
|
259
|
+
if (binary && exists(binary) && !out.some((b) => b.binary === binary)) out.push({ kind, binary, headlessOnly });
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
for (const root of testingBrowserRoots()) {
|
|
263
|
+
for (const entry of entriesNewestFirst(root)) {
|
|
264
|
+
const dir = path.join(root, entry);
|
|
265
|
+
// Playwright: chromium-<rev>/chrome-{mac-arm64,mac,linux,win}/...
|
|
266
|
+
// Puppeteer: chrome/<platform>-<version>/chrome-<platform>/...
|
|
267
|
+
for (const inner of [dir, ...entriesNewestFirst(dir).map((name) => path.join(dir, name))]) {
|
|
268
|
+
take('chrome-for-testing', path.join(inner, 'chrome-mac-arm64', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'));
|
|
269
|
+
take('chrome-for-testing', path.join(inner, 'chrome-mac', 'Google Chrome for Testing.app', 'Contents', 'MacOS', 'Google Chrome for Testing'));
|
|
270
|
+
take('chromium', path.join(inner, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'));
|
|
271
|
+
take('chrome-for-testing', path.join(inner, 'chrome-linux', 'chrome'));
|
|
272
|
+
take('chrome-for-testing', path.join(inner, 'chrome-win', 'chrome.exe'));
|
|
273
|
+
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell'), true);
|
|
274
|
+
take('headless-shell', path.join(inner, 'chrome-headless-shell-mac-x64', 'chrome-headless-shell'), true);
|
|
275
|
+
take('headless-shell', path.join(inner, 'chrome-headless-shell-linux64', 'chrome-headless-shell'), true);
|
|
276
|
+
take('headless-shell', path.join(inner, 'chrome-headless-shell-win64', 'chrome-headless-shell.exe'), true);
|
|
277
|
+
take('headless-shell', path.join(inner, 'chrome-linux', 'headless_shell'), true);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Does this file actually run?
|
|
286
|
+
*
|
|
287
|
+
* A path that exists is not a browser. This machine had a half-downloaded Chrome
|
|
288
|
+
* for Testing sitting in Playwright's cache: every file check said yes, and it
|
|
289
|
+
* died on its first library load. Asking it its version is one cheap process and
|
|
290
|
+
* it is the difference between "ready" and a run that fails ten minutes later.
|
|
291
|
+
*
|
|
292
|
+
* @param {string} binary
|
|
293
|
+
* @returns {Promise<{ok: boolean, version?: string, why?: string}>}
|
|
294
|
+
*/
|
|
295
|
+
export async function probeBrowser(binary) {
|
|
296
|
+
try {
|
|
297
|
+
const { stdout, stderr } = await exec(binary, ['--version'], { timeout: PROBE_MS, maxBuffer: 1 << 20, windowsHide: true });
|
|
298
|
+
const said = String(stdout || stderr).trim();
|
|
299
|
+
const version = /\d+\.\d+\.\d+(\.\d+)?/.exec(said)?.[0];
|
|
300
|
+
// It answered, but with no version in it. That is what a broken bundle does:
|
|
301
|
+
// it exits zero and prints the library it could not load.
|
|
302
|
+
if (!version) return { ok: false, why: said.split('\n')[0]?.slice(0, 200) || 'it printed nothing when asked its version' };
|
|
303
|
+
return { ok: true, version };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
const err = /** @type {{killed?: boolean, stderr?: string}} */ (Object(e));
|
|
306
|
+
if (err.killed) return { ok: false, why: `it did not answer within ${Math.round(PROBE_MS / 1000)} seconds` };
|
|
307
|
+
return { ok: false, why: String(err.stderr || (e instanceof Error ? e.message : e)).split('\n')[0].slice(0, 200) };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {BrowserKind} kind
|
|
313
|
+
* @returns {{name: string, why: string}}
|
|
314
|
+
*/
|
|
315
|
+
function describeKind(kind) {
|
|
316
|
+
const hit = RANK.find((r) => r.kind === kind);
|
|
317
|
+
return hit ? { name: hit.name, why: hit.why } : { name: 'a browser', why: '' };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Surveys already taken, keyed by what was asked for.
|
|
322
|
+
*
|
|
323
|
+
* A survey costs one process per candidate, and a run of forty journeys would
|
|
324
|
+
* otherwise ask this machine the same question forty times. The answer cannot
|
|
325
|
+
* change mid-run in any way that matters — nobody installs a browser while a
|
|
326
|
+
* check is running — and `refresh` is there for the one case that does: the
|
|
327
|
+
* agent installing one because doctor told it to.
|
|
328
|
+
*
|
|
329
|
+
* @type {Map<string, Promise<BrowserSurvey>>}
|
|
330
|
+
*/
|
|
331
|
+
const surveys = new Map();
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* What this machine can open, best first, with the person's own browser last.
|
|
335
|
+
*
|
|
336
|
+
* @param {object} [opts]
|
|
337
|
+
* @param {string} [opts.explicit] A binary named in settings or on the command line.
|
|
338
|
+
* @param {boolean} [opts.probe] Ask each candidate its version. Default true.
|
|
339
|
+
* @param {boolean} [opts.headless] Default true. When false, the headless shell is dropped:
|
|
340
|
+
* it has no window to show and offering it would be a lie.
|
|
341
|
+
* @param {boolean} [opts.refresh] Look again rather than reusing the answer from earlier
|
|
342
|
+
* in this process. Use it right after installing one.
|
|
343
|
+
* @returns {Promise<BrowserSurvey>}
|
|
344
|
+
*/
|
|
345
|
+
export async function surveyBrowsers(opts = {}) {
|
|
346
|
+
const key = JSON.stringify([opts.explicit ?? process.env.STAYSFIXED_BROWSER ?? '', opts.probe !== false, opts.headless !== false]);
|
|
347
|
+
if (opts.refresh) surveys.delete(key);
|
|
348
|
+
const already = surveys.get(key);
|
|
349
|
+
if (already) return await already;
|
|
350
|
+
const pending = takeSurvey(opts);
|
|
351
|
+
surveys.set(key, pending);
|
|
352
|
+
try {
|
|
353
|
+
return await pending;
|
|
354
|
+
} catch (e) {
|
|
355
|
+
surveys.delete(key);
|
|
356
|
+
throw e;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {{explicit?: string, probe?: boolean, headless?: boolean}} opts
|
|
362
|
+
* @returns {Promise<BrowserSurvey>}
|
|
363
|
+
*/
|
|
364
|
+
async function takeSurvey(opts) {
|
|
365
|
+
const wantProbe = opts.probe !== false;
|
|
366
|
+
const headless = opts.headless !== false;
|
|
367
|
+
|
|
368
|
+
/** @type {{kind: BrowserKind, binary: string, headlessOnly?: boolean}[]} */
|
|
369
|
+
const raw = [];
|
|
370
|
+
|
|
371
|
+
const named = opts.explicit ?? process.env.STAYSFIXED_BROWSER;
|
|
372
|
+
if (named) {
|
|
373
|
+
let binary = named;
|
|
374
|
+
try {
|
|
375
|
+
binary = resolveElectronBinary(named);
|
|
376
|
+
} catch {
|
|
377
|
+
// Not a Mac app folder, or not there at all. Either way the probe below
|
|
378
|
+
// is what decides, and it gives a better sentence than this would.
|
|
379
|
+
}
|
|
380
|
+
raw.push({ kind: 'given', binary });
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
raw.push(...testingBrowsers());
|
|
384
|
+
|
|
385
|
+
// The person's own, last, and only ever as a fallback.
|
|
386
|
+
const his = findChrome(process.env.STAYSFIXED_CHROME);
|
|
387
|
+
if (his) raw.push({ kind: 'everyday', binary: his });
|
|
388
|
+
|
|
389
|
+
/** @type {BrowserFound[]} */
|
|
390
|
+
const found = [];
|
|
391
|
+
for (const item of raw) {
|
|
392
|
+
if (found.some((f) => f.binary === item.binary)) continue;
|
|
393
|
+
if (item.headlessOnly && !headless) continue;
|
|
394
|
+
const { name, why } = describeKind(item.kind);
|
|
395
|
+
found.push({
|
|
396
|
+
kind: item.kind,
|
|
397
|
+
name: item.kind === 'given' ? `${name} (${path.basename(item.binary)})` : name,
|
|
398
|
+
binary: item.binary,
|
|
399
|
+
everyday: item.kind === 'everyday',
|
|
400
|
+
usable: true,
|
|
401
|
+
why,
|
|
402
|
+
...(item.headlessOnly ? { headlessOnly: true } : {}),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// All at once. Six short-lived processes finish in the time one of them takes,
|
|
407
|
+
// and doctor is the first call an agent makes: it must never be the slow one.
|
|
408
|
+
if (wantProbe) {
|
|
409
|
+
await Promise.all(
|
|
410
|
+
found.map(async (entry) => {
|
|
411
|
+
const answer = await probeBrowser(entry.binary);
|
|
412
|
+
entry.usable = answer.ok;
|
|
413
|
+
if (answer.version) entry.version = answer.version;
|
|
414
|
+
if (!answer.ok) entry.broken = answer.why ?? 'it did not run';
|
|
415
|
+
})
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const order = RANK.map((r) => r.kind);
|
|
420
|
+
found.sort((a, b) => {
|
|
421
|
+
if (a.usable !== b.usable) return a.usable ? -1 : 1;
|
|
422
|
+
return order.indexOf(a.kind) - order.indexOf(b.kind);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const chosen = found.find((f) => f.usable) ?? null;
|
|
426
|
+
const borrowingHis = chosen !== null && chosen.everyday;
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
found,
|
|
430
|
+
chosen,
|
|
431
|
+
borrowingHis,
|
|
432
|
+
note: noteFor(chosen, found, borrowingHis),
|
|
433
|
+
install: chosen && !borrowingHis ? null : INSTALL_COMMAND,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* The one command that turns "borrowing his browser" into "has its own". It is a
|
|
439
|
+
* download and a package, nothing else — no licence, no account, no clicking.
|
|
440
|
+
* That is why doctor reports it as something the agent does without asking.
|
|
441
|
+
*/
|
|
442
|
+
export const INSTALL_COMMAND = 'npm install --save-dev playwright && npx playwright install chromium';
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* @param {BrowserFound|null} chosen
|
|
446
|
+
* @param {BrowserFound[]} found
|
|
447
|
+
* @param {boolean} borrowingHis
|
|
448
|
+
* @returns {string}
|
|
449
|
+
*/
|
|
450
|
+
function noteFor(chosen, found, borrowingHis) {
|
|
451
|
+
const broken = found.filter((f) => !f.usable);
|
|
452
|
+
const brokenNote = broken.length
|
|
453
|
+
? ` ${broken.length === 1 ? 'One browser was' : `${broken.length} browsers were`} on disk but would not run, so ${broken.length === 1 ? 'it was' : 'they were'} passed over: ${broken.map((b) => `${b.name} (${b.broken})`).join('; ')}.`
|
|
454
|
+
: '';
|
|
455
|
+
|
|
456
|
+
if (!chosen) {
|
|
457
|
+
return `There is no browser on this machine that will run, so nothing that needs one can be checked.${brokenNote} This fixes it and needs nobody's permission: ${INSTALL_COMMAND}`;
|
|
458
|
+
}
|
|
459
|
+
if (borrowingHis) {
|
|
460
|
+
return (
|
|
461
|
+
`The only browser here is the one you use yourself (${chosen.binary}). It will be opened invisibly with a throwaway profile, so your own settings, cookies and tabs are never touched — but on a Mac it shares an application slot with your browser, so while a check is running, clicking your browser icon may wake this hidden copy instead of opening a window. ` +
|
|
462
|
+
`This fixes it and takes one command: ${INSTALL_COMMAND}${brokenNote}`
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
return `Checks open ${chosen.name}${chosen.version ? ` ${chosen.version}` : ''}, which is a separate application from the browser you use, so your own browser is never disturbed.${brokenNote}`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// The register — what we have open, and what we left behind
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Everything open right now, in this process.
|
|
474
|
+
* @type {Map<string, {id: string, pid: number|null, userDataDir: string, home: string, close: () => Promise<void>}>}
|
|
475
|
+
*/
|
|
476
|
+
const live = new Map();
|
|
477
|
+
|
|
478
|
+
/** Exit handlers are installed once, the first time a browser is opened. */
|
|
479
|
+
let guardsInstalled = false;
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Make sure nothing we opened outlives this process, however it ends.
|
|
483
|
+
*
|
|
484
|
+
* `exit` is the important one and the awkward one: by then nothing asynchronous
|
|
485
|
+
* will ever run again, so the only thing left is a synchronous kill and a
|
|
486
|
+
* synchronous delete. It is blunt, and being blunt at the very end is right —
|
|
487
|
+
* the alternative is a browser still running after the person's command has
|
|
488
|
+
* returned to their prompt.
|
|
489
|
+
*/
|
|
490
|
+
function installGuards() {
|
|
491
|
+
if (guardsInstalled) return;
|
|
492
|
+
guardsInstalled = true;
|
|
493
|
+
|
|
494
|
+
process.on('exit', () => {
|
|
495
|
+
for (const entry of live.values()) killNow(entry.pid, entry.home);
|
|
496
|
+
live.clear();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
for (const signal of /** @type {NodeJS.Signals[]} */ (['SIGINT', 'SIGTERM', 'SIGHUP'])) {
|
|
500
|
+
/** @type {NodeJS.SignalsListener} */
|
|
501
|
+
const onSignal = () => {
|
|
502
|
+
for (const entry of live.values()) killNow(entry.pid, entry.home);
|
|
503
|
+
live.clear();
|
|
504
|
+
// Take away OUR listener and nobody else's. A library that calls
|
|
505
|
+
// removeAllListeners has just deleted whatever the program around it was
|
|
506
|
+
// going to do about Ctrl-C, which is not this file's decision to make.
|
|
507
|
+
process.off(signal, onSignal);
|
|
508
|
+
// And only finish the job if nothing else was listening. If something was,
|
|
509
|
+
// it owns what happens next; a Ctrl-C that tidies up and then does not stop
|
|
510
|
+
// is its own kind of bug, and so is one that stops a shutdown halfway.
|
|
511
|
+
if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
|
|
512
|
+
};
|
|
513
|
+
process.on(signal, onSignal);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The last-resort cleanup: no promises, no awaiting, no politeness.
|
|
519
|
+
* @param {number|null} pid
|
|
520
|
+
* @param {string} home
|
|
521
|
+
*/
|
|
522
|
+
function killNow(pid, home) {
|
|
523
|
+
if (pid) {
|
|
524
|
+
try {
|
|
525
|
+
process.kill(pid, 'SIGKILL');
|
|
526
|
+
} catch {
|
|
527
|
+
// Already gone. That is the outcome we wanted.
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
try {
|
|
531
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
532
|
+
} catch {
|
|
533
|
+
// A profile left in the temporary folder is untidy, not harmful.
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
// Opening one
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The flags, and what each group is for.
|
|
543
|
+
*
|
|
544
|
+
* Every one of these removes a reason two runs of the same build could disagree
|
|
545
|
+
* for a cause that has nothing to do with the code. They are deliberately close
|
|
546
|
+
* to the version 1 list — that list was tuned against real pictures — with the
|
|
547
|
+
* profile and the port made non-negotiable here rather than optional there.
|
|
548
|
+
*
|
|
549
|
+
* @param {object} ctx
|
|
550
|
+
* @param {number|null} ctx.port Null when something else will choose how to talk to it.
|
|
551
|
+
* @param {string} ctx.userDataDir
|
|
552
|
+
* @param {boolean} ctx.headless
|
|
553
|
+
* @param {{width: number, height: number, deviceScaleFactor: number}} ctx.viewport
|
|
554
|
+
* @param {string[]} ctx.extra
|
|
555
|
+
* @returns {string[]}
|
|
556
|
+
*/
|
|
557
|
+
function argsFor(ctx) {
|
|
558
|
+
/** @type {string[]} */
|
|
559
|
+
const args = [];
|
|
560
|
+
if (ctx.headless) args.push('--headless=new');
|
|
561
|
+
if (ctx.port !== null) {
|
|
562
|
+
args.push(
|
|
563
|
+
`--remote-debugging-port=${ctx.port}`,
|
|
564
|
+
// Node's WebSocket sends no Origin header and modern Chrome refuses an
|
|
565
|
+
// unknown one. On a throwaway browser with a throwaway profile there is
|
|
566
|
+
// nothing here for anybody to reach.
|
|
567
|
+
'--remote-allow-origins=*'
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
args.push(`--user-data-dir=${ctx.userDataDir}`);
|
|
571
|
+
|
|
572
|
+
// Pixels land in the same place run after run.
|
|
573
|
+
args.push(
|
|
574
|
+
`--force-device-scale-factor=${ctx.viewport.deviceScaleFactor}`,
|
|
575
|
+
'--force-color-profile=srgb',
|
|
576
|
+
'--font-render-hinting=none',
|
|
577
|
+
'--disable-lcd-text',
|
|
578
|
+
'--disable-font-subpixel-positioning',
|
|
579
|
+
'--disable-gpu',
|
|
580
|
+
'--hide-scrollbars',
|
|
581
|
+
'--disable-smooth-scrolling',
|
|
582
|
+
'--force-prefers-reduced-motion'
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
// Nothing pops up, and the browser itself talks to nobody. The second half
|
|
586
|
+
// matters more than it looks: without it a run depends on somebody else's
|
|
587
|
+
// server being awake.
|
|
588
|
+
args.push(
|
|
589
|
+
'--no-first-run',
|
|
590
|
+
'--no-default-browser-check',
|
|
591
|
+
'--disable-extensions',
|
|
592
|
+
'--disable-background-networking',
|
|
593
|
+
'--disable-component-update',
|
|
594
|
+
'--disable-default-apps',
|
|
595
|
+
'--disable-sync',
|
|
596
|
+
'--metrics-recording-only',
|
|
597
|
+
'--disable-client-side-phishing-detection',
|
|
598
|
+
'--no-service-autorun',
|
|
599
|
+
'--password-store=basic',
|
|
600
|
+
'--use-mock-keychain',
|
|
601
|
+
'--mute-audio',
|
|
602
|
+
'--disable-notifications',
|
|
603
|
+
'--deny-permission-prompts'
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
// A hidden window counts as backgrounded, and Chrome slows backgrounded pages
|
|
607
|
+
// down. That turns "wait for the list to load" into a flaky timeout.
|
|
608
|
+
args.push('--disable-background-timer-throttling', '--disable-backgrounding-occluded-windows', '--disable-renderer-backgrounding', '--disable-ipc-flooding-protection', '--disable-dev-shm-usage');
|
|
609
|
+
|
|
610
|
+
if (process.platform === 'linux' && process.getuid?.() === 0) args.push('--no-sandbox');
|
|
611
|
+
|
|
612
|
+
args.push(`--window-size=${Math.round(ctx.viewport.width)},${Math.round(ctx.viewport.height)}`);
|
|
613
|
+
args.push(...ctx.extra);
|
|
614
|
+
// A blank page on purpose: the new-tab page talks to Google and looks
|
|
615
|
+
// different depending on who is signed in.
|
|
616
|
+
args.push('about:blank');
|
|
617
|
+
return args;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* A sortable, unique name for one opened browser. It is also the folder name, so
|
|
622
|
+
* a person listing the temporary folder can see when each one was started.
|
|
623
|
+
* @returns {string}
|
|
624
|
+
*/
|
|
625
|
+
function reservationId() {
|
|
626
|
+
return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + crypto.randomBytes(3).toString('hex');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* The record that makes a crashed run's browser findable afterwards.
|
|
631
|
+
*
|
|
632
|
+
* Written before the browser is known to be up, on purpose: a browser that hangs
|
|
633
|
+
* while starting is exactly the one somebody gives up on, and it has to still be
|
|
634
|
+
* findable by `staysfixed browsers --clean` after they do.
|
|
635
|
+
*
|
|
636
|
+
* @param {string} home
|
|
637
|
+
* @param {{id: string, pid: number|null, binary: string, userDataDir: string, port: number|null, startedAt: string, owner: number}} record
|
|
638
|
+
* @returns {Promise<void>}
|
|
639
|
+
*/
|
|
640
|
+
async function writeRecord(home, record) {
|
|
641
|
+
await fsp.writeFile(path.join(home, 'open.json'), JSON.stringify(record, null, 2)).catch(() => {});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* What the caller has to repeat to a person, when there is anything to repeat.
|
|
646
|
+
*
|
|
647
|
+
* Only ever about borrowing their own browser, because that is the only thing
|
|
648
|
+
* this tool does that a person could notice while it runs.
|
|
649
|
+
*
|
|
650
|
+
* @param {BrowserFound} chosen
|
|
651
|
+
* @param {boolean} headless What the caller asked for, not what it will get.
|
|
652
|
+
* @returns {string[]}
|
|
653
|
+
*/
|
|
654
|
+
function notesFor(chosen, headless) {
|
|
655
|
+
if (!chosen.everyday) return [];
|
|
656
|
+
/** @type {string[]} */
|
|
657
|
+
const notes = [
|
|
658
|
+
`This run is using your own browser (${chosen.binary}) because it is the only one here. Your profile is untouched — it runs on a throwaway one — but on a Mac it shares an application slot with your browser, so clicking your browser icon while this runs may wake this hidden copy instead of opening a window. One command fixes it for good: ${INSTALL_COMMAND}`,
|
|
659
|
+
];
|
|
660
|
+
if (!headless) {
|
|
661
|
+
// A VISIBLE copy of his own browser is the incident itself. It is downgraded
|
|
662
|
+
// rather than refused, because refusing would stop a check that is otherwise
|
|
663
|
+
// fine — and it says what it did instead of doing it quietly.
|
|
664
|
+
notes.push('It was asked to open a visible window and will run invisibly instead, because a visible copy of your own browser is exactly what took your browser away from you before.');
|
|
665
|
+
}
|
|
666
|
+
return notes;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* A browser this tool has reserved but is letting something else start.
|
|
671
|
+
*
|
|
672
|
+
* @typedef {object} Reservation
|
|
673
|
+
* @property {string} id
|
|
674
|
+
* @property {BrowserFound} browser Which browser to open. Never the person's, if there is any other.
|
|
675
|
+
* @property {string} userDataDir The throwaway profile to open it with.
|
|
676
|
+
* @property {string[]} args The hygiene flags, with no debugging port in them.
|
|
677
|
+
* @property {boolean} headless What it must actually run as, which is not always what was asked for.
|
|
678
|
+
* @property {string[]} notes Anything the caller has to repeat, in plain English.
|
|
679
|
+
* @property {(stop: () => Promise<void>, pid?: number|null) => void} startedBy
|
|
680
|
+
* Call this the moment the browser is open, handing over how to close it and its
|
|
681
|
+
* process id. From then on it is covered by everything below: closeEverything,
|
|
682
|
+
* the exit and Ctrl-C guards, and `staysfixed browsers --clean`.
|
|
683
|
+
* @property {() => Promise<void>} release Give the reservation back without ever opening anything.
|
|
684
|
+
*/
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Reserve a browser for something else to start.
|
|
688
|
+
*
|
|
689
|
+
* Playwright wants to launch the browser itself — it is how it gets its own
|
|
690
|
+
* protocol client, its route interception and its ARIA snapshots — and forcing it
|
|
691
|
+
* through {@link openBrowser} would mean giving all that up. So the choice, the
|
|
692
|
+
* throwaway profile, the flags and the bookkeeping stay here, and only the launch
|
|
693
|
+
* itself moves. That is the important half: whatever starts the process, the
|
|
694
|
+
* promises about the person's machine are the same ones, kept in the same place.
|
|
695
|
+
*
|
|
696
|
+
* The one thing a caller MUST do is call `startedBy` as soon as the browser is
|
|
697
|
+
* open. A browser nobody registered is a browser nothing will ever close.
|
|
698
|
+
*
|
|
699
|
+
* @param {object} [opts]
|
|
700
|
+
* @param {boolean} [opts.headless]
|
|
701
|
+
* @param {string} [opts.explicit]
|
|
702
|
+
* @param {{width?: number, height?: number, deviceScaleFactor?: number}} [opts.viewport]
|
|
703
|
+
* @param {BrowserSurvey} [opts.survey]
|
|
704
|
+
* @returns {Promise<Reservation>}
|
|
705
|
+
*/
|
|
706
|
+
export async function reserveBrowser(opts = {}) {
|
|
707
|
+
const headless = opts.headless !== false;
|
|
708
|
+
const survey = opts.survey ?? (await surveyBrowsers({ explicit: opts.explicit, headless }));
|
|
709
|
+
const chosen = survey.chosen;
|
|
710
|
+
if (!chosen) {
|
|
711
|
+
throw new StaysFixedError('There is no browser on this machine that Stays Fixed can open.', { hint: survey.note });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
installGuards();
|
|
715
|
+
|
|
716
|
+
const id = reservationId();
|
|
717
|
+
const home = path.join(SCRATCH_ROOT, id);
|
|
718
|
+
const userDataDir = path.join(home, 'profile');
|
|
719
|
+
await fsp.mkdir(userDataDir, { recursive: true });
|
|
720
|
+
|
|
721
|
+
const runHeadless = chosen.everyday ? true : headless;
|
|
722
|
+
const notes = notesFor(chosen, headless);
|
|
723
|
+
const viewport = { width: 1280, height: 800, deviceScaleFactor: 1, ...(opts.viewport ?? {}) };
|
|
724
|
+
// No debugging port: whatever starts the browser will pick its own way of
|
|
725
|
+
// talking to it, and a port we do not own is a port we must not name.
|
|
726
|
+
const args = argsFor({ port: null, userDataDir, headless: runHeadless, viewport, extra: [] });
|
|
727
|
+
|
|
728
|
+
await writeRecord(home, { id, pid: null, binary: chosen.binary, userDataDir, port: null, startedAt: new Date().toISOString(), owner: process.pid });
|
|
729
|
+
|
|
730
|
+
let handedOver = false;
|
|
731
|
+
/** @type {Promise<void>|null} */
|
|
732
|
+
let closing = null;
|
|
733
|
+
|
|
734
|
+
/** @param {() => Promise<void>} stop @param {number|null} [pid] */
|
|
735
|
+
const startedBy = (stop, pid = null) => {
|
|
736
|
+
handedOver = true;
|
|
737
|
+
void writeRecord(home, { id, pid, binary: chosen.binary, userDataDir, port: null, startedAt: new Date().toISOString(), owner: process.pid });
|
|
738
|
+
const close = () => {
|
|
739
|
+
closing ??= (async () => {
|
|
740
|
+
live.delete(id);
|
|
741
|
+
await stop().catch(() => {});
|
|
742
|
+
await fsp.rm(home, { recursive: true, force: true }).catch(() => {});
|
|
743
|
+
})();
|
|
744
|
+
return closing;
|
|
745
|
+
};
|
|
746
|
+
live.set(id, { id, pid, userDataDir, home, close });
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const release = async () => {
|
|
750
|
+
if (handedOver) {
|
|
751
|
+
const entry = live.get(id);
|
|
752
|
+
if (entry) await entry.close();
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
await fsp.rm(home, { recursive: true, force: true }).catch(() => {});
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
return { id, browser: chosen, userDataDir, args, headless: runHeadless, notes, startedBy, release };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Open a browser, register it, and hand back where to talk to it.
|
|
763
|
+
*
|
|
764
|
+
* It hands back an endpoint rather than a driven page on purpose. The web lane
|
|
765
|
+
* drives with Playwright and the Electron lane drives over the debugging socket
|
|
766
|
+
* directly; both need the same hygiene and neither should own it, so this owns
|
|
767
|
+
* the process and they own the driving.
|
|
768
|
+
*
|
|
769
|
+
* @param {object} [opts]
|
|
770
|
+
* @param {boolean} [opts.headless] Default true.
|
|
771
|
+
* @param {string} [opts.explicit] A specific binary to use.
|
|
772
|
+
* @param {{width?: number, height?: number, deviceScaleFactor?: number}} [opts.viewport]
|
|
773
|
+
* @param {string[]} [opts.args] Extra command line flags.
|
|
774
|
+
* @param {Record<string, string>} [opts.env]
|
|
775
|
+
* @param {string} [opts.timezone] IANA zone forced on the process before it starts.
|
|
776
|
+
* @param {string} [opts.locale] BCP-47 locale forced on the process before it starts.
|
|
777
|
+
* @param {AbortSignal} [opts.signal]
|
|
778
|
+
* @param {number} [opts.startTimeoutMs]
|
|
779
|
+
* @param {BrowserSurvey} [opts.survey] A survey already taken, so a run of many journeys
|
|
780
|
+
* does not re-probe the machine for every one.
|
|
781
|
+
* @returns {Promise<OpenBrowser>}
|
|
782
|
+
*/
|
|
783
|
+
export async function openBrowser(opts = {}) {
|
|
784
|
+
const headless = opts.headless !== false;
|
|
785
|
+
const survey = opts.survey ?? (await surveyBrowsers({ explicit: opts.explicit, headless }));
|
|
786
|
+
const chosen = survey.chosen;
|
|
787
|
+
|
|
788
|
+
if (!chosen) {
|
|
789
|
+
throw new StaysFixedError('There is no browser on this machine that Stays Fixed can open.', {
|
|
790
|
+
hint: `${survey.note}\nOr point at one you already have with STAYSFIXED_BROWSER=/path/to/the/browser.`,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (chosen.headlessOnly && !headless) {
|
|
794
|
+
throw new StaysFixedError(`${chosen.name} cannot show a window, and this run asked for a visible one.`, {
|
|
795
|
+
hint: `Install a full browser for testing and it will be used instead: ${INSTALL_COMMAND}`,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
installGuards();
|
|
800
|
+
|
|
801
|
+
const id = reservationId();
|
|
802
|
+
const home = path.join(SCRATCH_ROOT, id);
|
|
803
|
+
const userDataDir = path.join(home, 'profile');
|
|
804
|
+
await fsp.mkdir(userDataDir, { recursive: true });
|
|
805
|
+
|
|
806
|
+
let port = await freePort();
|
|
807
|
+
if (port === PORT_NEVER_USE) port = await freePort();
|
|
808
|
+
if (port === PORT_NEVER_USE) {
|
|
809
|
+
throw new StaysFixedError(`The operating system offered port ${PORT_NEVER_USE}, which Stays Fixed will not take.`, {
|
|
810
|
+
hint: 'Another session on this machine drives a signed-in browser on that port. Try the command again.',
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const notes = notesFor(chosen, headless);
|
|
815
|
+
|
|
816
|
+
const viewport = { width: 1280, height: 800, deviceScaleFactor: 1, ...(opts.viewport ?? {}) };
|
|
817
|
+
const runHeadless = chosen.everyday ? true : headless;
|
|
818
|
+
const args = argsFor({ port, userDataDir, headless: runHeadless, viewport, extra: opts.args ?? [] });
|
|
819
|
+
|
|
820
|
+
/** @type {NodeJS.ProcessEnv} */
|
|
821
|
+
const env = { ...process.env, ...(opts.env ?? {}) };
|
|
822
|
+
if (opts.timezone) env.TZ = opts.timezone;
|
|
823
|
+
if (opts.locale) {
|
|
824
|
+
const posix = opts.locale.replace('-', '_');
|
|
825
|
+
env.LANG = `${posix}.UTF-8`;
|
|
826
|
+
env.LC_ALL = `${posix}.UTF-8`;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const child = spawn(chosen.binary, args, { env, stdio: ['ignore', 'pipe', 'pipe'], signal: opts.signal });
|
|
830
|
+
const output = keepOutput(child);
|
|
831
|
+
|
|
832
|
+
await writeRecord(home, { id, pid: child.pid ?? null, binary: chosen.binary, userDataDir, port, startedAt: new Date().toISOString(), owner: process.pid });
|
|
833
|
+
|
|
834
|
+
/** @type {Promise<void>|null} */
|
|
835
|
+
let closing = null;
|
|
836
|
+
const close = () => {
|
|
837
|
+
closing ??= (async () => {
|
|
838
|
+
live.delete(id);
|
|
839
|
+
await stopProcess(child, GRACE_MS);
|
|
840
|
+
await fsp.rm(home, { recursive: true, force: true }).catch(() => {});
|
|
841
|
+
})();
|
|
842
|
+
return closing;
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
live.set(id, { id, pid: child.pid ?? null, userDataDir, home, close });
|
|
846
|
+
|
|
847
|
+
const diedEarly = /** @type {Promise<never>} */ (
|
|
848
|
+
new Promise((_resolve, reject) => {
|
|
849
|
+
child.once('error', (cause) => {
|
|
850
|
+
reject(new StaysFixedError(`Could not run the browser at ${chosen.binary}.`, { hint: survey.note, cause }));
|
|
851
|
+
});
|
|
852
|
+
child.once('exit', (code, signal) => {
|
|
853
|
+
reject(
|
|
854
|
+
new StaysFixedError(`The browser quit before it was ready (${signal ? `signal ${signal}` : `exit code ${code}`}).`, {
|
|
855
|
+
hint: output() ? `The last thing it said:\n${output()}` : undefined,
|
|
856
|
+
})
|
|
857
|
+
);
|
|
858
|
+
});
|
|
859
|
+
})
|
|
860
|
+
);
|
|
861
|
+
// Quitting later, on purpose, must not crash the process with an unhandled
|
|
862
|
+
// rejection. This catch does not stop the race below from seeing it.
|
|
863
|
+
diedEarly.catch(() => {});
|
|
864
|
+
|
|
865
|
+
const endpoint = `http://127.0.0.1:${port}`;
|
|
866
|
+
try {
|
|
867
|
+
/** @type {any} */
|
|
868
|
+
const version = await Promise.race([waitForEndpoint(endpoint, { timeoutMs: opts.startTimeoutMs ?? START_MS, intervalMs: 100 }), diedEarly]);
|
|
869
|
+
const wsUrl = version?.webSocketDebuggerUrl;
|
|
870
|
+
if (typeof wsUrl !== 'string' || wsUrl === '') {
|
|
871
|
+
throw new StaysFixedError('The browser answered but did not offer a debugging connection.');
|
|
872
|
+
}
|
|
873
|
+
return { id, browser: chosen, port, endpoint, webSocketDebuggerUrl: wsUrl, userDataDir, pid: child.pid ?? null, headless: runHeadless, notes, close };
|
|
874
|
+
} catch (e) {
|
|
875
|
+
await close();
|
|
876
|
+
if (isExpected(e)) throw e;
|
|
877
|
+
throw new StaysFixedError('The browser started but never answered, so nothing could be looked at.', {
|
|
878
|
+
hint: output() ? `The last thing it said:\n${output()}` : survey.note,
|
|
879
|
+
cause: e,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* How many browsers this process has open. For the run loop, and for a test that
|
|
886
|
+
* wants to prove none were left behind.
|
|
887
|
+
* @returns {number}
|
|
888
|
+
*/
|
|
889
|
+
export function openBrowserCount() {
|
|
890
|
+
return live.size;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Close every browser this process opened.
|
|
895
|
+
*
|
|
896
|
+
* The run loop calls this in its `finally`, so it runs on a clean finish, on a
|
|
897
|
+
* throw and on a cancel. It never throws: cleanup that can fail is cleanup that
|
|
898
|
+
* gets skipped.
|
|
899
|
+
*
|
|
900
|
+
* @returns {Promise<{closed: number}>}
|
|
901
|
+
*/
|
|
902
|
+
export async function closeEverything() {
|
|
903
|
+
const all = [...live.values()];
|
|
904
|
+
await Promise.all(all.map((entry) => entry.close().catch(() => {})));
|
|
905
|
+
live.clear();
|
|
906
|
+
return { closed: all.length };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
// Leftovers from a run that crashed
|
|
911
|
+
// ---------------------------------------------------------------------------
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* Browsers left behind by a run that died before it could tidy up.
|
|
915
|
+
*
|
|
916
|
+
* Found by the throwaway profile folder, never by name. A folder under
|
|
917
|
+
* {@link SCRATCH_ROOT} could only have been made by this tool, which is what
|
|
918
|
+
* makes it safe to act on — a browser is never touched because it looks like
|
|
919
|
+
* one of ours.
|
|
920
|
+
*
|
|
921
|
+
* @returns {Promise<Stray[]>}
|
|
922
|
+
*/
|
|
923
|
+
export async function findStrays() {
|
|
924
|
+
/** @type {Stray[]} */
|
|
925
|
+
const out = [];
|
|
926
|
+
/** @type {string[]} */
|
|
927
|
+
let ids = [];
|
|
928
|
+
try {
|
|
929
|
+
ids = await fsp.readdir(SCRATCH_ROOT);
|
|
930
|
+
} catch {
|
|
931
|
+
return out;
|
|
932
|
+
}
|
|
933
|
+
for (const id of ids) {
|
|
934
|
+
if (live.has(id)) continue;
|
|
935
|
+
const home = path.join(SCRATCH_ROOT, id);
|
|
936
|
+
/** @type {any} */
|
|
937
|
+
let record = null;
|
|
938
|
+
try {
|
|
939
|
+
record = JSON.parse(await fsp.readFile(path.join(home, 'open.json'), 'utf8'));
|
|
940
|
+
} catch {
|
|
941
|
+
record = null;
|
|
942
|
+
}
|
|
943
|
+
const pid = typeof record?.pid === 'number' ? record.pid : null;
|
|
944
|
+
const owner = typeof record?.owner === 'number' ? record.owner : null;
|
|
945
|
+
const userDataDir = typeof record?.userDataDir === 'string' ? record.userDataDir : path.join(home, 'profile');
|
|
946
|
+
out.push({
|
|
947
|
+
id,
|
|
948
|
+
pid,
|
|
949
|
+
running: pid !== null && (await isOurs(pid, userDataDir)),
|
|
950
|
+
// A leftover is only a leftover if the run that opened it is gone. Several
|
|
951
|
+
// Stays Fixed runs share this machine — on the owner's Mac, several agents
|
|
952
|
+
// do at once — and one of them clearing up must never reach into another
|
|
953
|
+
// one's live check and close the browser out from under it.
|
|
954
|
+
inUseByAnotherRun: owner !== null && owner !== process.pid && isAlive(owner),
|
|
955
|
+
owner,
|
|
956
|
+
userDataDir,
|
|
957
|
+
binary: typeof record?.binary === 'string' ? record.binary : '(not recorded)',
|
|
958
|
+
startedAt: typeof record?.startedAt === 'string' ? record.startedAt : '(not recorded)',
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* @param {number} pid
|
|
966
|
+
* @returns {boolean}
|
|
967
|
+
*/
|
|
968
|
+
function isAlive(pid) {
|
|
969
|
+
try {
|
|
970
|
+
process.kill(pid, 0);
|
|
971
|
+
return true;
|
|
972
|
+
} catch {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Is this process alive, AND is it one of ours?
|
|
979
|
+
*
|
|
980
|
+
* Both halves are required and the second is the important one. A process id is
|
|
981
|
+
* recycled by the operating system within hours; killing on a stale id alone
|
|
982
|
+
* would eventually kill something innocent. So the id has to still be running a
|
|
983
|
+
* command line containing a throwaway profile path that this tool created, and
|
|
984
|
+
* nothing else on the machine can be holding one of those.
|
|
985
|
+
*
|
|
986
|
+
* @param {number} pid
|
|
987
|
+
* @param {string} userDataDir
|
|
988
|
+
* @returns {Promise<boolean>}
|
|
989
|
+
*/
|
|
990
|
+
async function isOurs(pid, userDataDir) {
|
|
991
|
+
if (!userDataDir.startsWith(SCRATCH_ROOT)) return false;
|
|
992
|
+
if (!isAlive(pid)) return false;
|
|
993
|
+
if (process.platform === 'win32') {
|
|
994
|
+
// No cheap command line read here. The profile folder is under our own
|
|
995
|
+
// scratch root and the record was written by us, which is the same
|
|
996
|
+
// guarantee arrived at a different way.
|
|
997
|
+
return true;
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
const { stdout } = await exec('ps', ['-o', 'command=', '-p', String(pid)], { timeout: PROBE_MS, windowsHide: true });
|
|
1001
|
+
return String(stdout).includes(userDataDir);
|
|
1002
|
+
} catch {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Quit everything an earlier run left behind, and delete its throwaway profiles.
|
|
1009
|
+
*
|
|
1010
|
+
* This is what `staysfixed browsers --clean` runs. It is safe to run at any
|
|
1011
|
+
* time, including while a check is in progress: anything this process has open
|
|
1012
|
+
* is skipped, and anything else had to prove it was ours before being touched.
|
|
1013
|
+
*
|
|
1014
|
+
* @returns {Promise<{quit: Stray[], swept: Stray[], busy: Stray[], left: Stray[]}>}
|
|
1015
|
+
*/
|
|
1016
|
+
export async function cleanStrays() {
|
|
1017
|
+
const strays = await findStrays();
|
|
1018
|
+
/** @type {Stray[]} */
|
|
1019
|
+
const quit = [];
|
|
1020
|
+
/** @type {Stray[]} */
|
|
1021
|
+
const swept = [];
|
|
1022
|
+
/** @type {Stray[]} */
|
|
1023
|
+
const busy = [];
|
|
1024
|
+
/** @type {Stray[]} */
|
|
1025
|
+
const left = [];
|
|
1026
|
+
|
|
1027
|
+
for (const stray of strays) {
|
|
1028
|
+
if (stray.inUseByAnotherRun) {
|
|
1029
|
+
// Somebody else's check is still going. Their browser is not a leftover,
|
|
1030
|
+
// and closing it would break their run for the sake of tidiness.
|
|
1031
|
+
busy.push(stray);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
if (stray.running && stray.pid !== null) {
|
|
1035
|
+
try {
|
|
1036
|
+
process.kill(stray.pid, 'SIGTERM');
|
|
1037
|
+
} catch {
|
|
1038
|
+
// It went away between being listed and being asked. Fine.
|
|
1039
|
+
}
|
|
1040
|
+
await waitForGone(stray.pid, GRACE_MS);
|
|
1041
|
+
try {
|
|
1042
|
+
process.kill(stray.pid, 'SIGKILL');
|
|
1043
|
+
} catch {
|
|
1044
|
+
// Already gone, which is what we wanted.
|
|
1045
|
+
}
|
|
1046
|
+
quit.push(stray);
|
|
1047
|
+
} else {
|
|
1048
|
+
swept.push(stray);
|
|
1049
|
+
}
|
|
1050
|
+
try {
|
|
1051
|
+
await fsp.rm(path.join(SCRATCH_ROOT, stray.id), { recursive: true, force: true });
|
|
1052
|
+
} catch {
|
|
1053
|
+
left.push(stray);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
return { quit, swept, busy, left };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* @param {number} pid
|
|
1061
|
+
* @param {number} ms
|
|
1062
|
+
* @returns {Promise<void>}
|
|
1063
|
+
*/
|
|
1064
|
+
async function waitForGone(pid, ms) {
|
|
1065
|
+
const until = Date.now() + ms;
|
|
1066
|
+
for (;;) {
|
|
1067
|
+
try {
|
|
1068
|
+
process.kill(pid, 0);
|
|
1069
|
+
} catch {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (Date.now() > until) return;
|
|
1073
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
// ---------------------------------------------------------------------------
|
|
1078
|
+
// Words
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* The survey, said out loud. Used by doctor and by the command below, so an
|
|
1083
|
+
* agent and a person are never told different things about the same machine.
|
|
1084
|
+
*
|
|
1085
|
+
* @param {BrowserSurvey} survey
|
|
1086
|
+
* @returns {string[]}
|
|
1087
|
+
*/
|
|
1088
|
+
export function describeBrowsers(survey) {
|
|
1089
|
+
/** @type {string[]} */
|
|
1090
|
+
const lines = [];
|
|
1091
|
+
if (survey.found.length === 0) {
|
|
1092
|
+
lines.push('No browser was found on this machine at all.');
|
|
1093
|
+
} else {
|
|
1094
|
+
for (const b of survey.found) {
|
|
1095
|
+
const mark = b === survey.chosen ? '→' : ' ';
|
|
1096
|
+
const state = b.usable ? b.version ?? 'runs' : `will not run: ${b.broken ?? 'unknown'}`;
|
|
1097
|
+
lines.push(`${mark} ${b.name} — ${state}`);
|
|
1098
|
+
lines.push(` ${b.binary}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
lines.push('');
|
|
1102
|
+
lines.push(survey.note);
|
|
1103
|
+
return lines;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// ---------------------------------------------------------------------------
|
|
1107
|
+
// staysfixed browsers
|
|
1108
|
+
// ---------------------------------------------------------------------------
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* The command entry, in exactly the shape `src/v2/cli.js` already uses for
|
|
1112
|
+
* `check` and `doctor`. Wiring it up is one line there:
|
|
1113
|
+
*
|
|
1114
|
+
* import { BROWSERS_COMMAND } from './browsers.js';
|
|
1115
|
+
* ... browsers: BROWSERS_COMMAND, ...
|
|
1116
|
+
*
|
|
1117
|
+
* It is defined here rather than there because everything it does — knowing
|
|
1118
|
+
* which browser would be opened, and knowing which leftovers are safe to kill —
|
|
1119
|
+
* is this file's knowledge, and splitting it would put the safety rule in one
|
|
1120
|
+
* file and the kill in another.
|
|
1121
|
+
*/
|
|
1122
|
+
export const BROWSERS_COMMAND = {
|
|
1123
|
+
summary: 'Which browser checks open, and a way to clear up any this tool left running.',
|
|
1124
|
+
usage: 'staysfixed browsers [--clean] [--json]',
|
|
1125
|
+
describe:
|
|
1126
|
+
'Checks never open the browser you use if there is any other on this machine, and\nnever your profile. This says which one they would open and why, and lists any\nthat an interrupted run left behind.\n\n--clean quits those leftovers and deletes their throwaway profiles. It only ever\ntouches something started by this tool: anything it quits had to be running from\na scratch profile this tool created, so your own browser and anybody else’s\ncannot be caught by it.',
|
|
1127
|
+
options: /** @type {[string, string][]} */ ([
|
|
1128
|
+
['--clean', 'Quit anything an earlier run left behind, and delete its throwaway profile.'],
|
|
1129
|
+
['--json', 'The same answer as one object, and no prose. For agents.'],
|
|
1130
|
+
]),
|
|
1131
|
+
examples: ['staysfixed browsers', 'staysfixed browsers --clean'],
|
|
1132
|
+
spec: { booleans: ['clean', 'json'] },
|
|
1133
|
+
load: async () => ({ run: runBrowsersCommand }),
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* @param {{bool: (name: string) => boolean}} ctx
|
|
1138
|
+
* @returns {Promise<number>}
|
|
1139
|
+
*/
|
|
1140
|
+
export async function runBrowsersCommand(ctx) {
|
|
1141
|
+
const { say, ok, warn, blank, heading, paint, setLogLevel } = await import('../core/log.js');
|
|
1142
|
+
const { EXIT } = await import('../core/errors.js');
|
|
1143
|
+
|
|
1144
|
+
const clean = ctx.bool('clean');
|
|
1145
|
+
const survey = await surveyBrowsers();
|
|
1146
|
+
const strays = clean ? null : await findStrays();
|
|
1147
|
+
const cleaned = clean ? await cleanStrays() : null;
|
|
1148
|
+
|
|
1149
|
+
if (ctx.bool('json')) {
|
|
1150
|
+
setLogLevel({ quiet: true });
|
|
1151
|
+
process.stdout.write(
|
|
1152
|
+
JSON.stringify(
|
|
1153
|
+
{
|
|
1154
|
+
chosen: survey.chosen,
|
|
1155
|
+
borrowingYourOwnBrowser: survey.borrowingHis,
|
|
1156
|
+
note: survey.note,
|
|
1157
|
+
install: survey.install,
|
|
1158
|
+
found: survey.found,
|
|
1159
|
+
leftBehind: strays ?? [...(cleaned?.quit ?? []), ...(cleaned?.swept ?? [])],
|
|
1160
|
+
cleaned: cleaned
|
|
1161
|
+
? { quit: cleaned.quit.length, profilesRemoved: cleaned.quit.length + cleaned.swept.length, leftAloneBecauseAnotherRunOwnsThem: cleaned.busy.length, couldNotRemove: cleaned.left.length }
|
|
1162
|
+
: null,
|
|
1163
|
+
},
|
|
1164
|
+
null,
|
|
1165
|
+
2
|
|
1166
|
+
) + '\n'
|
|
1167
|
+
);
|
|
1168
|
+
return EXIT.ok;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
heading('Stays Fixed — the browser checks open');
|
|
1172
|
+
blank();
|
|
1173
|
+
for (const line of describeBrowsers(survey)) say(survey.borrowingHis && line === survey.note ? paint.yellow(line) : line);
|
|
1174
|
+
blank();
|
|
1175
|
+
|
|
1176
|
+
if (cleaned) {
|
|
1177
|
+
if (cleaned.quit.length === 0 && cleaned.swept.length === 0) {
|
|
1178
|
+
ok('Nothing was left behind. No browser from an earlier run is still running.');
|
|
1179
|
+
} else {
|
|
1180
|
+
if (cleaned.quit.length > 0) ok(`Quit ${cleaned.quit.length} browser${cleaned.quit.length === 1 ? '' : 's'} an earlier run left running.`);
|
|
1181
|
+
if (cleaned.swept.length > 0) ok(`Removed ${cleaned.swept.length} throwaway profile${cleaned.swept.length === 1 ? '' : 's'} whose browser had already stopped.`);
|
|
1182
|
+
for (const l of cleaned.left) warn(`Could not remove ${l.userDataDir} — delete it by hand when you get a moment.`);
|
|
1183
|
+
}
|
|
1184
|
+
if (cleaned.busy.length > 0) {
|
|
1185
|
+
say(
|
|
1186
|
+
paint.grey(
|
|
1187
|
+
` ${cleaned.busy.length} browser${cleaned.busy.length === 1 ? '' : 's'} left alone: another Stays Fixed run is still using ${cleaned.busy.length === 1 ? 'it' : 'them'}. ${cleaned.busy.length === 1 ? 'It is not a leftover' : 'They are not leftovers'} and closing ${cleaned.busy.length === 1 ? 'it' : 'them'} would break that run.`
|
|
1188
|
+
)
|
|
1189
|
+
);
|
|
1190
|
+
}
|
|
1191
|
+
} else if (strays && strays.length > 0) {
|
|
1192
|
+
const running = strays.filter((s) => s.running);
|
|
1193
|
+
warn(
|
|
1194
|
+
running.length > 0
|
|
1195
|
+
? `${running.length} browser${running.length === 1 ? '' : 's'} from an earlier run ${running.length === 1 ? 'is' : 'are'} still running. Run \`staysfixed browsers --clean\` to quit ${running.length === 1 ? 'it' : 'them'}.`
|
|
1196
|
+
: `${strays.length} throwaway profile${strays.length === 1 ? '' : 's'} from an earlier run ${strays.length === 1 ? 'is' : 'are'} still on disk. Run \`staysfixed browsers --clean\` to remove ${strays.length === 1 ? 'it' : 'them'}.`
|
|
1197
|
+
);
|
|
1198
|
+
} else {
|
|
1199
|
+
ok('Nothing was left behind by an earlier run.');
|
|
1200
|
+
}
|
|
1201
|
+
blank();
|
|
1202
|
+
return EXIT.ok;
|
|
1203
|
+
}
|