browser-commander 0.21.0 → 0.21.2

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 (53) hide show
  1. package/.jscpd.json +2 -1
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/package.json +6 -3
  5. package/src/browser/attach/snapshot.js +4 -4
  6. package/src/browser/browser-cookie-cache.js +14 -2
  7. package/src/browser/profile-directory.js +109 -6
  8. package/src/browser/real-browser.js +54 -16
  9. package/src/cli/args.js +47 -0
  10. package/src/cli/sessions.js +4 -0
  11. package/src/traces/bundle.js +3 -2
  12. package/src/traces/observers.js +14 -5
  13. package/src/traces/recorder.js +3 -0
  14. package/src/traces/viewer.js +16 -8
  15. package/tests/e2e/playwright.e2e.test.js +1 -0
  16. package/tests/e2e/puppeteer.e2e.test.js +1 -0
  17. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.html +2 -0
  18. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.png +1 -0
  19. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0001.state.json +54 -0
  20. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.html +2 -0
  21. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.png +1 -0
  22. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0002.state.json +60 -0
  23. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.html +2 -0
  24. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.png +1 -0
  25. package/tests/fixtures/traces/conformance/expected/bundle/checkpoints/0003.state.json +60 -0
  26. package/tests/fixtures/traces/conformance/expected/bundle/events.ndjson +18 -0
  27. package/tests/fixtures/traces/conformance/expected/bundle/manifest.json +77 -0
  28. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0001.ndjson +2 -0
  29. package/tests/fixtures/traces/conformance/expected/bundle/mutations/0002.ndjson +1 -0
  30. package/tests/fixtures/traces/conformance/expected/bundle/viewer.html +309 -0
  31. package/tests/fixtures/traces/conformance/expected/redaction.json +86 -0
  32. package/tests/fixtures/traces/conformance/expected/trace.lino +27 -0
  33. package/tests/fixtures/traces/conformance/scenario.json +249 -0
  34. package/tests/unit/browser/attach/extension-relay.test.js +1 -1
  35. package/tests/unit/browser/attach/snapshot-launch.test.js +10 -1
  36. package/tests/unit/browser/attach/snapshot.test.js +23 -4
  37. package/tests/unit/browser/browser-cookie-lock.test.js +102 -0
  38. package/tests/unit/browser/launch-building-blocks.test.js +127 -4
  39. package/tests/unit/browser/migration/index.test.js +1 -1
  40. package/tests/unit/browser/open-in-user-browser.test.js +1 -1
  41. package/tests/unit/browser/parity.test.js +1 -1
  42. package/tests/unit/browser/real-browser.test.js +1 -1
  43. package/tests/unit/browser/storage-state.test.js +2 -0
  44. package/tests/unit/cli/main.test.js +30 -2
  45. package/tests/unit/core/navigation-manager.test.js +11 -1
  46. package/tests/unit/playwright-protocol-coverage.test.js +231 -0
  47. package/tests/unit/puppeteer-bindings-coverage.test.js +214 -0
  48. package/tests/unit/scripts/ci-timeout-budgets.test.js +23 -0
  49. package/tests/unit/scripts/feature-parity.test.js +111 -5
  50. package/tests/unit/traces/conformance.test.js +92 -0
  51. package/tests/unit/traces/reader.test.js +1 -1
  52. package/tests/unit/traces/recorder.test.js +1 -1
  53. package/tests/unit/traces/viewer.test.js +78 -0
