browser-commander 0.30.1 → 0.31.1
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 +12 -0
- package/README.md +13 -6
- package/package.json +2 -2
- package/scripts/run-tests.mjs +2 -2
- package/src/browser/launcher.js +6 -1
- package/src/browser/real-browser.js +16 -3
- package/src/core/commander-features.js +15 -3
- package/src/factory.js +1 -0
- package/src/traces/commander-actions.js +5 -1
- package/src/traces/debug-output.js +81 -0
- package/src/traces/debug-session.js +73 -33
- package/src/traces/links.js +15 -10
- package/tests/e2e/issue-166-policy.e2e.test.js +169 -0
- package/tests/helpers/debug-environment.js +17 -0
- package/tests/unit/browser/real-browser.test.js +29 -0
- package/tests/unit/traces/issue-162-defaults.test.js +5 -14
- package/tests/unit/traces/issue-166-policy.test.js +333 -0
- package/tests/unit/traces/links-compatibility.test.js +56 -0
- package/tests/unit/traces/links.test.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.31.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c8f52e3: Upgrade Links Notation to ^0.25.2 so consumers using 0.25.2 can share one copy without an override. Preserve trace text containing lone UTF-16 surrogates, verify export/import and streaming compatibility, and document the new empty/control-value literals for consumers upgrading older parsers.
|
|
8
|
+
|
|
9
|
+
## 0.31.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 2f5b1d4: Leave JavaScript launch and commander recording off by default for read-only-safe navigation. Opt in with debug:true, a debug object or BROWSER_COMMANDER_DEBUG=1. Add bounded managed recording directories, retention controls and stopDebug(), with cleanup limited to the session's owned child directory. Document migration from 0.30.x and application-owned signal teardown.
|
|
14
|
+
|
|
3
15
|
## 0.30.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -816,12 +816,19 @@ See [capture, debugging and reusable sessions](https://github.com/link-foundatio
|
|
|
816
816
|
for screenshots, recordings, full/text DOM links, network/HAR, rotation,
|
|
817
817
|
persistent browsers, early trigger readiness and engine capability limits.
|
|
818
818
|
|
|
819
|
-
JavaScript commanders and browser launches
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
819
|
+
JavaScript commanders and browser launches leave recording off by default and
|
|
820
|
+
support navigation in a read-only cwd. Request `debug: true`, `debug: { output }`
|
|
821
|
+
or `BROWSER_COMMANDER_DEBUG=1` for complete capture; an output hint alone does not
|
|
822
|
+
enable recording. `debug: false` or `BROWSER_COMMANDER_DEBUG=0` overrides automatic
|
|
823
|
+
capture. Explicit trace APIs remain available.
|
|
824
|
+
|
|
825
|
+
Use `debug: { directory: '/owned/evidence', retain: false }` for a private unique
|
|
826
|
+
session child with finite data/duration limits and cleanup on teardown. Await
|
|
827
|
+
`commander.ready` before using its raw page; `commander.tracePath` exposes the
|
|
828
|
+
recording path. Await `commander.destroy()` or launch `close()` in `finally`.
|
|
829
|
+
`stopDebug({ discard: true })` removes only a managed child; recordings are
|
|
830
|
+
retained by default. See the guide above for migration from 0.30.x, limits and
|
|
831
|
+
application-owned signal handling.
|
|
825
832
|
|
|
826
833
|
`startTrace()` records a session into a schema-versioned bundle any of the three
|
|
827
834
|
languages can read - the manifest, an ordered NDJSON timeline, per-checkpoint
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.1",
|
|
4
4
|
"description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"command-stream": "^2.0.2",
|
|
85
85
|
"csv-parse": "^7.0.3",
|
|
86
86
|
"gifenc": "^1.0.3",
|
|
87
|
-
"links-notation": "^0.
|
|
87
|
+
"links-notation": "^0.25.2",
|
|
88
88
|
"log-lazy": "^1.0.4",
|
|
89
89
|
"plist": "^5.0.0",
|
|
90
90
|
"test-anywhere": "^0.9.1",
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -177,8 +177,8 @@ function runNodeTests(options, testFiles) {
|
|
|
177
177
|
process.execPath,
|
|
178
178
|
buildNodeTestArgs(options, testFiles),
|
|
179
179
|
{
|
|
180
|
-
//
|
|
181
|
-
//
|
|
180
|
+
// Isolate unrelated fixtures from ambient debug opt-ins. Recording policy
|
|
181
|
+
// regressions explicitly clear this harness opt-out.
|
|
182
182
|
env: { BROWSER_COMMANDER_DEBUG: '0', ...process.env, ...options.env },
|
|
183
183
|
stdio: 'inherit',
|
|
184
184
|
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -8,7 +8,8 @@ import {
|
|
|
8
8
|
import { assertSupportedEngine } from './connector.js';
|
|
9
9
|
import { emulateMedia } from './media.js';
|
|
10
10
|
import { reserveLoopbackPort } from './debugging-port.js';
|
|
11
|
-
import { attachLaunchDebug } from '../traces/debug-session.js';
|
|
11
|
+
import { attachLaunchDebug, debugEnabled } from '../traces/debug-session.js';
|
|
12
|
+
import { validateDebugConfig } from '../traces/debug-output.js';
|
|
12
13
|
import {
|
|
13
14
|
buildPlaywrightLaunchOptions,
|
|
14
15
|
buildPuppeteerLaunchOptions,
|
|
@@ -307,6 +308,7 @@ async function unfocusAddressBar(page, verbose, settleMs = 500) {
|
|
|
307
308
|
* @param {Object} [options.fingerprint] - Environment fields to present to pages, such as userAgent, timezone, locale, hardwareConcurrency or screen. Applied over CDP after launch; see src/fingerprint/profile.js for the full field list and presets.js for ready-made profiles.
|
|
308
309
|
* @param {boolean} [options.automationParity=true] - Keep navigator.webdriver false where a launch switch would turn it on (headless or engine launches)
|
|
309
310
|
* @param {boolean|Object} [options.downloads] - Manage downloads: true for defaults, or {directory, persist, conflict}. The directory may be an absolute path, 'user-downloads' or 'temporary'.
|
|
311
|
+
* @param {boolean|Object} [options.debug=false] - Opt-in recording: true, {output}, or {directory, retain, maxDurationMs, limits} for managed storage
|
|
310
312
|
* @returns {Promise<{browser: Object, page: Object, downloads: (Object|null), close: function(): Promise<void>, launch: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>}>} Browser (a BrowserContext for Playwright), page, download manager and launch metadata
|
|
311
313
|
*/
|
|
312
314
|
export async function launchBrowser(options = {}) {
|
|
@@ -318,6 +320,9 @@ export async function launchBrowserWithDependencies(
|
|
|
318
320
|
options = {},
|
|
319
321
|
dependencies = {}
|
|
320
322
|
) {
|
|
323
|
+
if (debugEnabled(options) && typeof options.debug === 'object') {
|
|
324
|
+
validateDebugConfig(options.debug);
|
|
325
|
+
}
|
|
321
326
|
if (options.keepOpen) {
|
|
322
327
|
const { connectOrLaunch } = await import('./persistent-session.js');
|
|
323
328
|
return attachLaunchDebug(
|
|
@@ -10,6 +10,7 @@ import { launchSafari } from './safari-launch.js';
|
|
|
10
10
|
import { isSafariChannel } from './safari-support.js';
|
|
11
11
|
|
|
12
12
|
import { startProcess } from '../utilities/subprocess.js';
|
|
13
|
+
import { withDeadline } from '../traces/deadline.js';
|
|
13
14
|
import {
|
|
14
15
|
applyAutomationParityArgs,
|
|
15
16
|
detectAutomationControlledTriggers,
|
|
@@ -308,15 +309,26 @@ function createCloser({
|
|
|
308
309
|
temporaryProfile,
|
|
309
310
|
closeTimeout,
|
|
310
311
|
closeConnection,
|
|
312
|
+
verbose,
|
|
311
313
|
}) {
|
|
312
314
|
const originalClose = closeConnection ?? browser?.close;
|
|
313
315
|
let closing;
|
|
314
316
|
const close = () => {
|
|
315
317
|
closing ??= (async () => {
|
|
316
318
|
if (browserProcess.exitCode === null && originalClose) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
319
|
+
// A signal can sever the engine transport while its close RPC is pending.
|
|
320
|
+
// Keep the existing close budget active until owned-process cleanup runs.
|
|
321
|
+
await withDeadline(
|
|
322
|
+
requestBrowserClose({ engine, browser, originalClose }),
|
|
323
|
+
closeTimeout,
|
|
324
|
+
'browser close'
|
|
325
|
+
).catch((error) => {
|
|
326
|
+
if (verbose) {
|
|
327
|
+
console.debug(
|
|
328
|
+
`[browser-commander] Engine close failed: ${error.message}; finishing owned process cleanup`
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
320
332
|
}
|
|
321
333
|
if (!(await waitForExit(browserProcess, closeTimeout))) {
|
|
322
334
|
browserProcess.kill();
|
|
@@ -755,6 +767,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
755
767
|
temporaryProfile,
|
|
756
768
|
closeTimeout,
|
|
757
769
|
closeConnection: engine === 'selenium' ? connection.close : undefined,
|
|
770
|
+
verbose,
|
|
758
771
|
});
|
|
759
772
|
return await installSessionPersistence(
|
|
760
773
|
{
|
|
@@ -52,6 +52,14 @@ export function attachCommanderFeatures(commander, options, recreate) {
|
|
|
52
52
|
let debugSession;
|
|
53
53
|
let detachActions;
|
|
54
54
|
commander.tracePath = null;
|
|
55
|
+
commander.stopDebug = async (stopOptions) => {
|
|
56
|
+
await commander.ready;
|
|
57
|
+
try {
|
|
58
|
+
return (await debugSession?.stop(stopOptions)) ?? null;
|
|
59
|
+
} finally {
|
|
60
|
+
detachActions?.();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
55
63
|
const ensureDebug = () => commander.ready;
|
|
56
64
|
for (const name of [...INTERACTIONS, 'goto', 'evaluate', 'count']) {
|
|
57
65
|
const method = commander[name];
|
|
@@ -71,10 +79,14 @@ export function attachCommanderFeatures(commander, options, recreate) {
|
|
|
71
79
|
commander.destroy = async () => {
|
|
72
80
|
try {
|
|
73
81
|
await commander.ready;
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
82
|
+
try {
|
|
83
|
+
await commander.stopDebug();
|
|
84
|
+
} finally {
|
|
85
|
+
await Promise.all([...managed].map((item) => item.stop()));
|
|
86
|
+
detachActions?.();
|
|
87
|
+
}
|
|
77
88
|
} finally {
|
|
89
|
+
detachActions?.();
|
|
78
90
|
await destroy();
|
|
79
91
|
}
|
|
80
92
|
};
|
package/src/factory.js
CHANGED
|
@@ -33,6 +33,7 @@ import { createCdpSession } from './browser/cdp-session.js';
|
|
|
33
33
|
* @param {boolean} options.enableNetworkTracking - Enable network request tracking (default: true)
|
|
34
34
|
* @param {boolean} options.enableNavigationManager - Enable navigation manager (default: true)
|
|
35
35
|
* @param {Object} [options.downloads] - A download manager to expose as commander.downloads, such as the one launchBrowser() returned
|
|
36
|
+
* @param {boolean|Object} [options.debug=false] - Opt-in recording: true, {output}, or {directory, retain, maxDurationMs, limits} for managed storage
|
|
36
37
|
* @returns {Object} - Browser commander API
|
|
37
38
|
*/
|
|
38
39
|
export function makeBrowserCommander(options = {}) {
|
|
@@ -141,6 +141,10 @@ export function observeCommanderActions({
|
|
|
141
141
|
commander[name] = original;
|
|
142
142
|
}
|
|
143
143
|
}
|
|
144
|
-
|
|
144
|
+
// A repeated teardown may belong to an older, already stopped recording.
|
|
145
|
+
// Preserve observers installed by a newer explicit trace on this commander.
|
|
146
|
+
if (commanders.get(commander) === state) {
|
|
147
|
+
commanders.delete(commander);
|
|
148
|
+
}
|
|
145
149
|
};
|
|
146
150
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { openTraceBundle } from './bundle.js';
|
|
5
|
+
|
|
6
|
+
/** Managed debug sessions own only a fresh child of the caller's storage root. */
|
|
7
|
+
export function validateDebugConfig(config = {}) {
|
|
8
|
+
if (config.retain !== undefined && typeof config.retain !== 'boolean') {
|
|
9
|
+
throw new TypeError('debug retain must be a boolean');
|
|
10
|
+
}
|
|
11
|
+
if (config.retain === false && config.directory === undefined) {
|
|
12
|
+
throw new TypeError('debug retain:false requires a managed directory');
|
|
13
|
+
}
|
|
14
|
+
if (config.directory === undefined) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
if (typeof config.directory !== 'string' || config.directory === '') {
|
|
18
|
+
throw new TypeError('debug managed directory must be a path');
|
|
19
|
+
}
|
|
20
|
+
if (config.output !== undefined || config.limits?.rotate) {
|
|
21
|
+
throw new TypeError(
|
|
22
|
+
'debug managed directory cannot use output or rotation'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (config.links && config.links !== false) {
|
|
26
|
+
throw new TypeError('debug managed directory uses links:false');
|
|
27
|
+
}
|
|
28
|
+
if (typeof config.network?.har === 'string') {
|
|
29
|
+
throw new TypeError('debug managed HAR must stay inside its directory');
|
|
30
|
+
}
|
|
31
|
+
for (const [name, value] of Object.entries({
|
|
32
|
+
maxBundleBytes: config.limits?.maxBundleBytes ?? 8 * 1024 * 1024,
|
|
33
|
+
maxResourceBytes: config.limits?.maxResourceBytes ?? 1024 * 1024,
|
|
34
|
+
maxDurationMs: config.maxDurationMs ?? 60000,
|
|
35
|
+
})) {
|
|
36
|
+
if (!Number.isSafeInteger(value) || value <= 0 || value > 2147483647) {
|
|
37
|
+
throw new RangeError(
|
|
38
|
+
`debug managed ${name} must be a finite positive integer`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function prepareDebugOutput(options, config) {
|
|
45
|
+
if (config.directory !== undefined) {
|
|
46
|
+
const directory = path.resolve(config.directory);
|
|
47
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
48
|
+
const output = await fs.mkdtemp(path.join(directory, 'session-'));
|
|
49
|
+
// No unbounded side export or rotation bypasses the managed bundle budget.
|
|
50
|
+
return {
|
|
51
|
+
output,
|
|
52
|
+
owned: true,
|
|
53
|
+
maxDurationMs: config.maxDurationMs ?? 60000,
|
|
54
|
+
traceOptions: {
|
|
55
|
+
...config,
|
|
56
|
+
// Continuous traces otherwise rotate automatically when the budget fills.
|
|
57
|
+
openBundle: openTraceBundle,
|
|
58
|
+
links: false,
|
|
59
|
+
network: config.network ?? { har: false },
|
|
60
|
+
limits: {
|
|
61
|
+
...config.limits,
|
|
62
|
+
maxBundleBytes: config.limits?.maxBundleBytes ?? 8 * 1024 * 1024,
|
|
63
|
+
maxResourceBytes: config.limits?.maxResourceBytes ?? 1024 * 1024,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
output: path.resolve(
|
|
70
|
+
config.output ??
|
|
71
|
+
options.output ??
|
|
72
|
+
process.env.BROWSER_COMMANDER_TRACE ??
|
|
73
|
+
path.join(
|
|
74
|
+
'browser-commander-traces',
|
|
75
|
+
`${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
76
|
+
)
|
|
77
|
+
),
|
|
78
|
+
owned: false,
|
|
79
|
+
traceOptions: config,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -1,15 +1,23 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
3
2
|
import { startTrace } from './recorder.js';
|
|
3
|
+
import { prepareDebugOutput, validateDebugConfig } from './debug-output.js';
|
|
4
4
|
|
|
5
5
|
const sessions = new WeakMap();
|
|
6
6
|
const disabledPages = new WeakSet();
|
|
7
7
|
|
|
8
8
|
export function debugEnabled(options = {}) {
|
|
9
|
+
if (options.debug === false || process.env.BROWSER_COMMANDER_DEBUG === '0') {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (options.debug === undefined && disabledPages.has(options.page)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
9
15
|
return (
|
|
10
|
-
options.debug
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
options.debug === true ||
|
|
17
|
+
(options.debug !== null && typeof options.debug === 'object') ||
|
|
18
|
+
(options.debug === undefined &&
|
|
19
|
+
(sessions.has(options.page) ||
|
|
20
|
+
process.env.BROWSER_COMMANDER_DEBUG === '1'))
|
|
13
21
|
);
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -18,7 +26,9 @@ export async function ensureDebugSession(options) {
|
|
|
18
26
|
const { page, commander } = options;
|
|
19
27
|
const existing = sessions.get(page);
|
|
20
28
|
if (!debugEnabled(options)) {
|
|
21
|
-
|
|
29
|
+
if (options.debug === false) {
|
|
30
|
+
disabledPages.add(page);
|
|
31
|
+
}
|
|
22
32
|
await existing?.stop();
|
|
23
33
|
return null;
|
|
24
34
|
}
|
|
@@ -27,50 +37,78 @@ export async function ensureDebugSession(options) {
|
|
|
27
37
|
if (!session) {
|
|
28
38
|
const config =
|
|
29
39
|
options.debug && typeof options.debug === 'object' ? options.debug : {};
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
options.output ??
|
|
33
|
-
process.env.BROWSER_COMMANDER_TRACE ??
|
|
34
|
-
path.join(
|
|
35
|
-
'browser-commander-traces',
|
|
36
|
-
`${new Date().toISOString().replaceAll(':', '-')}-${process.pid}-${randomUUID().slice(0, 8)}`
|
|
37
|
-
)
|
|
38
|
-
);
|
|
39
|
-
session = { path: output };
|
|
40
|
+
validateDebugConfig(config);
|
|
41
|
+
session = { path: null, owned: false };
|
|
40
42
|
sessions.set(page, session);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
43
|
+
let timer;
|
|
44
|
+
session.ready = prepareDebugOutput(options, config)
|
|
45
|
+
.then(async (prepared) => {
|
|
46
|
+
session.path = prepared.output;
|
|
47
|
+
session.owned = prepared.owned;
|
|
48
|
+
const trace = await startTrace({
|
|
49
|
+
...prepared.traceOptions,
|
|
50
|
+
output: prepared.output,
|
|
51
|
+
page,
|
|
52
|
+
commander: commander ?? {
|
|
53
|
+
page,
|
|
54
|
+
engine: options.engine,
|
|
55
|
+
downloads: options.downloads,
|
|
56
|
+
},
|
|
57
|
+
checkpointOnNavigation: config.checkpointOnNavigation ?? true,
|
|
58
|
+
});
|
|
59
|
+
console.error(
|
|
60
|
+
`[browser-commander] Recording session to ${session.path}`
|
|
61
|
+
);
|
|
62
|
+
if (prepared.maxDurationMs) {
|
|
63
|
+
timer = setTimeout(() => {
|
|
64
|
+
session
|
|
65
|
+
.stop()
|
|
66
|
+
.catch((error) =>
|
|
67
|
+
console.warn(
|
|
68
|
+
`[browser-commander] Could not finish recording: ${error.message}`
|
|
69
|
+
)
|
|
70
|
+
);
|
|
71
|
+
}, prepared.maxDurationMs);
|
|
72
|
+
timer.unref?.();
|
|
73
|
+
}
|
|
54
74
|
return trace;
|
|
55
75
|
})
|
|
56
|
-
.catch((error) => {
|
|
76
|
+
.catch(async (error) => {
|
|
57
77
|
sessions.delete(page);
|
|
78
|
+
if (session.owned) {
|
|
79
|
+
await fs.rm(session.path, { recursive: true, force: true });
|
|
80
|
+
}
|
|
58
81
|
console.warn(
|
|
59
82
|
`[browser-commander] Could not start recording: ${error.message}`
|
|
60
83
|
);
|
|
61
84
|
return null;
|
|
62
85
|
});
|
|
63
86
|
let stopping;
|
|
64
|
-
session.stop = () =>
|
|
65
|
-
|
|
87
|
+
session.stop = (stopOptions = {}) => {
|
|
88
|
+
const discard = stopOptions.discard ?? config.retain === false;
|
|
89
|
+
if (discard && !session.owned && session.path) {
|
|
90
|
+
return Promise.reject(
|
|
91
|
+
new Error('Only a managed debug directory can be discarded')
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return (stopping ??= session.ready.then(async (trace) => {
|
|
95
|
+
if (discard && trace && !session.owned) {
|
|
96
|
+
throw new Error('Only a managed debug directory can be discarded');
|
|
97
|
+
}
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
disabledPages.add(page);
|
|
66
100
|
try {
|
|
67
|
-
return await trace?.stop();
|
|
101
|
+
return await trace?.stop({ ...stopOptions, discard });
|
|
68
102
|
} finally {
|
|
69
103
|
if (sessions.get(page) === session) {
|
|
70
104
|
sessions.delete(page);
|
|
71
105
|
}
|
|
106
|
+
if (discard && session.owned) {
|
|
107
|
+
await fs.rm(session.path, { recursive: true, force: true });
|
|
108
|
+
}
|
|
72
109
|
}
|
|
73
110
|
}));
|
|
111
|
+
};
|
|
74
112
|
}
|
|
75
113
|
const trace = await session.ready;
|
|
76
114
|
if (!trace) {
|
|
@@ -93,6 +131,8 @@ export async function attachLaunchDebug(result, options) {
|
|
|
93
131
|
downloads: result.downloads,
|
|
94
132
|
});
|
|
95
133
|
result.tracePath = session?.path ?? null;
|
|
134
|
+
result.stopDebug = (options) =>
|
|
135
|
+
session?.stop(options) ?? Promise.resolve(null);
|
|
96
136
|
if (!session) {
|
|
97
137
|
return result;
|
|
98
138
|
}
|
package/src/traces/links.js
CHANGED
|
@@ -107,11 +107,11 @@ const HEAD_FIELDS = new Set([
|
|
|
107
107
|
/**
|
|
108
108
|
* Make one value safe to write as a link.
|
|
109
109
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
110
|
+
* Keep the representation's legacy escapes even though current Links Notation
|
|
111
|
+
* can quote both quote characters. Newlines must not split a record across
|
|
112
|
+
* lines. Lone UTF-16 surrogates are escaped too: the notation requires valid
|
|
113
|
+
* Unicode, but JavaScript trace strings can hold individual surrogate units.
|
|
114
|
+
* All these encodings are reversible, so the export loses no scalar text.
|
|
115
115
|
*
|
|
116
116
|
* @param {*} value - Any scalar from a trace record
|
|
117
117
|
* @returns {string} Text that survives a format and parse round trip
|
|
@@ -122,9 +122,13 @@ export function encodeLinkText(value) {
|
|
|
122
122
|
.replaceAll('\\', '\\\\')
|
|
123
123
|
.replaceAll('\n', '\\n')
|
|
124
124
|
.replaceAll('\r', '\\r')
|
|
125
|
-
.replaceAll('\t', '\\t')
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
.replaceAll('\t', '\\t')
|
|
126
|
+
// Unicode mode matches lone surrogates without splitting valid pairs.
|
|
127
|
+
.replace(
|
|
128
|
+
/[\ud800-\udfff]/gu,
|
|
129
|
+
(unit) => `\\u${unit.charCodeAt(0).toString(16)}`
|
|
130
|
+
);
|
|
131
|
+
// Preserve the existing encoding for values holding both quote characters.
|
|
128
132
|
return escaped.includes("'") && escaped.includes('"')
|
|
129
133
|
? escaped.replaceAll('"', '\\u0022')
|
|
130
134
|
: escaped;
|
|
@@ -139,8 +143,9 @@ export function encodeLinkText(value) {
|
|
|
139
143
|
export function decodeLinkText(text) {
|
|
140
144
|
const decoded = { '\\': '\\', n: '\n', r: '\r', t: '\t', u0022: '"' };
|
|
141
145
|
return String(text).replace(
|
|
142
|
-
/\\(\\|n|r|t|u0022)/g,
|
|
143
|
-
(match, code) =>
|
|
146
|
+
/\\(\\|n|r|t|u0022|ud[89a-f][0-9a-f]{2})/g,
|
|
147
|
+
(match, code) =>
|
|
148
|
+
decoded[code] ?? String.fromCharCode(Number.parseInt(code.slice(1), 16))
|
|
144
149
|
);
|
|
145
150
|
}
|
|
146
151
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { clearDebugEnvironment } from '../helpers/debug-environment.js';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { once } from 'node:events';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
11
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
12
|
+
import { readTrace } from '../../src/traces/reader.js';
|
|
13
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
14
|
+
|
|
15
|
+
function navigate(commander, url, timeout = 5000) {
|
|
16
|
+
return commander.goto({
|
|
17
|
+
url,
|
|
18
|
+
timeout,
|
|
19
|
+
waitForStableUrlBefore: false,
|
|
20
|
+
waitForStableUrlAfter: false,
|
|
21
|
+
waitForNetworkIdle: false,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fixture(run) {
|
|
26
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-readonly-'));
|
|
27
|
+
const cwd = process.cwd();
|
|
28
|
+
const restoreEnvironment = clearDebugEnvironment();
|
|
29
|
+
const readonly = path.join(root, 'checkout');
|
|
30
|
+
const evidence = path.join(root, 'evidence');
|
|
31
|
+
await fs.mkdir(readonly);
|
|
32
|
+
await fs.mkdir(evidence);
|
|
33
|
+
await fs.writeFile(path.join(evidence, 'keep.txt'), 'caller evidence');
|
|
34
|
+
await fs.chmod(readonly, 0o555);
|
|
35
|
+
process.chdir(readonly);
|
|
36
|
+
try {
|
|
37
|
+
if (process.platform !== 'win32') {
|
|
38
|
+
await assert.rejects(fs.access(readonly, fs.constants.W_OK));
|
|
39
|
+
}
|
|
40
|
+
await run({ readonly, evidence });
|
|
41
|
+
assert.deepEqual(await fs.readdir(readonly), []);
|
|
42
|
+
} finally {
|
|
43
|
+
process.chdir(cwd);
|
|
44
|
+
restoreEnvironment();
|
|
45
|
+
await fs.chmod(readonly, 0o700);
|
|
46
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
51
|
+
it(
|
|
52
|
+
`navigates in read-only cwd and cleans managed recording with ${engine}`,
|
|
53
|
+
{
|
|
54
|
+
skip: process.env.RUN_E2E !== 'true',
|
|
55
|
+
timeout: 90000,
|
|
56
|
+
},
|
|
57
|
+
async () => {
|
|
58
|
+
await fixture(async ({ evidence }) => {
|
|
59
|
+
let launched, commander;
|
|
60
|
+
const managedCommander = () =>
|
|
61
|
+
makeBrowserCommander({
|
|
62
|
+
page: launched.page,
|
|
63
|
+
debug: { directory: evidence, retain: false },
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
launched = await launchBrowser({
|
|
67
|
+
engine,
|
|
68
|
+
headless: true,
|
|
69
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
70
|
+
});
|
|
71
|
+
commander = makeBrowserCommander({ page: launched.page });
|
|
72
|
+
await navigate(
|
|
73
|
+
commander,
|
|
74
|
+
'data:text/html,<h1>No recording requested</h1>'
|
|
75
|
+
);
|
|
76
|
+
assert.equal(launched.tracePath, null);
|
|
77
|
+
assert.equal(commander.tracePath, null);
|
|
78
|
+
await commander.destroy();
|
|
79
|
+
commander = managedCommander();
|
|
80
|
+
await navigate(commander, 'data:text/html,<h1>Owned recording</h1>');
|
|
81
|
+
assert.equal(path.dirname(commander.tracePath), evidence);
|
|
82
|
+
await commander.stopDebug({ discard: false });
|
|
83
|
+
const trace = await readTrace(commander.tracePath);
|
|
84
|
+
assert.equal(trace.manifest.outcome, 'complete');
|
|
85
|
+
assert.ok(trace.events.some((event) => event.source === 'commander'));
|
|
86
|
+
// First stop determines retention; retained output is now caller-owned.
|
|
87
|
+
await fs.rm(commander.tracePath, { recursive: true });
|
|
88
|
+
await commander.destroy();
|
|
89
|
+
commander = managedCommander();
|
|
90
|
+
await commander.ready;
|
|
91
|
+
await assert.rejects(
|
|
92
|
+
navigate(commander, 'http://127.0.0.1:1/'),
|
|
93
|
+
/ERR_UNSAFE_PORT/
|
|
94
|
+
);
|
|
95
|
+
} finally {
|
|
96
|
+
try {
|
|
97
|
+
await commander?.destroy();
|
|
98
|
+
} finally {
|
|
99
|
+
await launched?.close();
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
assert.deepEqual(await fs.readdir(evidence), ['keep.txt']);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
109
|
+
it(
|
|
110
|
+
`application signal handler cleans managed recording on ${signal}`,
|
|
111
|
+
{
|
|
112
|
+
skip: process.env.RUN_E2E !== 'true' || process.platform === 'win32',
|
|
113
|
+
timeout: 90000,
|
|
114
|
+
},
|
|
115
|
+
async () => {
|
|
116
|
+
await fixture(async ({ readonly, evidence }) => {
|
|
117
|
+
const script = fileURLToPath(
|
|
118
|
+
new URL(
|
|
119
|
+
'../../../examples/managed-debug-session.mjs',
|
|
120
|
+
import.meta.url
|
|
121
|
+
)
|
|
122
|
+
);
|
|
123
|
+
const child = spawn(
|
|
124
|
+
process.execPath,
|
|
125
|
+
[script, evidence, '--wait-for-signal'],
|
|
126
|
+
{
|
|
127
|
+
cwd: readonly,
|
|
128
|
+
env: process.env,
|
|
129
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
const exited = once(child, 'exit');
|
|
133
|
+
let output = '',
|
|
134
|
+
errors = '';
|
|
135
|
+
child.stderr.on('data', (data) => {
|
|
136
|
+
errors += data;
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await new Promise((resolve, reject) => {
|
|
140
|
+
child.stdout.on('data', (data) => {
|
|
141
|
+
output += data;
|
|
142
|
+
if (output.includes('"ready":true')) {
|
|
143
|
+
resolve();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
child.once('error', reject);
|
|
147
|
+
child.once('exit', (code) =>
|
|
148
|
+
reject(new Error(`Child exited ${code}: ${errors}`))
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
const { tracePath } = JSON.parse(
|
|
152
|
+
output.split('\n').find((line) => line.includes('"ready":true'))
|
|
153
|
+
);
|
|
154
|
+
assert.equal(path.dirname(tracePath), evidence);
|
|
155
|
+
child.kill(signal);
|
|
156
|
+
const [code, killed] = await exited;
|
|
157
|
+
assert.equal(code, 0, errors);
|
|
158
|
+
assert.equal(killed, null);
|
|
159
|
+
assert.deepEqual(await fs.readdir(evidence), ['keep.txt']);
|
|
160
|
+
} finally {
|
|
161
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
162
|
+
child.kill('SIGTERM');
|
|
163
|
+
await exited;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Isolate explicit debug policy tests from the runner's environment opt-out. */
|
|
2
|
+
export function clearDebugEnvironment() {
|
|
3
|
+
const names = ['BROWSER_COMMANDER_DEBUG', 'BROWSER_COMMANDER_TRACE'];
|
|
4
|
+
const previous = names.map((name) => process.env[name]);
|
|
5
|
+
names.forEach((name) => {
|
|
6
|
+
delete process.env[name];
|
|
7
|
+
});
|
|
8
|
+
return () => {
|
|
9
|
+
names.forEach((name, index) => {
|
|
10
|
+
if (previous[index] === undefined) {
|
|
11
|
+
delete process.env[name];
|
|
12
|
+
} else {
|
|
13
|
+
process.env[name] = previous[index];
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -460,6 +460,35 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
460
460
|
await assert.rejects(() => access(result.userDataDir), /ENOENT/);
|
|
461
461
|
});
|
|
462
462
|
|
|
463
|
+
it(
|
|
464
|
+
'finishes owned cleanup when the engine close stalls after interruption',
|
|
465
|
+
{ timeout: 1000 },
|
|
466
|
+
async () => {
|
|
467
|
+
const calls = [];
|
|
468
|
+
const browserProcess = fakeProcess(calls);
|
|
469
|
+
const result = await launchAndConnectRealBrowserWithDependencies(
|
|
470
|
+
{ engine: 'puppeteer', closeTimeout: 20 },
|
|
471
|
+
{
|
|
472
|
+
resolveExecutable: async () => '/opt/google/chrome',
|
|
473
|
+
reservePort: async () => 40005,
|
|
474
|
+
spawnBrowser: () => browserProcess,
|
|
475
|
+
waitForEndpoint: async () => 'http://127.0.0.1:40005',
|
|
476
|
+
connect: async () => ({
|
|
477
|
+
browser: { close: () => new Promise(() => {}) },
|
|
478
|
+
page: null,
|
|
479
|
+
}),
|
|
480
|
+
}
|
|
481
|
+
);
|
|
482
|
+
temporaryDirectory = result.userDataDir;
|
|
483
|
+
await result.close();
|
|
484
|
+
assert.deepEqual(
|
|
485
|
+
{ calls, exitCode: browserProcess.exitCode },
|
|
486
|
+
{ calls: [['kill']], exitCode: 143 }
|
|
487
|
+
);
|
|
488
|
+
await assert.rejects(() => access(result.userDataDir), /ENOENT/);
|
|
489
|
+
}
|
|
490
|
+
);
|
|
491
|
+
|
|
463
492
|
it('passes restriction environment to the browser only', async () => {
|
|
464
493
|
const before = process.env.GOOGLE_API_KEY;
|
|
465
494
|
let spawnOptions;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearDebugEnvironment } from '../../helpers/debug-environment.js';
|
|
1
2
|
import { describe, it } from 'node:test';
|
|
2
3
|
import assert from 'node:assert/strict';
|
|
3
4
|
import fs from 'node:fs/promises';
|
|
@@ -19,23 +20,12 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
|
|
|
19
20
|
const output = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-defaults-'));
|
|
20
21
|
const page = createFakePage();
|
|
21
22
|
const commander = createFakeCommander(page, { destroy: async () => {} });
|
|
22
|
-
const
|
|
23
|
-
delete process.env.BROWSER_COMMANDER_DEBUG;
|
|
24
|
-
delete process.env.BROWSER_COMMANDER_TRACE;
|
|
23
|
+
const restoreEnvironment = clearDebugEnvironment();
|
|
25
24
|
try {
|
|
26
25
|
await run({ output, page, commander });
|
|
27
26
|
} finally {
|
|
28
27
|
await commander.destroy();
|
|
29
|
-
|
|
30
|
-
'BROWSER_COMMANDER_DEBUG',
|
|
31
|
-
'BROWSER_COMMANDER_TRACE',
|
|
32
|
-
]) {
|
|
33
|
-
if (previous[key] === undefined) {
|
|
34
|
-
delete process.env[key];
|
|
35
|
-
} else {
|
|
36
|
-
process.env[key] = previous[key];
|
|
37
|
-
}
|
|
38
|
-
}
|
|
28
|
+
restoreEnvironment();
|
|
39
29
|
await fs.rm(output, { recursive: true, force: true });
|
|
40
30
|
}
|
|
41
31
|
}
|
|
@@ -112,7 +102,7 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
|
|
|
112
102
|
});
|
|
113
103
|
});
|
|
114
104
|
|
|
115
|
-
it('records the very first call with
|
|
105
|
+
it('records the very first call with debug:true and chooses a unique session directory', async () => {
|
|
116
106
|
await fixture(async ({ output, page }) => {
|
|
117
107
|
const cwd = process.cwd();
|
|
118
108
|
process.chdir(output);
|
|
@@ -123,6 +113,7 @@ describe('complete recording defaults (issue #162)', { timeout: 15000 }, () => {
|
|
|
123
113
|
try {
|
|
124
114
|
commander = makeBrowserCommander({
|
|
125
115
|
page,
|
|
116
|
+
debug: true,
|
|
126
117
|
enableNetworkTracking: false,
|
|
127
118
|
enableNavigationManager: false,
|
|
128
119
|
enableDialogManager: false,
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { clearDebugEnvironment } from '../../helpers/debug-environment.js';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { attachCommanderFeatures } from '../../../src/core/commander-features.js';
|
|
8
|
+
import { attachLaunchDebug } from '../../../src/traces/debug-session.js';
|
|
9
|
+
import { readTrace } from '../../../src/traces/reader.js';
|
|
10
|
+
import { launchBrowserWithDependencies } from '../../../src/browser/launcher.js';
|
|
11
|
+
import {
|
|
12
|
+
createFakePage,
|
|
13
|
+
createFakeCommander,
|
|
14
|
+
} from '../../helpers/trace-fixtures.js';
|
|
15
|
+
|
|
16
|
+
describe('opt-in recording policy (issue #166)', { timeout: 15000 }, () => {
|
|
17
|
+
async function fixture(run) {
|
|
18
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-policy-'));
|
|
19
|
+
const page = createFakePage();
|
|
20
|
+
const commander = createFakeCommander(page, { destroy: async () => {} });
|
|
21
|
+
const restoreEnvironment = clearDebugEnvironment();
|
|
22
|
+
const cwd = process.cwd();
|
|
23
|
+
process.chdir(root);
|
|
24
|
+
try {
|
|
25
|
+
const configureDebug = async (debug) => {
|
|
26
|
+
attachCommanderFeatures(commander, { debug }, () => {});
|
|
27
|
+
await commander.ready;
|
|
28
|
+
};
|
|
29
|
+
await run({ root, page, commander, configureDebug });
|
|
30
|
+
} finally {
|
|
31
|
+
await commander.destroy();
|
|
32
|
+
process.chdir(cwd);
|
|
33
|
+
restoreEnvironment();
|
|
34
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const outputHint of ['none', 'option', 'environment']) {
|
|
39
|
+
it(`navigates without recording or writes with ${outputHint} output hints`, async () => {
|
|
40
|
+
await fixture(async ({ root, page, commander }) => {
|
|
41
|
+
const output = path.join(root, 'unrequested');
|
|
42
|
+
if (outputHint === 'environment') {
|
|
43
|
+
process.env.BROWSER_COMMANDER_TRACE = output;
|
|
44
|
+
}
|
|
45
|
+
attachCommanderFeatures(
|
|
46
|
+
commander,
|
|
47
|
+
outputHint === 'option' ? { output } : {},
|
|
48
|
+
() => {}
|
|
49
|
+
);
|
|
50
|
+
await commander.goto('https://example.com');
|
|
51
|
+
assert.equal(commander.tracePath, null);
|
|
52
|
+
assert.equal(page.listenerCount('request'), 0);
|
|
53
|
+
assert.deepEqual(page.state.initScripts, []);
|
|
54
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
it('enables recording through BROWSER_COMMANDER_DEBUG=1', async () => {
|
|
60
|
+
await fixture(async ({ root, commander }) => {
|
|
61
|
+
process.env.BROWSER_COMMANDER_DEBUG = '1';
|
|
62
|
+
process.env.BROWSER_COMMANDER_TRACE = path.join(root, 'requested');
|
|
63
|
+
attachCommanderFeatures(commander, {}, () => {});
|
|
64
|
+
await commander.ready;
|
|
65
|
+
assert.equal(commander.tracePath, process.env.BROWSER_COMMANDER_TRACE);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('leaves default launches unrecorded', async () => {
|
|
70
|
+
await fixture(async ({ root, page }) => {
|
|
71
|
+
const launched = await attachLaunchDebug({ page }, {});
|
|
72
|
+
assert.equal(launched.tracePath, null);
|
|
73
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('owns a unique managed child, shares it with launch, and discards only that child', async () => {
|
|
78
|
+
await fixture(async ({ root, page, commander }) => {
|
|
79
|
+
const directory = path.join(root, 'evidence');
|
|
80
|
+
await fs.mkdir(directory);
|
|
81
|
+
await fs.writeFile(path.join(directory, 'keep.txt'), 'caller owned');
|
|
82
|
+
let closed = 0;
|
|
83
|
+
const launched = await attachLaunchDebug(
|
|
84
|
+
{
|
|
85
|
+
page,
|
|
86
|
+
close: async () => {
|
|
87
|
+
closed += 1;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{ debug: { directory, retain: false } }
|
|
91
|
+
);
|
|
92
|
+
attachCommanderFeatures(commander, {}, () => {});
|
|
93
|
+
await commander.ready;
|
|
94
|
+
assert.equal(commander.tracePath, launched.tracePath);
|
|
95
|
+
assert.equal(path.dirname(commander.tracePath), directory);
|
|
96
|
+
await commander.goto('https://example.com');
|
|
97
|
+
await commander.destroy();
|
|
98
|
+
await launched.close();
|
|
99
|
+
assert.equal(closed, 1);
|
|
100
|
+
assert.deepEqual(await fs.readdir(directory), ['keep.txt']);
|
|
101
|
+
assert.deepEqual(await fs.readdir(root), ['evidence']);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('retains managed recordings unless discarded, with idempotent explicit stop', async () => {
|
|
106
|
+
await fixture(async ({ root, commander, configureDebug }) => {
|
|
107
|
+
await configureDebug({ directory: root });
|
|
108
|
+
const stopped = await commander.stopDebug();
|
|
109
|
+
assert.equal(stopped, await commander.stopDebug());
|
|
110
|
+
assert.equal(
|
|
111
|
+
(await readTrace(commander.tracePath)).manifest.outcome,
|
|
112
|
+
'complete'
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('allows explicit discard of a managed child', async () => {
|
|
118
|
+
await fixture(async ({ root, commander, configureDebug }) => {
|
|
119
|
+
await configureDebug({ directory: root });
|
|
120
|
+
const stopped = await commander.stopDebug({ discard: true });
|
|
121
|
+
assert.equal(stopped.discarded, true);
|
|
122
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('leaves later explicit traces intact when debug teardown repeats', async () => {
|
|
127
|
+
await fixture(async ({ root, page, commander, configureDebug }) => {
|
|
128
|
+
await configureDebug({ directory: root, retain: false });
|
|
129
|
+
await commander.stopDebug();
|
|
130
|
+
const first = await commander.startTrace({
|
|
131
|
+
output: path.join(root, 'first-explicit'),
|
|
132
|
+
});
|
|
133
|
+
await commander.stopDebug();
|
|
134
|
+
const second = await commander.startTrace({
|
|
135
|
+
output: path.join(root, 'second-explicit'),
|
|
136
|
+
});
|
|
137
|
+
await first.stop();
|
|
138
|
+
await second.stop();
|
|
139
|
+
let captures = 0;
|
|
140
|
+
page.evaluate = async () => {
|
|
141
|
+
captures += 1;
|
|
142
|
+
};
|
|
143
|
+
await commander.click('#after-teardown');
|
|
144
|
+
assert.equal(captures, 0, 'stopped traces must leave no input labels');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('refuses to discard a caller-supplied output directory', async () => {
|
|
149
|
+
await fixture(async ({ root, commander }) => {
|
|
150
|
+
attachCommanderFeatures(commander, { debug: { output: root } }, () => {});
|
|
151
|
+
await commander.ready;
|
|
152
|
+
await assert.rejects(commander.stopDebug({ discard: true }), /managed/);
|
|
153
|
+
await commander.stopDebug();
|
|
154
|
+
assert.equal((await readTrace(root)).manifest.outcome, 'complete');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('removes its managed child when recorder startup fails', async () => {
|
|
159
|
+
await fixture(async ({ root, page, commander }) => {
|
|
160
|
+
page.failEvaluate('captureSnapshotInPage', new Error('capture failed'));
|
|
161
|
+
attachCommanderFeatures(
|
|
162
|
+
commander,
|
|
163
|
+
{ debug: { directory: root, strict: true } },
|
|
164
|
+
() => {}
|
|
165
|
+
);
|
|
166
|
+
await commander.ready;
|
|
167
|
+
assert.equal(commander.tracePath, null);
|
|
168
|
+
assert.equal(page.listenerCount('request'), 0);
|
|
169
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('cleans managed recording in finally after failed navigation', async () => {
|
|
174
|
+
await fixture(async ({ root, commander }) => {
|
|
175
|
+
commander.goto = async () => {
|
|
176
|
+
throw new Error('navigation failed');
|
|
177
|
+
};
|
|
178
|
+
attachCommanderFeatures(
|
|
179
|
+
commander,
|
|
180
|
+
{ debug: { directory: root, retain: false } },
|
|
181
|
+
() => {}
|
|
182
|
+
);
|
|
183
|
+
try {
|
|
184
|
+
await assert.rejects(
|
|
185
|
+
commander.goto('https://example.com'),
|
|
186
|
+
/navigation failed/
|
|
187
|
+
);
|
|
188
|
+
} finally {
|
|
189
|
+
await commander.destroy();
|
|
190
|
+
}
|
|
191
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('stops managed recording automatically at its duration limit', async () => {
|
|
196
|
+
await fixture(async ({ root, commander }) => {
|
|
197
|
+
attachCommanderFeatures(
|
|
198
|
+
commander,
|
|
199
|
+
{
|
|
200
|
+
debug: {
|
|
201
|
+
directory: root,
|
|
202
|
+
retain: false,
|
|
203
|
+
maxDurationMs: 20,
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
() => {}
|
|
207
|
+
);
|
|
208
|
+
await commander.ready;
|
|
209
|
+
// Poll actual teardown, bounded by the suite timeout, rather than a fixed sleep.
|
|
210
|
+
while ((await fs.readdir(root)).length) {
|
|
211
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
212
|
+
}
|
|
213
|
+
assert.equal((await commander.stopDebug()).discarded, true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('enforces the managed data budget and preserves partial outcome', async () => {
|
|
218
|
+
await fixture(async ({ root, page, commander }) => {
|
|
219
|
+
attachCommanderFeatures(
|
|
220
|
+
commander,
|
|
221
|
+
{
|
|
222
|
+
debug: {
|
|
223
|
+
directory: root,
|
|
224
|
+
limits: { maxBundleBytes: 1024, maxResourceBytes: 512 },
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
() => {}
|
|
228
|
+
);
|
|
229
|
+
await commander.ready;
|
|
230
|
+
for (let index = 0; index < 20; index += 1) {
|
|
231
|
+
page.emit('pageerror', new Error('bounded error '.repeat(20)));
|
|
232
|
+
}
|
|
233
|
+
const result = await commander.stopDebug();
|
|
234
|
+
const trace = await readTrace(result.path);
|
|
235
|
+
assert.equal(trace.manifest.outcome, 'partial');
|
|
236
|
+
const events = await fs.stat(path.join(result.path, 'events.ndjson'));
|
|
237
|
+
assert.ok(events.size <= 1024);
|
|
238
|
+
assert.equal(trace.manifest.limits.maxBundleBytes, 1024);
|
|
239
|
+
await assert.rejects(fs.access(path.join(result.path, 'trace.lino')));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not create recording storage when browser launch fails', async () => {
|
|
244
|
+
await fixture(async ({ root }) => {
|
|
245
|
+
await assert.rejects(
|
|
246
|
+
launchBrowserWithDependencies(
|
|
247
|
+
{ debug: { directory: root } },
|
|
248
|
+
{
|
|
249
|
+
resolveSystem: async () => '/usr/bin/google-chrome',
|
|
250
|
+
launchRealBrowser: async () => {
|
|
251
|
+
throw new Error('launch failed');
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
),
|
|
255
|
+
/launch failed/
|
|
256
|
+
);
|
|
257
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('discards its child and closes the browser even when finalization fails', async () => {
|
|
262
|
+
await fixture(async ({ root, page }) => {
|
|
263
|
+
let closed = false;
|
|
264
|
+
const launched = await attachLaunchDebug(
|
|
265
|
+
{
|
|
266
|
+
page,
|
|
267
|
+
close: async () => {
|
|
268
|
+
closed = true;
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
debug: { directory: root, retain: false },
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
await fs.mkdir(path.join(launched.tracePath, 'manifest.json'));
|
|
276
|
+
await assert.rejects(launched.close(), /EISDIR|EPERM|EACCES/);
|
|
277
|
+
assert.equal(closed, true);
|
|
278
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('validates managed launch configuration before starting the browser', async () => {
|
|
283
|
+
await fixture(async ({ root }) => {
|
|
284
|
+
let started = false;
|
|
285
|
+
await assert.rejects(
|
|
286
|
+
launchBrowserWithDependencies(
|
|
287
|
+
{
|
|
288
|
+
debug: { directory: root, retain: false, maxDurationMs: Infinity },
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
launchRealBrowser: async () => {
|
|
292
|
+
started = true;
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
),
|
|
296
|
+
/debug managed/
|
|
297
|
+
);
|
|
298
|
+
assert.equal(started, false);
|
|
299
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
for (const debug of [false, true, { directory: '.' }]) {
|
|
304
|
+
it(`environment opt-out prevents recording with debug=${JSON.stringify(debug)}`, async () => {
|
|
305
|
+
await fixture(async ({ root, commander }) => {
|
|
306
|
+
process.env.BROWSER_COMMANDER_DEBUG = '0';
|
|
307
|
+
attachCommanderFeatures(commander, { debug }, () => {});
|
|
308
|
+
await commander.ready;
|
|
309
|
+
assert.equal(commander.tracePath, null);
|
|
310
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
for (const debug of [
|
|
316
|
+
{ retain: false },
|
|
317
|
+
{ directory: '.', output: 'other' },
|
|
318
|
+
{ directory: '.', limits: { maxBundleBytes: Infinity } },
|
|
319
|
+
{ directory: '.', limits: { rotate: { maxBytes: 1024 } } },
|
|
320
|
+
{ directory: '.', links: { output: '../outside.lino' } },
|
|
321
|
+
{ directory: '.', network: { har: '../outside.har' } },
|
|
322
|
+
{ directory: '.', maxDurationMs: Infinity },
|
|
323
|
+
]) {
|
|
324
|
+
it(`rejects unsafe managed settings before writing: ${JSON.stringify(debug)}`, async () => {
|
|
325
|
+
await fixture(async ({ root, commander }) => {
|
|
326
|
+
attachCommanderFeatures(commander, { debug }, () => {});
|
|
327
|
+
await assert.rejects(commander.ready, /debug|managed/);
|
|
328
|
+
commander.ready = Promise.resolve();
|
|
329
|
+
assert.deepEqual(await fs.readdir(root), []);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Parser, StreamParser } from 'links-notation';
|
|
4
|
+
import {
|
|
5
|
+
decodeLinkText,
|
|
6
|
+
formatTraceLinks,
|
|
7
|
+
timelineLink,
|
|
8
|
+
} from '../../../src/traces/links.js';
|
|
9
|
+
import { readRepoText } from '../../helpers/repo.js';
|
|
10
|
+
|
|
11
|
+
describe(
|
|
12
|
+
'trace compatibility with Links Notation (issue #168)',
|
|
13
|
+
{ timeout: 10000 },
|
|
14
|
+
() => {
|
|
15
|
+
it('preserves scalar text through the formatter, parser and trace decoder', () => {
|
|
16
|
+
for (const text of [
|
|
17
|
+
'',
|
|
18
|
+
'#comment',
|
|
19
|
+
'issue#168',
|
|
20
|
+
'~1{61}',
|
|
21
|
+
'both\'and" with `backticks`',
|
|
22
|
+
'line\r\nbreak\ttab\\literal\\u0022\\ud800\\udfff',
|
|
23
|
+
'\u0000\u0001\u007f',
|
|
24
|
+
'a\u00a0b\u2028c\u2029d\ufeff',
|
|
25
|
+
'Tiếng Việt 日本語 🧭 e\u0301',
|
|
26
|
+
'\ud800',
|
|
27
|
+
'\udfff',
|
|
28
|
+
'🧭\ud800🧭\udfff',
|
|
29
|
+
]) {
|
|
30
|
+
const line = formatTraceLinks([
|
|
31
|
+
timelineLink({ kind: 'console', text }),
|
|
32
|
+
]);
|
|
33
|
+
assert.equal(line.split('\n').length, 2, 'one record per line');
|
|
34
|
+
const [parsed] = new Parser().parse(line);
|
|
35
|
+
const field = parsed.values.find((value) => value.id === 'text');
|
|
36
|
+
assert.equal(decodeLinkText(field.values[0].id), text);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('imports the existing cross-language golden export in arbitrary chunks', () => {
|
|
41
|
+
const text = readRepoText(
|
|
42
|
+
'js/tests/fixtures/traces/conformance/expected/trace.lino'
|
|
43
|
+
);
|
|
44
|
+
const expected = new Parser().parse(text);
|
|
45
|
+
assert.equal(expected.length, 27);
|
|
46
|
+
for (const chunkSize of [1, 17, text.length]) {
|
|
47
|
+
const stream = new StreamParser();
|
|
48
|
+
for (let offset = 0; offset < text.length; offset += chunkSize) {
|
|
49
|
+
stream.write(text.slice(offset, offset + chunkSize));
|
|
50
|
+
}
|
|
51
|
+
stream.end();
|
|
52
|
+
assert.deepEqual(stream.drain(), expected);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
);
|
|
@@ -544,8 +544,8 @@ describe('trace links export (issue #94)', () => {
|
|
|
544
544
|
// The notation's formatter and parser decide whether an export can be read
|
|
545
545
|
// back, so the range this package depends on and the version the tests
|
|
546
546
|
// proved are the same range.
|
|
547
|
-
assert.strictEqual(manifest.dependencies['links-notation'], '^0.
|
|
548
|
-
assert.match(installed.version, /^0\.
|
|
547
|
+
assert.strictEqual(manifest.dependencies['links-notation'], '^0.25.2');
|
|
548
|
+
assert.match(installed.version, /^0\.25\./);
|
|
549
549
|
});
|
|
550
550
|
|
|
551
551
|
it('should export a bundle named by path as readily as an open one', async () => {
|