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.
Files changed (58) hide show
  1. package/.jscpd-baseline.json +24 -23
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +2 -0
  4. package/package.json +5 -3
  5. package/src/bindings.js +19 -0
  6. package/src/browser/attach/snapshot-launch.js +8 -6
  7. package/src/browser/attach/snapshot.js +39 -2
  8. package/src/browser/browser-cookie-database.js +71 -2
  9. package/src/browser/browser-cookie-session.js +183 -0
  10. package/src/browser/browser-cookies.js +29 -0
  11. package/src/browser/browser-sources.js +13 -0
  12. package/src/browser/browser-sources.json +13 -0
  13. package/src/browser/debugging-port.js +4 -2
  14. package/src/browser/default-browser.js +33 -9
  15. package/src/browser/launch-diagnostics.js +102 -0
  16. package/src/browser/launcher.js +28 -8
  17. package/src/browser/migration/sqlite-snapshot.js +5 -27
  18. package/src/browser/navigation.js +173 -98
  19. package/src/browser/real-browser.js +135 -68
  20. package/src/browser/session-cookies.js +78 -0
  21. package/src/browser/session-persistence.js +72 -0
  22. package/src/browser/storage-state.js +32 -6
  23. package/src/browser/system-browser.js +2 -2
  24. package/src/core/dialog-manager.js +7 -0
  25. package/src/core/managed-navigation.js +101 -0
  26. package/src/core/navigation-manager.js +62 -78
  27. package/src/core/navigation-operation.js +25 -0
  28. package/src/core/navigation-readiness.js +40 -16
  29. package/src/core/network-tracker.js +39 -92
  30. package/src/core/page-session.js +3 -2
  31. package/src/core/page-trigger-manager.js +15 -12
  32. package/src/core/readiness.js +67 -13
  33. package/src/core/subscriptions.js +20 -0
  34. package/src/core/webdriver-page.js +38 -0
  35. package/src/elements/locators.js +35 -5
  36. package/src/elements/reusable.js +92 -0
  37. package/src/elements/visibility.js +6 -11
  38. package/src/exports.js +14 -0
  39. package/src/factory.js +4 -4
  40. package/src/high-level/universal-logic.js +57 -21
  41. package/src/interactions/click.js +3 -0
  42. package/src/interactions/scroll.js +8 -2
  43. package/src/utilities/subprocess.js +15 -4
  44. package/tests/e2e/navigation-budget.e2e.test.js +132 -0
  45. package/tests/e2e/reusable-session.e2e.test.js +231 -0
  46. package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
  47. package/tests/unit/browser/attach/snapshot.test.js +33 -0
  48. package/tests/unit/browser/catalogue-launch.test.js +50 -2
  49. package/tests/unit/browser/default-browser.test.js +18 -0
  50. package/tests/unit/browser/launch-diagnostics.test.js +52 -0
  51. package/tests/unit/browser/real-browser.test.js +1 -1
  52. package/tests/unit/browser/session-workflows.test.js +206 -0
  53. package/tests/unit/browser/storage-state.test.js +7 -2
  54. package/tests/unit/core/navigation-budget.test.js +120 -0
  55. package/tests/unit/core/navigation-manager.test.js +23 -1
  56. package/tests/unit/core/webdriver-page.test.js +33 -0
  57. package/tests/unit/elements/reusable-helpers.test.js +83 -0
  58. 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&nbsp; 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
- for (const root of resolveBrowserRoots(browser.id, options)) {
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
+ });
@@ -405,7 +405,7 @@ describe('launchAndConnectRealBrowser', () => {
405
405
  },
406
406
  }
407
407
  ),
408
- PortRaceError
408
+ (error) => error.category === 'port_race' && error.phase === 'endpoint'
409
409
  );
410
410
  assert.equal(attempts, 1);
411
411
  });
@@ -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, '/tmp/state.json'), savedState);
121
- assert.deepEqual(calls, [{ path: '/tmp/state.json' }]);
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 () => {