browser-commander 0.26.2 → 0.27.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 +12 -0
- package/README.md +2 -0
- package/package.json +5 -3
- 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/browser-sources.js +13 -0
- package/src/browser/browser-sources.json +13 -0
- 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/launcher.js +28 -8
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/real-browser.js +135 -68
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/system-browser.js +2 -2
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +62 -78
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +40 -16
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +15 -12
- package/src/core/readiness.js +67 -13
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +35 -5
- package/src/elements/reusable.js +92 -0
- package/src/elements/visibility.js +6 -11
- package/src/exports.js +14 -0
- package/src/factory.js +4 -4
- package/src/high-level/universal-logic.js +57 -21
- package/src/interactions/click.js +3 -0
- package/src/interactions/scroll.js +8 -2
- package/src/utilities/subprocess.js +15 -4
- 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/catalogue-launch.test.js +50 -2
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-diagnostics.test.js +52 -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/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/traces/links.test.js +2 -2
|
@@ -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
|
+
});
|
|
@@ -6,6 +6,7 @@ import { useTempDirectories } from '../../helpers/temp-directory.js';
|
|
|
6
6
|
import {
|
|
7
7
|
BROWSER_SOURCES,
|
|
8
8
|
resolveBrowserRoots,
|
|
9
|
+
resolveBrowserProtectionRoots,
|
|
9
10
|
} from '../../../src/browser/browser-sources.js';
|
|
10
11
|
import {
|
|
11
12
|
assertDedicatedUserDataDir,
|
|
@@ -13,8 +14,51 @@ import {
|
|
|
13
14
|
} from '../../../src/browser/system-browser.js';
|
|
14
15
|
|
|
15
16
|
const temporary = useTempDirectories('bc-catalogue-protection-');
|
|
17
|
+
const macOSOptions = {
|
|
18
|
+
platform: 'darwin',
|
|
19
|
+
homeDir: '/users/test',
|
|
20
|
+
environment: {},
|
|
21
|
+
};
|
|
16
22
|
|
|
17
23
|
describe('catalogue launch and protection (#121)', () => {
|
|
24
|
+
it('accepts dedicated macOS application profiles under Library (#132)', () => {
|
|
25
|
+
const options = macOSOptions;
|
|
26
|
+
for (const directory of [
|
|
27
|
+
'Library/Application Support/package-registry-manager/browser-profile',
|
|
28
|
+
'Library/Safari-backup/profile',
|
|
29
|
+
'Library/Cookies-backup/profile',
|
|
30
|
+
'Library/Containers/com.apple.Safari-helper/profile',
|
|
31
|
+
]) {
|
|
32
|
+
assert.doesNotThrow(() =>
|
|
33
|
+
assertDedicatedUserDataDir(
|
|
34
|
+
path.posix.join(options.homeDir, directory),
|
|
35
|
+
options
|
|
36
|
+
)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('protects Safari stores and Chrome profiles on macOS (#132)', () => {
|
|
42
|
+
const options = macOSOptions;
|
|
43
|
+
for (const directory of [
|
|
44
|
+
'Library/Safari',
|
|
45
|
+
'Library/Cookies',
|
|
46
|
+
'Library/Containers/com.apple.Safari',
|
|
47
|
+
'Library/Safari Technology Preview',
|
|
48
|
+
'Library/Containers/com.apple.SafariTechnologyPreview',
|
|
49
|
+
'Library/Application Support/Google/Chrome/Default',
|
|
50
|
+
]) {
|
|
51
|
+
const root = path.posix.join(options.homeDir, directory);
|
|
52
|
+
for (const requested of [root, path.posix.join(root, 'new-profile')]) {
|
|
53
|
+
assert.throws(
|
|
54
|
+
() => assertDedicatedUserDataDir(requested, options),
|
|
55
|
+
/dedicated.*default profile/,
|
|
56
|
+
requested
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
18
62
|
it('protects a new profile beneath a symlink to a default root', async () => {
|
|
19
63
|
const homeDir = await temporary();
|
|
20
64
|
const options = { homeDir, environment: {} };
|
|
@@ -47,7 +91,7 @@ describe('catalogue launch and protection (#121)', () => {
|
|
|
47
91
|
}
|
|
48
92
|
});
|
|
49
93
|
|
|
50
|
-
it('protects every catalogue root and descendant profile on every OS', () => {
|
|
94
|
+
it('protects every catalogue protection root and descendant profile on every OS', () => {
|
|
51
95
|
for (const platform of ['linux', 'darwin', 'win32']) {
|
|
52
96
|
const options = {
|
|
53
97
|
platform,
|
|
@@ -55,7 +99,11 @@ describe('catalogue launch and protection (#121)', () => {
|
|
|
55
99
|
environment: {},
|
|
56
100
|
};
|
|
57
101
|
for (const browser of BROWSER_SOURCES) {
|
|
58
|
-
|
|
102
|
+
const roots = resolveBrowserProtectionRoots(browser.id, options);
|
|
103
|
+
if (!browser.protectionRoots) {
|
|
104
|
+
assert.deepEqual(roots, resolveBrowserRoots(browser.id, options));
|
|
105
|
+
}
|
|
106
|
+
for (const root of roots) {
|
|
59
107
|
assert.throws(
|
|
60
108
|
() => assertDedicatedUserDataDir(root, options),
|
|
61
109
|
/dedicated.*default profile/,
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import {
|
|
7
|
+
installSessionPersistence,
|
|
8
|
+
sessionPersistencePath,
|
|
9
|
+
} from '../../../src/browser/session-persistence.js';
|
|
10
|
+
import {
|
|
11
|
+
findSiteSessions,
|
|
12
|
+
readBrowserCookieSession,
|
|
13
|
+
} from '../../../src/browser/browser-cookie-session.js';
|
|
14
|
+
|
|
15
|
+
it('discovery reads Firefox and Safari through their existing cookie readers', async () => {
|
|
16
|
+
const read = [];
|
|
17
|
+
const sessions = await findSiteSessions(
|
|
18
|
+
{
|
|
19
|
+
domains: ['example.test'],
|
|
20
|
+
sources: ['firefox', 'safari'].map((browser) => ({
|
|
21
|
+
browser,
|
|
22
|
+
path: '/artificial/profile',
|
|
23
|
+
})),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
readCookies: async (options) => {
|
|
27
|
+
read.push(options);
|
|
28
|
+
return [
|
|
29
|
+
{ name: 'session', domain: '.example.test' },
|
|
30
|
+
{ domain: 'notexample.test' },
|
|
31
|
+
];
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
assert.equal(read.length, 2);
|
|
36
|
+
assert.ok(
|
|
37
|
+
read.every(
|
|
38
|
+
(options) => options.via === 'database' && options.cache === false
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
assert.ok(
|
|
42
|
+
sessions.every(
|
|
43
|
+
(session) =>
|
|
44
|
+
session.cookies.length === 1 &&
|
|
45
|
+
session.loggedIn === null &&
|
|
46
|
+
!session.error
|
|
47
|
+
)
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('non-Chromium session validation imports cookies into a disposable engine browser', async () => {
|
|
52
|
+
let seeded;
|
|
53
|
+
let closed = 0;
|
|
54
|
+
const sessions = await findSiteSessions(
|
|
55
|
+
{
|
|
56
|
+
domains: ['example.test'],
|
|
57
|
+
sources: [{ browser: 'firefox', path: '/artificial/profile' }],
|
|
58
|
+
isLoggedIn: async ({ cookies }) => cookies.length === 1,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
readCookies: async () => [
|
|
62
|
+
{
|
|
63
|
+
name: 'session',
|
|
64
|
+
value: 'artificial',
|
|
65
|
+
domain: '.example.test',
|
|
66
|
+
path: '/',
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
launch: async (options) => {
|
|
70
|
+
assert.equal(options.launch, 'engine');
|
|
71
|
+
assert.equal(options.userDataDir, undefined);
|
|
72
|
+
return {
|
|
73
|
+
page: {
|
|
74
|
+
context: () => ({
|
|
75
|
+
addCookies: async (cookies) => {
|
|
76
|
+
seeded = cookies;
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
close: async () => closed++,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
);
|
|
85
|
+
assert.equal(seeded.length, 1);
|
|
86
|
+
assert.equal(closed, 1);
|
|
87
|
+
assert.equal(sessions[0].loggedIn, true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('browser reads request cookie-only copies, filter domains and always close', async () => {
|
|
91
|
+
let closed = 0,
|
|
92
|
+
launched;
|
|
93
|
+
const result = {
|
|
94
|
+
page: {
|
|
95
|
+
context: () => ({
|
|
96
|
+
storageState: async () => ({
|
|
97
|
+
cookies: [{ domain: '.example.test' }, { domain: 'notexample.test' }],
|
|
98
|
+
origins: [],
|
|
99
|
+
}),
|
|
100
|
+
}),
|
|
101
|
+
},
|
|
102
|
+
close: async () => closed++,
|
|
103
|
+
};
|
|
104
|
+
const cookies = await readBrowserCookieSession(
|
|
105
|
+
{
|
|
106
|
+
browser: 'chrome',
|
|
107
|
+
profileDir: '/artificial/Default',
|
|
108
|
+
domainFilter: 'example.test',
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
launch: async (options) => {
|
|
112
|
+
launched = options;
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
assert.equal(cookies.length, 1);
|
|
118
|
+
assert.deepEqual(launched.attach.include, ['cookies']);
|
|
119
|
+
assert.equal(closed, 1);
|
|
120
|
+
});
|
|
121
|
+
it('live validation failure closes the snapshot and records the error', async () => {
|
|
122
|
+
let closed = 0;
|
|
123
|
+
const results = await findSiteSessions(
|
|
124
|
+
{
|
|
125
|
+
domains: ['example.test'],
|
|
126
|
+
sources: [{ browser: 'chrome', path: '/artificial/Default' }],
|
|
127
|
+
isLoggedIn: async () => {
|
|
128
|
+
throw new Error('artificial validation failure');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
launch: async () => ({
|
|
133
|
+
page: {
|
|
134
|
+
context: () => ({
|
|
135
|
+
storageState: async () => ({ cookies: [], origins: [] }),
|
|
136
|
+
}),
|
|
137
|
+
},
|
|
138
|
+
close: async () => closed++,
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
assert.equal(closed, 1);
|
|
143
|
+
assert.match(results[0].error, /validation failure/);
|
|
144
|
+
});
|
|
145
|
+
it('persistence requires a dedicated profile and writes only session cookies with restricted permissions', async () => {
|
|
146
|
+
assert.throws(
|
|
147
|
+
() => sessionPersistencePath({ persistSessionCookies: true }),
|
|
148
|
+
/dedicated/
|
|
149
|
+
);
|
|
150
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-session-test-'));
|
|
151
|
+
try {
|
|
152
|
+
let closed = 0;
|
|
153
|
+
const state = {
|
|
154
|
+
cookies: [
|
|
155
|
+
{ name: 'session', expires: -1 },
|
|
156
|
+
{ name: 'persistent', expires: 9999999999 },
|
|
157
|
+
],
|
|
158
|
+
origins: [],
|
|
159
|
+
};
|
|
160
|
+
const page = { context: () => ({ storageState: async () => state }) };
|
|
161
|
+
const result = await installSessionPersistence(
|
|
162
|
+
{ page, browser: {}, close: async () => closed++ },
|
|
163
|
+
{ userDataDir: directory, persistSessionCookies: true }
|
|
164
|
+
);
|
|
165
|
+
await Promise.all([result.close(), result.close()]);
|
|
166
|
+
const file = path.join(directory, 'browser-commander-session.json');
|
|
167
|
+
assert.equal(closed, 1);
|
|
168
|
+
assert.deepEqual(JSON.parse(await readFile(file, 'utf8')).cookies, [
|
|
169
|
+
state.cookies[0],
|
|
170
|
+
]);
|
|
171
|
+
if (process.platform !== 'win32') {
|
|
172
|
+
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
|
173
|
+
}
|
|
174
|
+
} finally {
|
|
175
|
+
await rm(directory, { recursive: true, force: true });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('session discovery continues after a source close failure', async () => {
|
|
180
|
+
let closed = 0;
|
|
181
|
+
const sessions = await findSiteSessions(
|
|
182
|
+
{
|
|
183
|
+
domains: ['example.test'],
|
|
184
|
+
sources: [
|
|
185
|
+
{ browser: 'chrome', path: '/artificial/Default' },
|
|
186
|
+
{ browser: 'chrome', path: '/artificial/Profile 1' },
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
launch: async () => ({
|
|
191
|
+
page: {
|
|
192
|
+
context: () => ({
|
|
193
|
+
storageState: async () => ({ cookies: [], origins: [] }),
|
|
194
|
+
}),
|
|
195
|
+
},
|
|
196
|
+
close: async () => {
|
|
197
|
+
closed++;
|
|
198
|
+
throw new Error('artificial close failure');
|
|
199
|
+
},
|
|
200
|
+
}),
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
assert.equal(sessions.length, 2);
|
|
204
|
+
assert.equal(closed, 2);
|
|
205
|
+
assert.ok(sessions.every((session) => /close failure/.test(session.error)));
|
|
206
|
+
});
|
|
@@ -106,6 +106,10 @@ describe('browser storage state', () => {
|
|
|
106
106
|
});
|
|
107
107
|
|
|
108
108
|
it('delegates Playwright saves to the page context', async () => {
|
|
109
|
+
temporaryDirectory = await mkdtemp(
|
|
110
|
+
path.join(os.tmpdir(), 'browser-commander-playwright-state-')
|
|
111
|
+
);
|
|
112
|
+
const filePath = path.join(temporaryDirectory, 'state.json');
|
|
109
113
|
const savedState = { cookies: [], origins: [] };
|
|
110
114
|
const calls = [];
|
|
111
115
|
const page = {
|
|
@@ -117,8 +121,9 @@ describe('browser storage state', () => {
|
|
|
117
121
|
}),
|
|
118
122
|
};
|
|
119
123
|
|
|
120
|
-
assert.equal(await saveStorageState(page,
|
|
121
|
-
assert.deepEqual(calls, [
|
|
124
|
+
assert.equal(await saveStorageState(page, filePath), savedState);
|
|
125
|
+
assert.deepEqual(calls, [undefined]);
|
|
126
|
+
assert.deepEqual(JSON.parse(await readFile(filePath, 'utf8')), savedState);
|
|
122
127
|
});
|
|
123
128
|
|
|
124
129
|
it('saves Puppeteer cookies and current-origin localStorage', async () => {
|