@@ -0,0 +1,249 @@
1
+ {
2
+ "description": "One continuous trace recorded against a fake page by JavaScript, Python and Rust; all three must write these bytes.",
3
+ "clock": { "now": 1767225600000, "monotonic": 1234.5 },
4
+ "engine": "playwright",
5
+ "options": {
6
+ "mode": "continuous",
7
+ "commanderVersion": "0.0.0-conformance",
8
+ "privacy": {
9
+ "redactPatterns": ["sk-[a-z0-9]+"],
10
+ "redactQueryParams": ["ticket"]
11
+ },
12
+ "limits": { "maxQueuedMutations": 100 },
13
+ "links": true
14
+ },
15
+ "snapshot": {
16
+ "html": "<!DOCTYPE html>\n<html><head><title>Start</title></head><body><form><input name=\"user\" value=\"alice\"><input type=\"checkbox\" name=\"agree\"><select name=\"plan\"><option>a</option></select></form></body></html>",
17
+ "truncated": false,
18
+ "state": {
19
+ "url": "https://user:pw@example.com/start?ticket=t-1&lang=en#access_token=abc&view=1",
20
+ "title": "Start – “quoted” 'single' \\ back",
21
+ "readyState": "complete",
22
+ "scroll": { "x": 0, "y": 12.5 },
23
+ "viewport": { "width": 1280, "height": 720 },
24
+ "activeElement": null,
25
+ "controls": [
26
+ {
27
+ "path": "form > input:nth-of-type(1)",
28
+ "tag": "input",
29
+ "type": "text",
30
+ "name": "user",
31
+ "id": null,
32
+ "focused": false,
33
+ "value": "alice",
34
+ "redacted": false
35
+ },
36
+ {
37
+ "path": "form > input:nth-of-type(2)",
38
+ "tag": "input",
39
+ "type": "checkbox",
40
+ "name": "agree",
41
+ "id": null,
42
+ "focused": false,
43
+ "value": "on",
44
+ "checked": false,
45
+ "redacted": false
46
+ },
47
+ {
48
+ "path": "form > select",
49
+ "tag": "select",
50
+ "type": null,
51
+ "name": "plan",
52
+ "id": null,
53
+ "focused": false,
54
+ "value": "a",
55
+ "selected": [0],
56
+ "redacted": false
57
+ }
58
+ ],
59
+ "frames": []
60
+ }
61
+ },
62
+ "steps": [
63
+ {
64
+ "op": "queueMutations",
65
+ "dropped": 2,
66
+ "batches": [
67
+ {
68
+ "at": 1767225600200,
69
+ "records": [
70
+ {
71
+ "type": "attributes",
72
+ "target": 4,
73
+ "name": "class",
74
+ "value": "busy"
75
+ }
76
+ ]
77
+ },
78
+ {
79
+ "at": 1767225600100,
80
+ "records": [
81
+ {
82
+ "type": "live-state",
83
+ "target": 2,
84
+ "property": "value",
85
+ "value": "bo"
86
+ },
87
+ {
88
+ "type": "childList",
89
+ "target": 1,
90
+ "added": [{ "id": 9, "html": "<p>new
line</p>" }],
91
+ "removed": [],
92
+ "index": 3
93
+ }
94
+ ]
95
+ }
96
+ ]
97
+ },
98
+ {
99
+ "op": "emit",
100
+ "event": "framenavigated",
101
+ "data": {
102
+ "url": "https://example.com/next?token=abc&q=a b",
103
+ "main": true
104
+ }
105
+ },
106
+ {
107
+ "op": "emit",
108
+ "event": "framenavigated",
109
+ "data": { "url": "https://ads.example.com/frame", "main": false }
110
+ },
111
+ {
112
+ "op": "emit",
113
+ "event": "console",
114
+ "data": {
115
+ "type": "log",
116
+ "text": "key sk-abc123 loaded\n\ttab \"and\" 'quotes'"
117
+ }
118
+ },
119
+ {
120
+ "op": "emit",
121
+ "event": "pageerror",
122
+ "data": {
123
+ "message": "boom: (unbalanced",
124
+ "stack": "Error: boom\n at main.js:1:2"
125
+ }
126
+ },
127
+ {
128
+ "op": "emit",
129
+ "event": "requestfailed",
130
+ "data": {
131
+ "url": "https://api.example.com/v1?api_key=k&page=2",
132
+ "method": "POST",
133
+ "errorText": "net::ERR_FAILED"
134
+ }
135
+ },
136
+ {
137
+ "op": "dialog",
138
+ "data": { "type": "confirm", "message": "Continue? #yes" }
139
+ },
140
+ {
141
+ "op": "download",
142
+ "phase": "completed",
143
+ "artifact": {
144
+ "id": "download-1",
145
+ "suggestedFilename": "report final.csv",
146
+ "path": "/downloads/report final.csv",
147
+ "checksum": "sha256:00ff",
148
+ "bytes": 42,
149
+ "url": "https://files.example.com/r.csv?signature=s"
150
+ }
151
+ },
152
+ {
153
+ "op": "interaction",
154
+ "action": "goto",
155
+ "target": "https://example.com/next?code=abc"
156
+ },
157
+ {
158
+ "op": "interaction",
159
+ "action": "clickButton",
160
+ "target": "#go",
161
+ "fail": "button #go is not visible"
162
+ },
163
+ {
164
+ "op": "setSnapshot",
165
+ "snapshot": {
166
+ "html": "<!DOCTYPE html>\n<html><body><form><input name=\"user\" value=\"bob\"><textarea>sk-zzz9 note</textarea></form></body></html>",
167
+ "truncated": true,
168
+ "state": {
169
+ "url": "https://example.com/next?token=abc",
170
+ "title": "Next",
171
+ "readyState": "interactive",
172
+ "scroll": { "x": 0, "y": 0 },
173
+ "viewport": { "width": 1280, "height": 720 },
174
+ "activeElement": "form > textarea",
175
+ "controls": [
176
+ {
177
+ "path": "form > input:nth-of-type(1)",
178
+ "tag": "input",
179
+ "type": "text",
180
+ "name": "user",
181
+ "id": null,
182
+ "focused": false,
183
+ "value": "bob",
184
+ "redacted": false
185
+ },
186
+ {
187
+ "path": "form > input:nth-of-type(2)",
188
+ "tag": "input",
189
+ "type": "checkbox",
190
+ "name": "agree",
191
+ "id": null,
192
+ "focused": false,
193
+ "value": "on",
194
+ "checked": true,
195
+ "redacted": false
196
+ },
197
+ {
198
+ "path": "form > textarea",
199
+ "tag": "textarea",
200
+ "type": null,
201
+ "name": null,
202
+ "id": "note",
203
+ "focused": true,
204
+ "value": "sk-zzz9 note",
205
+ "scroll": { "x": 0, "y": 3 },
206
+ "redacted": false
207
+ }
208
+ ],
209
+ "frames": [
210
+ { "url": "https://ads.example.com/frame", "crossOrigin": true }
211
+ ]
212
+ }
213
+ }
214
+ },
215
+ { "op": "checkpoint", "name": "after click", "actor": "automation" },
216
+ {
217
+ "op": "queueMutations",
218
+ "dropped": 0,
219
+ "batches": [
220
+ {
221
+ "at": 1767225600300,
222
+ "records": [
223
+ { "type": "characterData", "target": 7, "value": "done ✓ 😀" }
224
+ ]
225
+ }
226
+ ]
227
+ },
228
+ {
229
+ "op": "event",
230
+ "name": "note",
231
+ "data": {
232
+ "authorization": "Bearer xyz",
233
+ "detail": "uses sk-q1w2",
234
+ "pageUrl": "https://example.com/?session=s1",
235
+ "count": 3,
236
+ "ratio": 0.25,
237
+ "whole": 2.0,
238
+ "nested": { "list": [1, "two", null, true] }
239
+ }
240
+ },
241
+ {
242
+ "op": "checkpoint",
243
+ "name": "final",
244
+ "actor": "caller",
245
+ "reason": "failure"
246
+ },
247
+ { "op": "stop" }
248
+ ]
249
+ }
@@ -1,4 +1,4 @@
1
- // feature-parity: attach.extension
1
+ // feature-parity: attach.extension@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { once } from 'node:events';
4
4
  import { access } from 'node:fs/promises';
@@ -1,5 +1,5 @@
1
1
  import assert from 'node:assert';
2
- import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { describe, it } from 'node:test';
5
5
 
@@ -192,6 +192,15 @@ describe('snapshot attach launch option', () => {
192
192
  ]);
193
193
 
194
194
  await access(result.userDataDir);
195
+ assert.equal(
196
+ JSON.parse(
197
+ await readFile(
198
+ path.join(result.userDataDir, 'Profile 1', 'Preferences'),
199
+ 'utf8'
200
+ )
201
+ ).browser.check_default_browser,
202
+ false
203
+ );
195
204
  browserProcess.exit(0);
196
205
  await new Promise((resolve) => setTimeout(resolve, 50));
197
206
  await assert.rejects(access(result.userDataDir), { code: 'ENOENT' });
@@ -1,4 +1,4 @@
1
- // feature-parity: attach.snapshot
1
+ // feature-parity: attach.snapshot@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { createHash } from 'node:crypto';
4
4
  import {
@@ -151,9 +151,16 @@ describe('snapshotUserDataDir', () => {
151
151
  assert.equal(report.target, to);
152
152
  assert.deepEqual(report.warnings, []);
153
153
  assert.equal(report.copied.databases, 1);
154
+ const localState = JSON.parse(
155
+ await readFile(path.join(to, 'Local State'), 'utf8')
156
+ );
157
+ assert.deepEqual(localState.os_crypt, { key: 'k' });
158
+ assert.equal(localState.browser.default_browser_infobar_declined_count, 5);
159
+ assert.equal(localState.browser.default_browser_declined_count, 5);
154
160
  assert.equal(
155
- await readFile(path.join(to, 'Local State'), 'utf8'),
156
- JSON.stringify({ os_crypt: { key: 'k' } })
161
+ (await readProfileJson(path.join(to, 'Default'), 'Preferences')).browser
162
+ .check_default_browser,
163
+ false
157
164
  );
158
165
  assert.deepEqual(
159
166
  await readProfileJson(path.join(to, 'Default'), 'Bookmarks'),
@@ -258,7 +265,10 @@ describe('snapshotUserDataDir', () => {
258
265
 
259
266
  const copy = path.join(to, 'Default', 'History');
260
267
  // One self-contained file: the committed WAL content is folded in.
261
- assert.deepEqual(await readdir(path.join(to, 'Default')), ['History']);
268
+ assert.deepEqual(await readdir(path.join(to, 'Default')), [
269
+ 'History',
270
+ 'Preferences',
271
+ ]);
262
272
  assert.equal(report.copied.databases, 1);
263
273
  assert.equal(countRows(copy), 6);
264
274
  const database = new BetterSqlite3(copy, { readonly: true });
@@ -335,6 +345,15 @@ describe('snapshotUserDataDir', () => {
335
345
  try {
336
346
  assert.equal(report.source.profile, 'Profile 1');
337
347
  await stat(path.join(report.target, 'Profile 1', 'Preferences'));
348
+ assert.equal(
349
+ (
350
+ await readProfileJson(
351
+ path.join(report.target, 'Profile 1'),
352
+ 'Preferences'
353
+ )
354
+ ).browser.check_default_browser,
355
+ false
356
+ );
338
357
  await assert.rejects(stat(path.join(report.target, 'Default')), {
339
358
  code: 'ENOENT',
340
359
  });
@@ -0,0 +1,102 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs/promises';
3
+ import { syncBuiltinESMExports } from 'node:module';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { test } from 'node:test';
7
+
8
+ import {
9
+ clearBrowserCookieMemoryCache,
10
+ getCachedCredential,
11
+ } from '../../../src/browser/browser-cookie-cache.js';
12
+
13
+ async function cleanup(t, directory) {
14
+ t.mock.restoreAll();
15
+ syncBuiltinESMExports();
16
+ clearBrowserCookieMemoryCache();
17
+ await fs.rm(directory, { recursive: true, force: true });
18
+ }
19
+
20
+ test('retries a Windows delete-pending credential lock without rereading the credential', async (t) => {
21
+ const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-cookie-lock-'));
22
+ const realOpen = fs.open;
23
+ let attempted = false;
24
+ let reads = 0;
25
+ // Model the observed Windows exclusive-open EPERM while the previous
26
+ // reader's lock is pending deletion. Other file operations stay real.
27
+ t.mock.method(fs, 'open', async (filename, ...args) => {
28
+ if (filename.endsWith('.lock') && !attempted) {
29
+ attempted = true;
30
+ throw Object.assign(new Error('lock pending deletion'), {
31
+ code: 'EPERM',
32
+ });
33
+ }
34
+ return realOpen(filename, ...args);
35
+ });
36
+ syncBuiltinESMExports();
37
+ try {
38
+ const options = {
39
+ cache: { enabled: true, dir: directory, ttlSeconds: 60 },
40
+ identity: 'windows-lock-contention',
41
+ refresh: false,
42
+ metadata: {},
43
+ platform: 'win32',
44
+ create: async () => {
45
+ reads += 1;
46
+ return Buffer.from('credential');
47
+ },
48
+ };
49
+ assert.deepEqual(
50
+ await getCachedCredential(options),
51
+ Buffer.from('credential')
52
+ );
53
+ clearBrowserCookieMemoryCache();
54
+ assert.deepEqual(
55
+ await getCachedCredential(options),
56
+ Buffer.from('credential')
57
+ );
58
+ assert.equal(reads, 1);
59
+ assert.ok(attempted);
60
+ } finally {
61
+ await cleanup(t, directory);
62
+ }
63
+ });
64
+
65
+ test('does not retry permanent permission errors or Unix EPERM', async (t) => {
66
+ const directory = await fs.mkdtemp(
67
+ path.join(os.tmpdir(), 'bc-cookie-denied-')
68
+ );
69
+ const realOpen = fs.open;
70
+ try {
71
+ for (const [platform, code] of [
72
+ ['win32', 'EACCES'],
73
+ ['linux', 'EPERM'],
74
+ ]) {
75
+ const error = Object.assign(new Error('access denied'), { code });
76
+ t.mock.method(fs, 'open', async (filename, ...args) => {
77
+ if (filename.endsWith('.lock')) {
78
+ throw error;
79
+ }
80
+ return realOpen(filename, ...args);
81
+ });
82
+ syncBuiltinESMExports();
83
+ await assert.rejects(
84
+ getCachedCredential({
85
+ cache: { enabled: true, dir: directory, ttlSeconds: 60 },
86
+ identity: platform,
87
+ refresh: false,
88
+ metadata: {},
89
+ platform,
90
+ create: async () => {
91
+ throw new Error('must not read credentials');
92
+ },
93
+ }),
94
+ (received) => received === error
95
+ );
96
+ t.mock.restoreAll();
97
+ syncBuiltinESMExports();
98
+ }
99
+ } finally {
100
+ await cleanup(t, directory);
101
+ }
102
+ });
@@ -6,6 +6,7 @@ import path from 'node:path';
6
6
  import { PassThrough } from 'node:stream';
7
7
  import {
8
8
  access,
9
+ mkdir,
9
10
  mkdtemp,
10
11
  readFile,
11
12
  rm,
@@ -22,8 +23,10 @@ import {
22
23
  FIRST_RUN_SENTINEL,
23
24
  INITIAL_LOCAL_STATE,
24
25
  LOCAL_STATE_FILE,
26
+ PREFERENCES_FILE,
25
27
  TEMPORARY_PROFILE_PREFIX,
26
28
  createTemporaryUserDataDir,
29
+ configureUserDataDir,
27
30
  prepareUserDataDir,
28
31
  removeUserDataDir,
29
32
  } from '../../../src/browser/profile-directory.js';
@@ -106,6 +109,11 @@ describe('launch restrictions', () => {
106
109
  });
107
110
 
108
111
  describe('profile directory', () => {
112
+ const readPreferences = async (directory) =>
113
+ JSON.parse(
114
+ await readFile(path.join(directory, 'Default', 'Preferences'), 'utf8')
115
+ );
116
+
109
117
  it('creates a fresh profile with the First Run sentinel', async () => {
110
118
  const dir = await createTemporaryUserDataDir();
111
119
  try {
@@ -113,15 +121,25 @@ describe('profile directory', () => {
113
121
  await access(path.join(dir, FIRST_RUN_SENTINEL));
114
122
  assert.deepEqual(
115
123
  JSON.parse(await readFile(path.join(dir, LOCAL_STATE_FILE), 'utf8')),
116
- INITIAL_LOCAL_STATE
124
+ {
125
+ browser: {
126
+ ...INITIAL_LOCAL_STATE.browser,
127
+ default_browser_infobar_declined_count: 5,
128
+ default_browser_declined_count: 5,
129
+ },
130
+ fre: INITIAL_LOCAL_STATE.fre,
131
+ }
117
132
  );
133
+ assert.deepEqual(await readPreferences(dir), {
134
+ browser: { check_default_browser: false },
135
+ });
118
136
  } finally {
119
137
  await removeUserDataDir(dir);
120
138
  }
121
139
  await assert.rejects(() => access(dir), /ENOENT/);
122
140
  });
123
141
 
124
- it('never overwrites Local State that Chrome already wrote', async () => {
142
+ it('preserves other Local State values that Chrome already wrote', async () => {
125
143
  const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
126
144
  try {
127
145
  const localState = path.join(dir, LOCAL_STATE_FILE);
@@ -130,9 +148,61 @@ describe('profile directory', () => {
130
148
  '{"browser":{"enabled_labs_experiments":[]}}'
131
149
  );
132
150
  await prepareUserDataDir(dir);
151
+ assert.deepEqual(JSON.parse(await readFile(localState, 'utf8')), {
152
+ browser: {
153
+ enabled_labs_experiments: [],
154
+ default_browser_infobar_declined_count: 5,
155
+ default_browser_declined_count: 5,
156
+ },
157
+ });
158
+ } finally {
159
+ await rm(dir, { recursive: true, force: true });
160
+ }
161
+ });
162
+
163
+ it('deep-merges settings and lets the named default-browser option override a copied preference', async () => {
164
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
165
+ try {
166
+ await prepareUserDataDir(dir);
167
+ await writeFile(
168
+ path.join(dir, 'Default', 'Preferences'),
169
+ JSON.stringify({
170
+ browser: { check_default_browser: true, show_home_button: false },
171
+ intl: { accept_languages: 'en' },
172
+ })
173
+ );
174
+ await prepareUserDataDir(dir, {
175
+ defaultBrowserCheck: false,
176
+ preferences: { browser: { show_home_button: true } },
177
+ localState: { browser: { extra: 1 } },
178
+ });
179
+ assert.deepEqual(await readPreferences(dir), {
180
+ browser: { check_default_browser: false, show_home_button: true },
181
+ intl: { accept_languages: 'en' },
182
+ });
183
+ assert.deepEqual(
184
+ JSON.parse(await readFile(path.join(dir, LOCAL_STATE_FILE), 'utf8')),
185
+ {
186
+ browser: {
187
+ last_whats_new_version: 9999,
188
+ default_browser_infobar_declined_count: 5,
189
+ default_browser_declined_count: 5,
190
+ extra: 1,
191
+ },
192
+ fre: { has_user_seen_fre: true },
193
+ }
194
+ );
195
+ await prepareUserDataDir(dir, { defaultBrowserCheck: true });
133
196
  assert.equal(
134
- await readFile(localState, 'utf8'),
135
- '{"browser":{"enabled_labs_experiments":[]}}'
197
+ JSON.parse(
198
+ await readFile(path.join(dir, 'Default', 'Preferences'), 'utf8')
199
+ ).browser.check_default_browser,
200
+ true
201
+ );
202
+ assert.equal(
203
+ JSON.parse(await readFile(path.join(dir, LOCAL_STATE_FILE), 'utf8'))
204
+ .browser.default_browser_declined_count,
205
+ 0
136
206
  );
137
207
  } finally {
138
208
  await rm(dir, { recursive: true, force: true });
@@ -153,6 +223,59 @@ describe('profile directory', () => {
153
223
  await rm(parent, { recursive: true, force: true });
154
224
  }
155
225
  });
226
+
227
+ it('allows an explicit first-run flow in a new profile', async () => {
228
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
229
+ try {
230
+ await prepareUserDataDir(dir, { firstRun: true });
231
+ await assert.rejects(
232
+ () => access(path.join(dir, FIRST_RUN_SENTINEL)),
233
+ /ENOENT/
234
+ );
235
+ } finally {
236
+ await rm(dir, { recursive: true, force: true });
237
+ }
238
+ });
239
+
240
+ it('rejects malformed profile settings before writing them', async () => {
241
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
242
+ try {
243
+ await assert.rejects(
244
+ prepareUserDataDir(dir, { preferences: { browser: false } }),
245
+ /preferences.browser/u
246
+ );
247
+ await assert.rejects(
248
+ prepareUserDataDir(dir, { preferences: JSON.parse('{"__proto__":1}') }),
249
+ /profile setting key/u
250
+ );
251
+ } finally {
252
+ await rm(dir, { recursive: true, force: true });
253
+ }
254
+ });
255
+
256
+ it('configures a selected snapshot profile instead of Default', async () => {
257
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'bc-profile-test-'));
258
+ try {
259
+ const selected = path.join(dir, 'Profile 1');
260
+ await mkdir(selected);
261
+ await writeFile(
262
+ path.join(selected, 'Preferences'),
263
+ '{"browser":{"check_default_browser":true}}'
264
+ );
265
+ await configureUserDataDir(dir, { profileDirectory: 'Profile 1' });
266
+ assert.equal(
267
+ JSON.parse(await readFile(path.join(selected, 'Preferences'), 'utf8'))
268
+ .browser.check_default_browser,
269
+ false
270
+ );
271
+ await assert.rejects(
272
+ () => access(path.join(dir, PREFERENCES_FILE)),
273
+ /ENOENT/
274
+ );
275
+ } finally {
276
+ await rm(dir, { recursive: true, force: true });
277
+ }
278
+ });
156
279
  });
157
280
 
158
281
  describe('debugging port', () => {
@@ -1,4 +1,4 @@
1
- // feature-parity: migration.profile
1
+ // feature-parity: migration.profile@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
@@ -1,4 +1,4 @@
1
- // feature-parity: attach.open
1
+ // feature-parity: attach.open@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { describe, it } from 'node:test';
4
4
 
@@ -1,4 +1,4 @@
1
- // feature-parity: parity.measure
1
+ // feature-parity: parity.measure@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { describe, it } from 'node:test';
4
4
 
@@ -1,4 +1,4 @@
1
- // feature-parity: migration.launch
1
+ // feature-parity: migration.launch@native-typed
2
2
  import assert from 'node:assert';
3
3
  import { access, mkdtemp, rm } from 'node:fs/promises';
4
4
  import os from 'node:os';
@@ -146,3 +146,5 @@ describe('browser storage state', () => {
146
146
  assert.deepEqual(JSON.parse(await readFile(filePath, 'utf8')), state);
147
147
  });
148
148
  });
149
+
150
+ // feature-parity: storage.portable@native-typed