browser-commander 0.26.3 → 0.28.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/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +22 -0
- package/README.md +6 -0
- package/eslint.config.js +11 -0
- package/package.json +9 -4
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/connector.js +117 -13
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launch-options.js +2 -1
- package/src/browser/launch-restrictions.json +33 -2
- package/src/browser/launcher.js +54 -9
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/persistent-session.js +360 -0
- package/src/browser/profile-directory.js +20 -0
- package/src/browser/real-browser.js +142 -70
- package/src/browser/restrictions.js +13 -4
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/session-watchdog.js +71 -0
- package/src/browser/session-worker.js +70 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/webdriver.js +4 -0
- package/src/capture/encoding.js +327 -0
- package/src/capture/errors.js +12 -0
- package/src/capture/files.js +9 -0
- package/src/capture/index.js +5 -0
- package/src/capture/recording.js +136 -0
- package/src/capture/screenshot.js +222 -0
- package/src/capture/video.js +186 -0
- package/src/capture/viewport.js +68 -0
- package/src/capture/worker.js +22 -0
- package/src/cli/args.js +66 -1
- package/src/cli/commands.js +113 -2
- package/src/cli/high-level-methods.js +40 -3
- package/src/cli/sessions.js +11 -3
- package/src/core/commander-features.js +105 -0
- package/src/core/constants.js +1 -0
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +66 -80
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +68 -16
- package/src/core/navigation-safety.js +51 -0
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +130 -42
- package/src/core/readiness.js +76 -14
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +61 -37
- package/src/elements/reusable.js +92 -0
- package/src/elements/selectors.js +37 -81
- package/src/elements/visibility.js +38 -21
- package/src/exports.js +14 -0
- package/src/factory.js +17 -16
- package/src/high-level/universal-logic.js +57 -21
- package/src/index.js +2 -0
- package/src/interactions/click.js +47 -0
- package/src/interactions/overlays.js +28 -0
- package/src/interactions/scroll.js +8 -2
- package/src/traces/bundle.js +14 -5
- package/src/traces/checkpoints.js +99 -0
- package/src/traces/links.js +117 -1
- package/src/traces/mutation-stream.js +1 -0
- package/src/traces/network.js +257 -0
- package/src/traces/page-capture.js +143 -52
- package/src/traces/reader.js +8 -10
- package/src/traces/recorder.js +221 -245
- package/src/traces/render.js +168 -0
- package/src/traces/rolling.js +180 -0
- package/src/traces/start-cleanup.js +15 -0
- package/src/traces/stop.js +159 -0
- package/src/traces/storage.js +43 -0
- package/src/utilities/subprocess.js +47 -4
- package/tests/e2e/cli.e2e.test.js +20 -1
- package/tests/e2e/click-readiness.e2e.test.js +1 -1
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-building-blocks.test.js +5 -1
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/launcher.test.js +5 -1
- package/tests/unit/browser/persistent-session.test.js +59 -0
- package/tests/unit/browser/quiet-ui.test.js +16 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- package/tests/unit/browser/tab-selection.test.js +38 -0
- package/tests/unit/capture/capture.test.js +52 -0
- package/tests/unit/capture/cleanup.test.js +24 -0
- package/tests/unit/capture/viewport.test.js +168 -0
- package/tests/unit/cli/dispatcher.test.js +25 -0
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/readiness.test.js +21 -0
- package/tests/unit/core/trigger-readiness.test.js +39 -0
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/elements/visible-count.test.js +24 -0
- package/tests/unit/elements/wait-navigation.test.js +26 -0
- package/tests/unit/interactions/navigation-timeout.test.js +39 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/traces/bundle.test.js +16 -0
- package/tests/unit/traces/links.test.js +2 -2
- package/tests/unit/traces/network.test.js +97 -0
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
import { describe, it } from 'node:test';
|
|
16
16
|
import assert from 'node:assert';
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { readFile, rm } from 'node:fs/promises';
|
|
19
|
+
import path from 'node:path';
|
|
18
20
|
import { fileURLToPath } from 'node:url';
|
|
19
21
|
|
|
20
22
|
import { ProcessRunner } from 'command-stream/process-runner';
|
|
@@ -23,6 +25,10 @@ import { normalizeRunOutput } from '../../../tests/cli-contract/normalize.mjs';
|
|
|
23
25
|
import { runCommand } from '../../src/utilities/subprocess.js';
|
|
24
26
|
import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
|
|
25
27
|
import { repoPath } from '../helpers/repo.js';
|
|
28
|
+
import {
|
|
29
|
+
closeRemoteBrowser,
|
|
30
|
+
SESSION_METADATA,
|
|
31
|
+
} from '../../src/browser/persistent-session.js';
|
|
26
32
|
|
|
27
33
|
const BIN = fileURLToPath(
|
|
28
34
|
new URL('../../bin/browser-commander.js', import.meta.url)
|
|
@@ -124,8 +130,9 @@ for (const engine of ENGINES) {
|
|
|
124
130
|
E2E,
|
|
125
131
|
async () => {
|
|
126
132
|
const { runner, printed } = startKeptOpenBrowser(engine);
|
|
133
|
+
let launched;
|
|
127
134
|
try {
|
|
128
|
-
|
|
135
|
+
launched = await printed;
|
|
129
136
|
assert.match(launched.cdpEndpoint, /^http:\/\/127\.0\.0\.1:\d+$/u);
|
|
130
137
|
const attach = [
|
|
131
138
|
'--cdp-endpoint',
|
|
@@ -153,6 +160,18 @@ for (const engine of ENGINES) {
|
|
|
153
160
|
} finally {
|
|
154
161
|
runner.kill('SIGTERM');
|
|
155
162
|
await Promise.resolve(runner).catch(() => {});
|
|
163
|
+
if (launched?.userDataDir) {
|
|
164
|
+
const file = path.join(launched.userDataDir, SESSION_METADATA);
|
|
165
|
+
const metadata = JSON.parse(await readFile(file, 'utf8'));
|
|
166
|
+
await closeRemoteBrowser(metadata.webSocketDebuggerUrl);
|
|
167
|
+
await assert.rejects(fetch(`${launched.cdpEndpoint}/json/version`));
|
|
168
|
+
// Chrome finishes flushing its profile after closing CDP.
|
|
169
|
+
await rm(launched.userDataDir, {
|
|
170
|
+
recursive: true,
|
|
171
|
+
force: true,
|
|
172
|
+
maxRetries: 5,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
);
|
|
@@ -216,7 +216,7 @@ describe(
|
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
assert.strictEqual(result.ready, false);
|
|
219
|
-
assert.strictEqual(result.status, 'timed_out');
|
|
219
|
+
assert.strictEqual(result.status, 'timed_out', JSON.stringify(result));
|
|
220
220
|
assert.ok(
|
|
221
221
|
elapsed < 6000,
|
|
222
222
|
`the wait took ${elapsed}ms for a 1500ms budget`
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
4
|
+
import { createCommander, predicate } from '../../src/index.js';
|
|
5
|
+
import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
|
|
6
|
+
|
|
7
|
+
// Authored local fixtures only. Each deliberately stalled connection is closed
|
|
8
|
+
// by server teardown; browser actions and suites have finite budgets.
|
|
9
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
10
|
+
describe(
|
|
11
|
+
`navigation budgets: ${engine}`,
|
|
12
|
+
{ skip: !process.env.RUN_E2E, timeout: 60000 },
|
|
13
|
+
() => {
|
|
14
|
+
let browser;
|
|
15
|
+
let page;
|
|
16
|
+
let commander;
|
|
17
|
+
let server;
|
|
18
|
+
before(async () => {
|
|
19
|
+
server = await startFixtureHost((route, _req, res) => {
|
|
20
|
+
if (route === '/stall' || route === '/pending') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (route === '/redirect') {
|
|
24
|
+
res.writeHead(302, { location: '/ready' });
|
|
25
|
+
res.end();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (route === '/continuous') {
|
|
29
|
+
sendHtml(
|
|
30
|
+
res,
|
|
31
|
+
'<h1>Continuous requests</h1><script>setInterval(() => fetch("/pending"), 60);</script>'
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
sendHtml(res, '<h1 id="ready">Ready</h1>');
|
|
36
|
+
});
|
|
37
|
+
const { chromium } = await import('playwright');
|
|
38
|
+
const launchOptions = { headless: true, ...CHROME_LAUNCH_OPTIONS };
|
|
39
|
+
if (engine === 'playwright') {
|
|
40
|
+
browser = await chromium.launch(launchOptions);
|
|
41
|
+
page = await browser.newPage();
|
|
42
|
+
} else {
|
|
43
|
+
const { default: puppeteer } = await import('puppeteer');
|
|
44
|
+
browser = await puppeteer.launch({
|
|
45
|
+
...launchOptions,
|
|
46
|
+
executablePath:
|
|
47
|
+
CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
|
|
48
|
+
});
|
|
49
|
+
page = await browser.newPage();
|
|
50
|
+
}
|
|
51
|
+
commander = createCommander({ page, engine });
|
|
52
|
+
});
|
|
53
|
+
after(async () => {
|
|
54
|
+
await commander?.destroy();
|
|
55
|
+
await browser?.close();
|
|
56
|
+
await server?.close();
|
|
57
|
+
});
|
|
58
|
+
const navigate = async (url, options = {}) => {
|
|
59
|
+
const start = performance.now();
|
|
60
|
+
const result = await commander.goto({
|
|
61
|
+
url,
|
|
62
|
+
timeout: 800,
|
|
63
|
+
verify: false,
|
|
64
|
+
waitForStableUrlBefore: false,
|
|
65
|
+
waitForStableUrlAfter: false,
|
|
66
|
+
waitForNetworkIdle: false,
|
|
67
|
+
...options,
|
|
68
|
+
});
|
|
69
|
+
assert.ok(
|
|
70
|
+
performance.now() - start < 1600,
|
|
71
|
+
'end-to-end budget must bound elapsed time'
|
|
72
|
+
);
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
it('loads a data page quickly with tracking enabled', async () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
(await navigate('data:text/html,<h1>Authored fixture</h1>')).status,
|
|
78
|
+
'ready'
|
|
79
|
+
);
|
|
80
|
+
assert.ok(commander.networkTracker);
|
|
81
|
+
assert.ok(page.listenerCount?.('request') > 0);
|
|
82
|
+
});
|
|
83
|
+
it('follows redirects under the same deadline', async () => {
|
|
84
|
+
const result = await navigate(`${server.baseUrl}/redirect`);
|
|
85
|
+
assert.equal(result.status, 'ready');
|
|
86
|
+
assert.equal(page.url(), `${server.baseUrl}/ready`);
|
|
87
|
+
});
|
|
88
|
+
it('times out continuous network activity and cleans navigation state', async () => {
|
|
89
|
+
const result = await navigate(`${server.baseUrl}/continuous`, {
|
|
90
|
+
waitForNetworkIdle: true,
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.status, 'timed_out');
|
|
93
|
+
assert.equal(commander.navigationManager.isNavigating(), false);
|
|
94
|
+
});
|
|
95
|
+
it('bounds a stalled engine navigation', async () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
(await navigate(`${server.baseUrl}/stall`)).status,
|
|
98
|
+
'timed_out'
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
it('observes a positive selector check', async () => {
|
|
102
|
+
const ready = predicate({
|
|
103
|
+
name: 'selector_ready',
|
|
104
|
+
fn: async () =>
|
|
105
|
+
page.evaluate(() => Boolean(document.querySelector('#ready'))),
|
|
106
|
+
});
|
|
107
|
+
assert.equal(
|
|
108
|
+
(await navigate(`${server.baseUrl}/ready`, { checks: [ready] }))
|
|
109
|
+
.status,
|
|
110
|
+
'ready'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
it('cancels a stalled call and preserves only the session listeners', async () => {
|
|
114
|
+
const abort = new AbortController();
|
|
115
|
+
const event =
|
|
116
|
+
engine === 'playwright' ? 'framenavigated' : 'framenavigated';
|
|
117
|
+
const count = page.listenerCount?.(event);
|
|
118
|
+
const timer = setTimeout(() => abort.abort(), 80);
|
|
119
|
+
try {
|
|
120
|
+
const result = await navigate(`${server.baseUrl}/stall`, {
|
|
121
|
+
signal: abort.signal,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(result.status, 'interrupted');
|
|
124
|
+
assert.equal(commander.navigationManager.isNavigating(), false);
|
|
125
|
+
assert.equal(page.listenerCount?.(event), count);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// feature-parity: elements.reusable@native-typed sessions.runtime@native-typed sessions.workflow@native-typed
|
|
2
|
+
import { describe, it, before, after } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {
|
|
5
|
+
createCommander,
|
|
6
|
+
launchBrowser,
|
|
7
|
+
saveStorageState,
|
|
8
|
+
} from '../../src/index.js';
|
|
9
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
|
|
13
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
14
|
+
|
|
15
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
16
|
+
describe(
|
|
17
|
+
`reusable helpers and session cookies: ${engine}`,
|
|
18
|
+
{ skip: !process.env.RUN_E2E, timeout: 60000 },
|
|
19
|
+
() => {
|
|
20
|
+
let browser, page, commander, server;
|
|
21
|
+
before(async () => {
|
|
22
|
+
server = await startFixtureHost((_route, _req, res) =>
|
|
23
|
+
sendHtml(
|
|
24
|
+
res,
|
|
25
|
+
`<p>Hello world</p>
|
|
26
|
+
<button class="pick disabled">Disabled</button><button class="pick">Choose me</button>
|
|
27
|
+
<input type="checkbox" id="checkbox"><input type="radio" id="radio"><div id="hidden" hidden>Hidden</div>`
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
const { chromium } = await import('playwright');
|
|
31
|
+
const options = { headless: true, ...CHROME_LAUNCH_OPTIONS };
|
|
32
|
+
if (engine === 'playwright') {
|
|
33
|
+
browser = await chromium.launch(options);
|
|
34
|
+
page = await browser.newPage();
|
|
35
|
+
} else {
|
|
36
|
+
const { default: puppeteer } = await import('puppeteer');
|
|
37
|
+
browser = await puppeteer.launch({
|
|
38
|
+
...options,
|
|
39
|
+
executablePath:
|
|
40
|
+
CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
|
|
41
|
+
});
|
|
42
|
+
page = await browser.newPage();
|
|
43
|
+
}
|
|
44
|
+
await page.goto(server.baseUrl);
|
|
45
|
+
commander = createCommander({ page, engine });
|
|
46
|
+
});
|
|
47
|
+
after(async () => {
|
|
48
|
+
await commander?.destroy();
|
|
49
|
+
await browser?.close();
|
|
50
|
+
await server?.close();
|
|
51
|
+
});
|
|
52
|
+
it('uses ordered selectors and normalizes nonbreaking spaces only when requested', async () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
await commander.findFirst({
|
|
55
|
+
selectors: ['#absent', '#hidden', '#checkbox'],
|
|
56
|
+
visible: true,
|
|
57
|
+
}),
|
|
58
|
+
'#checkbox'
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
await commander.hasText({ texts: ['Hello world'] }),
|
|
62
|
+
false
|
|
63
|
+
);
|
|
64
|
+
assert.equal(
|
|
65
|
+
await commander.hasText({
|
|
66
|
+
texts: ['missing', 'Hello world'],
|
|
67
|
+
normalizeWhitespace: true,
|
|
68
|
+
}),
|
|
69
|
+
true
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
it('selects the requested index for enabled checks, clicks and scrolls', async () => {
|
|
73
|
+
assert.equal(
|
|
74
|
+
await commander.isEnabled({ selector: '.pick', index: 0 }),
|
|
75
|
+
false
|
|
76
|
+
);
|
|
77
|
+
assert.equal(
|
|
78
|
+
await commander.isEnabled({ selector: '.pick', index: 1 }),
|
|
79
|
+
true
|
|
80
|
+
);
|
|
81
|
+
await commander.installClickListener({
|
|
82
|
+
buttonText: 'Choose me',
|
|
83
|
+
storageKey: 'picked',
|
|
84
|
+
});
|
|
85
|
+
await commander.scrollIntoView({ selector: '.pick', index: 1 });
|
|
86
|
+
await commander.clickButton({
|
|
87
|
+
selector: '.pick',
|
|
88
|
+
index: 1,
|
|
89
|
+
verify: false,
|
|
90
|
+
waitAfterClick: 0,
|
|
91
|
+
waitForNavigation: false,
|
|
92
|
+
});
|
|
93
|
+
assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
|
|
94
|
+
status: 'observed',
|
|
95
|
+
set: true,
|
|
96
|
+
});
|
|
97
|
+
assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
|
|
98
|
+
status: 'observed',
|
|
99
|
+
set: true,
|
|
100
|
+
});
|
|
101
|
+
assert.equal(
|
|
102
|
+
await commander.uninstallClickListener({ storageKey: 'picked' }),
|
|
103
|
+
true
|
|
104
|
+
);
|
|
105
|
+
await page.evaluate(() => window.sessionStorage.removeItem('picked'));
|
|
106
|
+
await commander.clickButton({
|
|
107
|
+
selector: '.pick',
|
|
108
|
+
index: 1,
|
|
109
|
+
verify: false,
|
|
110
|
+
waitAfterClick: 0,
|
|
111
|
+
waitForNavigation: false,
|
|
112
|
+
});
|
|
113
|
+
assert.deepEqual(await commander.readFlag({ storageKey: 'picked' }), {
|
|
114
|
+
status: 'observed',
|
|
115
|
+
set: false,
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
it('checks checkboxes and radios idempotently with native input', async () => {
|
|
119
|
+
assert.equal(
|
|
120
|
+
await commander.isChecked({ selector: '#checkbox' }),
|
|
121
|
+
false
|
|
122
|
+
);
|
|
123
|
+
assert.deepEqual(await commander.check({ selector: '#checkbox' }), {
|
|
124
|
+
checked: true,
|
|
125
|
+
changed: true,
|
|
126
|
+
verified: true,
|
|
127
|
+
});
|
|
128
|
+
assert.deepEqual(await commander.check({ selector: '#checkbox' }), {
|
|
129
|
+
checked: true,
|
|
130
|
+
changed: false,
|
|
131
|
+
verified: true,
|
|
132
|
+
});
|
|
133
|
+
assert.equal(
|
|
134
|
+
(await commander.check({ selector: '#checkbox', checked: false }))
|
|
135
|
+
.verified,
|
|
136
|
+
true
|
|
137
|
+
);
|
|
138
|
+
assert.equal(
|
|
139
|
+
(await commander.check({ selector: '#radio' })).checked,
|
|
140
|
+
true
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
it('sets HttpOnly cookies across origins and clears by domain boundary', async () => {
|
|
144
|
+
await commander.setCookies({
|
|
145
|
+
cookies: [
|
|
146
|
+
{
|
|
147
|
+
name: 'session',
|
|
148
|
+
value: 'artificial',
|
|
149
|
+
domain: 'example.test',
|
|
150
|
+
path: '/',
|
|
151
|
+
expires: 0,
|
|
152
|
+
httpOnly: true,
|
|
153
|
+
sameSite: 'lax',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'keep',
|
|
157
|
+
value: 'artificial',
|
|
158
|
+
domain: 'notexample.test',
|
|
159
|
+
path: '/',
|
|
160
|
+
expires: -1,
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
const state = await saveStorageState(page);
|
|
165
|
+
const session = state.cookies.find(
|
|
166
|
+
(cookie) => cookie.name === 'session'
|
|
167
|
+
);
|
|
168
|
+
assert.ok(session);
|
|
169
|
+
assert.equal(session.httpOnly, true);
|
|
170
|
+
assert.equal(session.expires, -1);
|
|
171
|
+
assert.equal(session.sameSite, 'Lax');
|
|
172
|
+
await commander.clearCookies({ domain: 'example.test' });
|
|
173
|
+
const retained = (await saveStorageState(page)).cookies;
|
|
174
|
+
assert.equal(
|
|
175
|
+
retained.some((cookie) => cookie.name === 'session'),
|
|
176
|
+
false
|
|
177
|
+
);
|
|
178
|
+
assert.equal(
|
|
179
|
+
retained.some((cookie) => cookie.name === 'keep'),
|
|
180
|
+
true
|
|
181
|
+
);
|
|
182
|
+
await commander.clearCookies();
|
|
183
|
+
});
|
|
184
|
+
it('restores opted-in session cookies after closing a dedicated profile', async () => {
|
|
185
|
+
const directory = await mkdtemp(
|
|
186
|
+
path.join(os.tmpdir(), 'bc-persist-e2e-')
|
|
187
|
+
);
|
|
188
|
+
const { chromium } = await import('playwright');
|
|
189
|
+
const options = {
|
|
190
|
+
engine,
|
|
191
|
+
launch: 'engine',
|
|
192
|
+
headless: true,
|
|
193
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
194
|
+
executablePath:
|
|
195
|
+
CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
|
|
196
|
+
userDataDir: directory,
|
|
197
|
+
persistSessionCookies: true,
|
|
198
|
+
};
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = await launchBrowser(options);
|
|
202
|
+
const sessionCommander = createCommander({
|
|
203
|
+
page: result.page,
|
|
204
|
+
engine,
|
|
205
|
+
});
|
|
206
|
+
await sessionCommander.setCookies([
|
|
207
|
+
{
|
|
208
|
+
name: 'session',
|
|
209
|
+
value: 'artificial',
|
|
210
|
+
domain: 'example.test',
|
|
211
|
+
path: '/',
|
|
212
|
+
expires: 0,
|
|
213
|
+
httpOnly: true,
|
|
214
|
+
},
|
|
215
|
+
]);
|
|
216
|
+
await sessionCommander.destroy();
|
|
217
|
+
await result.close();
|
|
218
|
+
result = await launchBrowser(options);
|
|
219
|
+
const cookies = (await saveStorageState(result.page)).cookies;
|
|
220
|
+
assert.equal(
|
|
221
|
+
cookies.find((cookie) => cookie.name === 'session')?.value,
|
|
222
|
+
'artificial'
|
|
223
|
+
);
|
|
224
|
+
} finally {
|
|
225
|
+
await result?.close();
|
|
226
|
+
await rm(directory, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -108,8 +108,8 @@ describe('describeAttachDifferences', () => {
|
|
|
108
108
|
describe('snapshot attach launch option', () => {
|
|
109
109
|
it('maps channels to the browser whose profile is copied', () => {
|
|
110
110
|
assert.equal(snapshotBrowserForChannel('chrome'), 'chrome');
|
|
111
|
-
assert.equal(snapshotBrowserForChannel('chrome-beta'), 'chrome');
|
|
112
|
-
assert.equal(snapshotBrowserForChannel('msedge-dev'), 'edge');
|
|
111
|
+
assert.equal(snapshotBrowserForChannel('chrome-beta'), 'chrome-beta');
|
|
112
|
+
assert.equal(snapshotBrowserForChannel('msedge-dev'), 'edge-dev');
|
|
113
113
|
assert.equal(snapshotBrowserForChannel('brave'), 'brave');
|
|
114
114
|
assert.equal(snapshotBrowserForChannel('chromium'), 'chromium');
|
|
115
115
|
});
|
|
@@ -437,3 +437,36 @@ describe('snapshotUserDataDir', () => {
|
|
|
437
437
|
await assert.rejects(stat(path.join(root, 'copy')), { code: 'ENOENT' });
|
|
438
438
|
});
|
|
439
439
|
});
|
|
440
|
+
|
|
441
|
+
it('cookie-only snapshots include committed WAL data without history or storage', async () => {
|
|
442
|
+
const root = await makeTempDir();
|
|
443
|
+
const target = await makeTempDir();
|
|
444
|
+
await put(root, 'Local State', '{}');
|
|
445
|
+
await put(root, 'Default/History', 'private history');
|
|
446
|
+
await put(root, 'Default/Local Storage/unrelated', 'private storage');
|
|
447
|
+
await put(root, 'Default/Network/Cookies', '');
|
|
448
|
+
const writer = new BetterSqlite3(path.join(root, 'Default/Network/Cookies'));
|
|
449
|
+
writer.pragma('journal_mode=WAL');
|
|
450
|
+
writer.exec('CREATE TABLE cookies(value); INSERT INTO cookies VALUES (42)');
|
|
451
|
+
try {
|
|
452
|
+
await snapshotUserDataDir({
|
|
453
|
+
browser: 'chrome',
|
|
454
|
+
userDataDir: root,
|
|
455
|
+
to: target,
|
|
456
|
+
include: ['cookies'],
|
|
457
|
+
});
|
|
458
|
+
await assert.rejects(stat(path.join(target, 'Default/History')));
|
|
459
|
+
await assert.rejects(stat(path.join(target, 'Default/Local Storage')));
|
|
460
|
+
const copy = new BetterSqlite3(
|
|
461
|
+
path.join(target, 'Default/Network/Cookies'),
|
|
462
|
+
{ readonly: true }
|
|
463
|
+
);
|
|
464
|
+
try {
|
|
465
|
+
assert.equal(copy.prepare('SELECT value FROM cookies').get().value, 42);
|
|
466
|
+
} finally {
|
|
467
|
+
copy.close();
|
|
468
|
+
}
|
|
469
|
+
} finally {
|
|
470
|
+
writer.close();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
@@ -119,3 +119,21 @@ describe('resolveDefaultBrowser', () => {
|
|
|
119
119
|
assert.equal(browserForIdentifier('', 'darwin'), null);
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
it('uses the modern macOS URL handler when LSHandlers is empty', async () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
await resolveDefaultBrowser({
|
|
126
|
+
platform: 'darwin',
|
|
127
|
+
runCommand: async (command) =>
|
|
128
|
+
command === 'osascript' ? 'com.google.chrome\n' : '()',
|
|
129
|
+
}),
|
|
130
|
+
'chrome'
|
|
131
|
+
);
|
|
132
|
+
assert.equal(
|
|
133
|
+
await resolveDefaultBrowser({
|
|
134
|
+
platform: 'darwin',
|
|
135
|
+
runCommand: async () => '',
|
|
136
|
+
}),
|
|
137
|
+
null
|
|
138
|
+
);
|
|
139
|
+
});
|
|
@@ -73,7 +73,11 @@ describe('launch restrictions', () => {
|
|
|
73
73
|
GOOGLE_DEFAULT_CLIENT_ID: 'no',
|
|
74
74
|
GOOGLE_DEFAULT_CLIENT_SECRET: 'no',
|
|
75
75
|
});
|
|
76
|
-
assert.ok(
|
|
76
|
+
assert.ok(
|
|
77
|
+
resolved.args.includes(
|
|
78
|
+
'--disable-features=SessionRestoreInfobar,Translate'
|
|
79
|
+
)
|
|
80
|
+
);
|
|
77
81
|
assert.ok(LAUNCH_RESTRICTION_PRESETS['legacy-launch-browser']);
|
|
78
82
|
});
|
|
79
83
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// feature-parity: launch.diagnostics@native-typed
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { redactLaunchEvidence } from '../../../src/browser/launch-diagnostics.js';
|
|
4
|
+
|
|
5
|
+
it('diagnostics scrub URL credentials and queries and bound multibyte evidence', () => {
|
|
6
|
+
const redacted = redactLaunchEvidence(
|
|
7
|
+
['https://', 'user:', 'private@example.test/secret?access=private'].join('')
|
|
8
|
+
);
|
|
9
|
+
assert.doesNotMatch(redacted, /private|example\.test/);
|
|
10
|
+
assert.ok(Buffer.byteLength(redactLaunchEvidence('😀'.repeat(4096))) <= 4096);
|
|
11
|
+
});
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { launchRealBrowser } from '../../../src/browser/real-browser.js';
|
|
14
|
+
|
|
15
|
+
it('bad executable preserves bounded, redacted structured early-exit evidence', async () => {
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
() =>
|
|
18
|
+
launchRealBrowser({
|
|
19
|
+
engine: 'playwright',
|
|
20
|
+
executablePath: process.execPath,
|
|
21
|
+
headless: true,
|
|
22
|
+
startupTimeout: 1000,
|
|
23
|
+
}),
|
|
24
|
+
(error) => {
|
|
25
|
+
assert.equal(error.name, 'BrowserLaunchError');
|
|
26
|
+
assert.equal(error.phase, 'endpoint');
|
|
27
|
+
assert.equal(error.engine, 'playwright');
|
|
28
|
+
assert.equal(error.category, 'early_exit');
|
|
29
|
+
assert.equal(typeof error.exitCode, 'number');
|
|
30
|
+
assert.match(error.stderrTail, /bad option/);
|
|
31
|
+
assert.ok(error.cause);
|
|
32
|
+
assert.ok(error.stderrTail.length <= 4096);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('missing executable is classified without exposing its path', async () => {
|
|
39
|
+
await assert.rejects(
|
|
40
|
+
() =>
|
|
41
|
+
launchRealBrowser({
|
|
42
|
+
executablePath: '/private/secret-token/missing-browser',
|
|
43
|
+
}),
|
|
44
|
+
(error) => {
|
|
45
|
+
assert.equal(error.name, 'BrowserLaunchError');
|
|
46
|
+
assert.equal(error.category, 'missing_executable');
|
|
47
|
+
assert.ok(!JSON.stringify(error).includes('secret-token'));
|
|
48
|
+
assert.ok(!error.message.includes('secret-token'));
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
);
|
|
52
|
+
});
|
|
@@ -202,7 +202,11 @@ describe('launchBrowser', () => {
|
|
|
202
202
|
assert.equal(options.env.GOOGLE_API_KEY, 'no');
|
|
203
203
|
assert.equal(process.env.GOOGLE_API_KEY, googleBefore);
|
|
204
204
|
assert.ok(options.args.includes('--no-first-run'));
|
|
205
|
-
assert.ok(
|
|
205
|
+
assert.ok(
|
|
206
|
+
options.args.includes(
|
|
207
|
+
'--disable-features=SessionRestoreInfobar,Translate'
|
|
208
|
+
)
|
|
209
|
+
);
|
|
206
210
|
await result.close();
|
|
207
211
|
});
|
|
208
212
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { closeRemoteBrowser } from '../../../src/browser/persistent-session.js';
|
|
4
|
+
|
|
5
|
+
function websocket(context) {
|
|
6
|
+
const sockets = [];
|
|
7
|
+
class Socket {
|
|
8
|
+
constructor() {
|
|
9
|
+
sockets.push(this);
|
|
10
|
+
globalThis.queueMicrotask(() => this.onopen());
|
|
11
|
+
}
|
|
12
|
+
send(message) {
|
|
13
|
+
this.request = JSON.parse(message);
|
|
14
|
+
}
|
|
15
|
+
close() {
|
|
16
|
+
this.clientClosed = true;
|
|
17
|
+
this.onclose();
|
|
18
|
+
}
|
|
19
|
+
message(value) {
|
|
20
|
+
this.onmessage({ data: JSON.stringify(value) });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function createSocket() {
|
|
24
|
+
return new Socket();
|
|
25
|
+
}
|
|
26
|
+
context.mock.method(globalThis, 'WebSocket', createSocket);
|
|
27
|
+
return sockets;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('waits for browser shutdown rather than closing on unrelated CDP events', async (context) => {
|
|
31
|
+
const sockets = websocket(context);
|
|
32
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
33
|
+
const socket = sockets[0];
|
|
34
|
+
await Promise.resolve();
|
|
35
|
+
socket.message({ method: 'Target.targetCreated', params: {} });
|
|
36
|
+
const prematurelyClosed = socket.clientClosed;
|
|
37
|
+
socket.message({ id: socket.request.id, result: {} });
|
|
38
|
+
socket.onclose();
|
|
39
|
+
await closing;
|
|
40
|
+
assert.equal(prematurelyClosed, undefined);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('reports CDP shutdown errors instead of claiming the browser closed', async (context) => {
|
|
44
|
+
const sockets = websocket(context);
|
|
45
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
46
|
+
await Promise.resolve();
|
|
47
|
+
sockets[0].message({ id: 1, error: { message: 'shutdown refused' } });
|
|
48
|
+
await assert.rejects(closing, /shutdown refused/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts Chrome disconnecting abruptly after its shutdown acknowledgement', async (context) => {
|
|
52
|
+
const sockets = websocket(context);
|
|
53
|
+
const closing = closeRemoteBrowser('ws://127.0.0.1/browser');
|
|
54
|
+
await Promise.resolve();
|
|
55
|
+
sockets[0].message({ id: 1, result: {} });
|
|
56
|
+
sockets[0].onerror();
|
|
57
|
+
sockets[0].onclose();
|
|
58
|
+
await closing;
|
|
59
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { resolveRestrictions } from '../../../src/browser/restrictions.js';
|
|
4
|
+
|
|
5
|
+
it('quiet-ui merges caller features and disables Translate in preferences', () => {
|
|
6
|
+
const result = resolveRestrictions(['quiet-ui'], {
|
|
7
|
+
disableFeatures: ['CustomFeature', 'Translate'],
|
|
8
|
+
});
|
|
9
|
+
const switches = result.args.filter((arg) =>
|
|
10
|
+
arg.startsWith('--disable-features=')
|
|
11
|
+
);
|
|
12
|
+
assert.equal(switches.length, 1);
|
|
13
|
+
assert.ok(switches[0].includes('SessionRestoreInfobar'));
|
|
14
|
+
assert.ok(switches[0].includes('CustomFeature'));
|
|
15
|
+
assert.equal(result.preferences.translate.enabled, false);
|
|
16
|
+
});
|