browser-commander 0.22.0 → 0.23.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/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b56775b: Add Safari and Safari Technology Preview cookie sources, including installed,
8
+ default/auto and custom-directory import, counts-only domain discovery, validated
9
+ binarycookies decoding and actionable Full Disk Access errors. Migration reports
10
+ unsupported Safari classes and the SameSite Lax fallback. Other Safari stores,
11
+ Firefox/WebKit targets, full clones and additional data classes remain tracked in
12
+ issues #117–#120; this release does not claim full Safari profile import.
13
+
3
14
  ## 0.22.0
4
15
 
5
16
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -33,6 +33,11 @@ import {
33
33
  resolveSourceBrowser,
34
34
  } from './browser-profiles.js';
35
35
  import { resolveDefaultBrowser } from './default-browser.js';
36
+ import {
37
+ countSafariCookies,
38
+ parseSafariCookies,
39
+ readSafariCookieFile,
40
+ } from './safari-cookies.js';
36
41
 
37
42
  const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
38
43
  const MICROSECONDS_PER_SECOND = 1_000_000n;
@@ -319,6 +324,14 @@ export async function readBrowserCookiesWithDependencies(
319
324
  return cachedCookies;
320
325
  }
321
326
 
327
+ if (browserFamily(browser) === 'safari') {
328
+ const cookies = parseSafariCookies(
329
+ await readSafariCookieFile(cookiePath, { environment }),
330
+ options.domainFilter
331
+ );
332
+ await writeCookieResultCache({ cache, identity, cookies, now });
333
+ return cookies;
334
+ }
322
335
  const database = await openCookieDatabase(cookiePath);
323
336
  let cookies;
324
337
  try {
@@ -426,8 +439,15 @@ export async function listCookieSources({
426
439
  let error;
427
440
  let database;
428
441
  try {
429
- database = await openCookieDatabase(cookiePath);
430
- counts = countCookiesByDomain(database, family, domains);
442
+ if (family === 'safari') {
443
+ counts = countSafariCookies(
444
+ await readSafariCookieFile(cookiePath, { environment }),
445
+ domains
446
+ );
447
+ } else {
448
+ database = await openCookieDatabase(cookiePath);
449
+ counts = countCookiesByDomain(database, family, domains);
450
+ }
431
451
  } catch (openError) {
432
452
  error = openError.message;
433
453
  } finally {
@@ -514,12 +534,24 @@ export async function resolveImportSource({
514
534
  environment,
515
535
  runCommand,
516
536
  });
517
- const holders = (
518
- await listCookieSources({ domains, platform, homeDir, environment })
519
- ).filter((source) => !source.error);
537
+ const listedSources = await listCookieSources({
538
+ domains,
539
+ platform,
540
+ homeDir,
541
+ environment,
542
+ });
543
+ const holders = listedSources.filter((source) => !source.error);
520
544
  const fromDefault = holders.filter(
521
545
  (source) => source.browser === systemDefault
522
546
  );
547
+ const unreadableDefault = listedSources.find(
548
+ (source) => source.browser === systemDefault && source.error
549
+ );
550
+ if (fromDefault.length === 0 && unreadableDefault) {
551
+ throw new Error(
552
+ `Could not inspect the default browser (${systemDefault}): ${unreadableDefault.error}`
553
+ );
554
+ }
523
555
  const candidates = fromDefault.length > 0 ? fromDefault : holders;
524
556
  if (candidates.length === 0) {
525
557
  if (!systemDefault) {
@@ -10,6 +10,7 @@ import {
10
10
  resolveBrowserRoots,
11
11
  } from './browser-sources.js';
12
12
  import { resolveDefaultBrowser } from './default-browser.js';
13
+ import { findSafariCookieFile } from './safari-cookies.js';
13
14
 
14
15
  /**
15
16
  * Keywords that select the operating-system default browser instead of a named
@@ -112,6 +113,9 @@ function chromiumCookiePaths(profilePath, pathApi = path) {
112
113
 
113
114
  export async function findCookieDatabase(browser, profilePath, platform) {
114
115
  const pathApi = platformPath(platform ?? process.platform);
116
+ if (browserFamily(browser) === 'safari') {
117
+ return findSafariCookieFile(profilePath);
118
+ }
115
119
  if (browserFamily(browser) === 'firefox') {
116
120
  const candidate = pathApi.join(profilePath, 'cookies.sqlite');
117
121
  return (await pathExists(candidate)) ? candidate : null;
@@ -274,6 +278,20 @@ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
274
278
  const family = browserFamily(browser);
275
279
  const profiles = [];
276
280
  for (const root of roots) {
281
+ if (family === 'safari') {
282
+ if (await findSafariCookieFile(root)) {
283
+ return [
284
+ {
285
+ browser,
286
+ name: 'Default',
287
+ displayName: 'Default',
288
+ path: root,
289
+ isDefault: true,
290
+ },
291
+ ];
292
+ }
293
+ continue;
294
+ }
277
295
  profiles.push(
278
296
  ...(family === 'firefox'
279
297
  ? await listFirefoxProfiles(browser, root, platform)
@@ -394,6 +394,36 @@
394
394
  "darwin": ["org.floorp.Floorp"],
395
395
  "linux": ["floorp.desktop"]
396
396
  }
397
+ },
398
+ {
399
+ "id": "safari",
400
+ "family": "safari",
401
+ "aliases": ["apple-safari"],
402
+ "singleProfile": true,
403
+ "roots": {
404
+ "darwin": [
405
+ "{home}/Library/Containers/com.apple.Safari/Data/Library",
406
+ "{home}/Library"
407
+ ]
408
+ },
409
+ "default": {
410
+ "darwin": ["com.apple.safari"]
411
+ }
412
+ },
413
+ {
414
+ "id": "safari-technology-preview",
415
+ "family": "safari",
416
+ "aliases": ["safari-tp", "safari-preview"],
417
+ "singleProfile": true,
418
+ "roots": {
419
+ "darwin": [
420
+ "{home}/Library/Containers/com.apple.SafariTechnologyPreview/Data/Library",
421
+ "{home}/Library/Safari Technology Preview"
422
+ ]
423
+ },
424
+ "default": {
425
+ "darwin": ["com.apple.safaritechnologypreview"]
426
+ }
397
427
  }
398
428
  ]
399
429
  }
@@ -114,6 +114,34 @@ function mergeReport(report, className, fragment) {
114
114
  report.warnings.push(...(fragment.warnings ?? []));
115
115
  }
116
116
 
117
+ function reportSafariClasses(report, selected) {
118
+ for (const type of ALL_DATA_CLASSES.filter(
119
+ (name) => name !== 'cookies' && selected.has(name)
120
+ )) {
121
+ report.skipped.push({
122
+ type,
123
+ item: report.source.browser,
124
+ reason:
125
+ type === 'passwords'
126
+ ? 'safari-password-export-required'
127
+ : 'safari-class-not-supported',
128
+ detail:
129
+ type === 'passwords'
130
+ ? 'Safari passwords live in the Keychain. Export Passwords from Safari or the Passwords app to CSV; CSV import is tracked separately and is not supported yet.'
131
+ : 'Safari currently supports cookie import only; this data class has not been translated.',
132
+ });
133
+ }
134
+ if (report.cookies.length > 0) {
135
+ report.warnings.push({
136
+ type: 'cookies',
137
+ item: report.source.browser,
138
+ reason: 'safari-samesite-unavailable',
139
+ detail:
140
+ 'Cookies.binarycookies does not store SameSite; imported cookies use Lax.',
141
+ });
142
+ }
143
+ }
144
+
117
145
  async function resolvePasswordKeys({
118
146
  keys,
119
147
  browser,
@@ -234,6 +262,11 @@ export async function migrateProfile({
234
262
  }
235
263
  }
236
264
 
265
+ if (browserFamily(browser) === 'safari') {
266
+ reportSafariClasses(report, selected);
267
+ return report;
268
+ }
269
+
237
270
  if (selected.has('bookmarks')) {
238
271
  mergeReport(
239
272
  report,
@@ -0,0 +1,173 @@
1
+ /** Read Safari's unencrypted, mixed-endian Cookies.binarycookies store.
2
+ * Format: https://github.com/libyal/dtformats/blob/main/documentation/Safari%20Cookies.asciidoc
3
+ * No SameSite attribute is stored in this format; use Lax and report that loss.
4
+ */
5
+ import { readFile as readFileBytes, stat } from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import { TextDecoder } from 'node:util';
8
+
9
+ const APPLE_EPOCH = 978_307_200;
10
+ const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
11
+
12
+ function invalid(detail) {
13
+ throw new Error(`Invalid Safari binarycookies: ${detail}`);
14
+ }
15
+
16
+ function bounded(data, offset, size) {
17
+ if (offset < 0 || size < 0 || offset + size > data.length) {
18
+ invalid('truncated or out-of-range data');
19
+ }
20
+ return data.subarray(offset, offset + size);
21
+ }
22
+
23
+ function uint(data, offset, bigEndian = false) {
24
+ const bytes = bounded(data, offset, 4);
25
+ return bigEndian ? bytes.readUInt32BE() : bytes.readUInt32LE();
26
+ }
27
+
28
+ function string(record, field) {
29
+ const offset = uint(record, field);
30
+ if (offset < 56 || offset >= record.length) {
31
+ invalid('string offset');
32
+ }
33
+ const end = record.indexOf(0, offset);
34
+ if (end < 0) {
35
+ invalid('unterminated string');
36
+ }
37
+ try {
38
+ return utf8.decode(record.subarray(offset, end));
39
+ } catch {
40
+ return invalid('invalid UTF-8');
41
+ }
42
+ }
43
+
44
+ /** Validate all structural offsets before walking records; counts are bounded
45
+ * by the file size, so forged counts cannot cause unbounded allocations.
46
+ */
47
+ function* records(data) {
48
+ if (bounded(data, 0, 4).toString('ascii') !== 'cook') {
49
+ invalid('file signature');
50
+ }
51
+ const pages = uint(data, 4, true);
52
+ bounded(data, 8, pages * 4);
53
+ let pageStart = 8 + pages * 4;
54
+ for (let index = 0; index < pages; index += 1) {
55
+ const page = bounded(data, pageStart, uint(data, 8 + index * 4, true));
56
+ pageStart += page.length;
57
+ if (uint(page, 0) !== 0x00010000) {
58
+ invalid('page signature');
59
+ }
60
+ const count = uint(page, 4);
61
+ const headerSize = 8 + count * 4;
62
+ bounded(page, 0, headerSize);
63
+ let previousEnd = headerSize;
64
+ for (let cookie = 0; cookie < count; cookie += 1) {
65
+ const start = uint(page, 8 + cookie * 4);
66
+ if (start < previousEnd) {
67
+ invalid('overlapping record');
68
+ }
69
+ const size = uint(page, start);
70
+ if (size < 56) {
71
+ invalid('short record');
72
+ }
73
+ const record = bounded(page, start, size);
74
+ previousEnd = start + size;
75
+ yield record;
76
+ }
77
+ }
78
+ // Safari may append a checksum and a plist trailer. They are not cookies.
79
+ }
80
+
81
+ /** Decode cookies in automation shape, using the existing substring filter. */
82
+ export function parseSafariCookies(data, domainFilter) {
83
+ const cookies = [];
84
+ for (const record of records(data)) {
85
+ const domain = string(record, 16);
86
+ if (
87
+ domainFilter &&
88
+ !domain.toLowerCase().includes(domainFilter.toLowerCase())
89
+ ) {
90
+ continue;
91
+ }
92
+ const expiry = record.readDoubleLE(40) + APPLE_EPOCH;
93
+ if (
94
+ !Number.isFinite(expiry) ||
95
+ Math.abs(expiry) > Number.MAX_SAFE_INTEGER
96
+ ) {
97
+ invalid('expiry');
98
+ }
99
+ const flags = uint(record, 8);
100
+ cookies.push({
101
+ name: string(record, 20),
102
+ value: string(record, 28),
103
+ domain,
104
+ path: string(record, 24) || '/',
105
+ expires: Math.floor(expiry),
106
+ httpOnly: Boolean(flags & 4),
107
+ secure: Boolean(flags & 1),
108
+ sameSite: 'Lax',
109
+ });
110
+ }
111
+ return cookies;
112
+ }
113
+
114
+ /** Counts decode only domain strings. Names and values are never decoded. */
115
+ export function countSafariCookies(data, domains) {
116
+ const byDomain = domains?.length
117
+ ? Object.fromEntries(domains.map((domain) => [domain, 0]))
118
+ : null;
119
+ let total = 0;
120
+ for (const record of records(data)) {
121
+ const host = string(record, 16).toLowerCase();
122
+ total += 1;
123
+ for (const domain of Object.keys(byDomain ?? {})) {
124
+ if (host.includes(domain.toLowerCase())) {
125
+ byDomain[domain] += 1;
126
+ }
127
+ }
128
+ }
129
+ return { total, byDomain };
130
+ }
131
+
132
+ /** A protected path is still a source: preserve it for an actionable read error. */
133
+ export async function findSafariCookieFile(profileDir, statFile = stat) {
134
+ for (const candidate of [
135
+ path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
136
+ path.join(profileDir, 'Cookies.binarycookies'),
137
+ ]) {
138
+ try {
139
+ if ((await statFile(candidate)).isFile()) {
140
+ return candidate;
141
+ }
142
+ } catch (error) {
143
+ if (['EPERM', 'EACCES'].includes(error.code)) {
144
+ return candidate;
145
+ }
146
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
147
+ throw error;
148
+ }
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+
154
+ export async function readSafariCookieFile(
155
+ filePath,
156
+ { environment = process.env, readFile = readFileBytes } = {}
157
+ ) {
158
+ try {
159
+ return await readFile(filePath);
160
+ } catch (error) {
161
+ if (!['EPERM', 'EACCES'].includes(error.code)) {
162
+ throw error;
163
+ }
164
+ const app =
165
+ environment.__CFBundleIdentifier ||
166
+ environment.TERM_PROGRAM ||
167
+ process.execPath;
168
+ throw new Error(
169
+ `Safari cookie access denied (${error.code}) at ${filePath}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
170
+ { cause: error }
171
+ );
172
+ }
173
+ }
@@ -0,0 +1,246 @@
1
+ // feature-parity: sources.safari-cookies@native-typed
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import { PassThrough } from 'node:stream';
7
+ import { describe, it } from 'node:test';
8
+
9
+ import {
10
+ listCookieSources,
11
+ readBrowserCookiesWithDependencies,
12
+ resolveImportSource,
13
+ } from '../../../src/browser/browser-cookies.js';
14
+ import { listBrowserProfiles } from '../../../src/browser/browser-profiles.js';
15
+ import { migrateProfile } from '../../../src/browser/migration/index.js';
16
+ import { runCli } from '../../../src/cli/main.js';
17
+ import { runScript } from '../../../src/cli/script.js';
18
+ import {
19
+ findSafariCookieFile,
20
+ parseSafariCookies,
21
+ readSafariCookieFile,
22
+ } from '../../../src/browser/safari-cookies.js';
23
+ import { repoPath } from '../../helpers/repo.js';
24
+ import { useTempDirectories } from '../../helpers/temp-directory.js';
25
+
26
+ const fixture = await readFile(
27
+ repoPath('tests/fixtures/safari/Cookies.binarycookies')
28
+ );
29
+ const expected = JSON.parse(
30
+ await readFile(repoPath('tests/fixtures/safari/expected.json'), 'utf8')
31
+ );
32
+ const makeDirectory = useTempDirectories();
33
+ const safariDefault = async () =>
34
+ '( { LSHandlerURLScheme = https; LSHandlerRoleAll = "com.apple.Safari"; } )';
35
+
36
+ async function installed(browser = 'safari', legacy = false) {
37
+ const homeDir = await makeDirectory('bc-safari-');
38
+ const bundle =
39
+ browser === 'safari'
40
+ ? 'com.apple.Safari'
41
+ : 'com.apple.SafariTechnologyPreview';
42
+ const root = legacy
43
+ ? path.join(homeDir, 'Library')
44
+ : path.join(homeDir, 'Library', 'Containers', bundle, 'Data', 'Library');
45
+ await mkdir(path.join(root, 'Cookies'), { recursive: true });
46
+ const file = path.join(root, 'Cookies', 'Cookies.binarycookies');
47
+ await copyFile(repoPath('tests/fixtures/safari/Cookies.binarycookies'), file);
48
+ return { homeDir, root, file, platform: 'darwin', environment: {} };
49
+ }
50
+
51
+ describe('Safari cookies', { timeout: 10000 }, () => {
52
+ it('decodes multiple pages, flags, UTF-8 and Apple epoch without guessing session cookies', () => {
53
+ assert.deepEqual(parseSafariCookies(fixture), expected);
54
+ });
55
+
56
+ it('rejects truncated data and forged counts, record sizes, offsets, strings and expiry', () => {
57
+ // The last eight bytes are an optional footer; every shorter prefix is invalid.
58
+ for (let end = 0; end < fixture.length - 8; end += 1) {
59
+ assert.throws(
60
+ () => parseSafariCookies(fixture.subarray(0, end)),
61
+ /Invalid Safari binarycookies/
62
+ );
63
+ }
64
+ for (const [offset, value] of [
65
+ [4, 0xffffffff],
66
+ [24, 0xffffffff],
67
+ [28, 0xffffffff],
68
+ [40, 0xffffffff],
69
+ [56, 0],
70
+ ]) {
71
+ const broken = Buffer.from(fixture);
72
+ broken.writeUInt32LE(value, offset);
73
+ assert.throws(
74
+ () => parseSafariCookies(broken),
75
+ /Invalid Safari binarycookies/
76
+ );
77
+ }
78
+ const broken = Buffer.from(fixture);
79
+ broken.writeDoubleLE(NaN, 80);
80
+ assert.throws(
81
+ () => parseSafariCookies(broken),
82
+ /Invalid Safari binarycookies/
83
+ );
84
+ });
85
+
86
+ it('reads both Safari variants and the legacy path without a keystore', async () => {
87
+ for (const [browser, legacy] of [
88
+ ['safari', false],
89
+ ['safari-technology-preview', false],
90
+ ['safari', true],
91
+ ]) {
92
+ const options = await installed(browser, legacy);
93
+ const before = await readFile(options.file);
94
+ const profiles = await listBrowserProfiles({ ...options, browser });
95
+ assert.equal(profiles[0].path, options.root);
96
+ const cookies = await readBrowserCookiesWithDependencies(
97
+ { browser, cache: false },
98
+ options
99
+ );
100
+ assert.deepEqual(cookies, expected);
101
+ assert.deepEqual(await readFile(options.file), before);
102
+ }
103
+ });
104
+
105
+ it('lists counts without decoding cookie values and supports default domain import', async () => {
106
+ const options = await installed();
107
+ const damagedValue = Buffer.from(fixture);
108
+ damagedValue[40 + damagedValue.readUInt32LE(68)] = 0xff;
109
+ await writeFile(options.file, damagedValue);
110
+ const sources = await listCookieSources({
111
+ ...options,
112
+ domains: ['GITHUB.COM'],
113
+ });
114
+ assert.equal(sources.length, 1);
115
+ assert.equal(sources[0].cookies, 4);
116
+ assert.deepEqual(sources[0].byDomain, { 'GITHUB.COM': 2 });
117
+ assert.ok(!JSON.stringify(sources).includes('fixture-token'));
118
+ await writeFile(options.file, fixture);
119
+ const source = await resolveImportSource({
120
+ ...options,
121
+ browser: 'default',
122
+ domains: ['github.com'],
123
+ runCommand: safariDefault,
124
+ });
125
+ assert.equal(source.browser, 'safari');
126
+ assert.equal(source.warning, null);
127
+ const report = await migrateProfile({
128
+ ...options,
129
+ from: { browser: 'auto' },
130
+ to: path.join(options.homeDir, 'target'),
131
+ domains: ['github.com', 'GITHUB.COM'],
132
+ runCommand: safariDefault,
133
+ });
134
+ assert.deepEqual(report.cookies, expected.slice(0, 2));
135
+ assert.equal(report.migrated.cookies, 2);
136
+ assert.equal(report.skipped.length, 5);
137
+ assert.equal(
138
+ report.skipped.find((entry) => entry.type === 'passwords').reason,
139
+ 'safari-password-export-required'
140
+ );
141
+ assert.ok(
142
+ report.warnings.some(
143
+ (entry) => entry.reason === 'safari-samesite-unavailable'
144
+ )
145
+ );
146
+ });
147
+
148
+ it('reads an explicit source directory and reports actionable Full Disk Access errors', async () => {
149
+ const options = await installed();
150
+ const cookies = await readBrowserCookiesWithDependencies(
151
+ {
152
+ browser: 'safari',
153
+ profileDir: options.root,
154
+ domainFilter: 'github.com',
155
+ cache: false,
156
+ },
157
+ { homeDir: path.join(options.homeDir, 'empty') }
158
+ );
159
+ assert.deepEqual(cookies, expected.slice(0, 2));
160
+ const error = Object.assign(new Error('Operation not permitted'), {
161
+ code: 'EPERM',
162
+ });
163
+ assert.ok(
164
+ await findSafariCookieFile(options.root, async () => {
165
+ throw error;
166
+ })
167
+ );
168
+ await assert.rejects(
169
+ readSafariCookieFile(options.file, {
170
+ environment: { TERM_PROGRAM: 'TestTerminal' },
171
+ readFile: async () => {
172
+ throw error;
173
+ },
174
+ }),
175
+ /Full Disk Access.*TestTerminal.*x-apple.systempreferences:com.apple.preference.security\?Privacy_AllFiles/
176
+ );
177
+ });
178
+
179
+ it('preserves unreadable source errors instead of silently treating the default as empty', async () => {
180
+ const options = await installed();
181
+ await writeFile(options.file, Buffer.from('corrupt'));
182
+ const sources = await listCookieSources({
183
+ ...options,
184
+ domains: ['github.com'],
185
+ });
186
+ assert.match(sources[0].error, /Invalid Safari binarycookies/);
187
+ await assert.rejects(
188
+ resolveImportSource({
189
+ ...options,
190
+ browser: 'default',
191
+ domains: ['github.com'],
192
+ runCommand: safariDefault,
193
+ }),
194
+ /Could not inspect the default browser.*Invalid Safari binarycookies/
195
+ );
196
+ });
197
+
198
+ it('imports through the CLI and command-stream dispatcher using the real reader', async () => {
199
+ const options = await installed();
200
+ const to = path.join(options.homeDir, 'target');
201
+ const output = new PassThrough();
202
+ const chunks = [];
203
+ output.on('data', (chunk) => chunks.push(chunk.toString()));
204
+ const exitCode = await runCli(
205
+ [
206
+ 'profile',
207
+ 'migrate',
208
+ '--from',
209
+ 'safari',
210
+ '--user-data-dir',
211
+ options.root,
212
+ '--include',
213
+ 'cookies',
214
+ '--domain',
215
+ 'github.com',
216
+ '--to',
217
+ to,
218
+ ],
219
+ {
220
+ stdout: output,
221
+ stdin: new PassThrough(),
222
+ signals: new EventEmitter(),
223
+ }
224
+ );
225
+ const report = JSON.parse(chunks.join(''));
226
+ assert.equal(exitCode, 0, JSON.stringify(report));
227
+ assert.deepEqual(report.cookies, expected.slice(0, 2));
228
+ const script = await runScript({
229
+ steps: [
230
+ {
231
+ method: 'profile.migrate',
232
+ params: {
233
+ from: 'safari',
234
+ userDataDir: options.root,
235
+ to,
236
+ include: ['cookies'],
237
+ domains: ['github.com'],
238
+ },
239
+ },
240
+ ],
241
+ });
242
+ assert.equal(script.failed, false, JSON.stringify(script));
243
+ assert.deepEqual(script.results[0].result.cookies, expected.slice(0, 2));
244
+ assert.deepEqual(await readFile(options.file), fixture);
245
+ });
246
+ });