browser-commander 0.20.0 → 0.21.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 +145 -0
- package/README.md +216 -32
- package/bin/browser-commander.js +16 -0
- package/eslint.config.js +10 -0
- package/examples/react-test-app/src/App.jsx +24 -23
- package/extension/background.js +14 -0
- package/extension/manifest.json +12 -0
- package/extension/relay-handler.js +353 -0
- package/package.json +19 -4
- package/src/browser/attach/differences.js +131 -0
- package/src/browser/attach/extension-relay.js +402 -0
- package/src/browser/attach/index.js +81 -0
- package/src/browser/attach/snapshot-launch.js +146 -0
- package/src/browser/attach/snapshot.js +374 -0
- package/src/browser/attach/websocket.js +615 -0
- package/src/browser/browser-cookie-cache.js +12 -17
- package/src/browser/browser-cookie-credentials.js +6 -9
- package/src/browser/browser-cookies.js +5 -2
- package/src/browser/cdp-session.js +94 -0
- package/src/browser/connector.js +42 -4
- package/src/browser/debugging-port.js +155 -0
- package/src/browser/launch-options.js +63 -52
- package/src/browser/launch-restrictions.json +147 -0
- package/src/browser/launcher.js +329 -133
- package/src/browser/media.js +5 -0
- package/src/browser/migration/bookmarks.js +73 -0
- package/src/browser/migration/chromium-crypto.js +89 -0
- package/src/browser/migration/cookies.js +155 -0
- package/src/browser/migration/extensions.js +187 -0
- package/src/browser/migration/firefox-bookmarks.js +144 -0
- package/src/browser/migration/firefox-der.js +84 -0
- package/src/browser/migration/firefox-nss.js +281 -0
- package/src/browser/migration/firefox.js +334 -0
- package/src/browser/migration/fs-utils.js +51 -0
- package/src/browser/migration/history.js +103 -0
- package/src/browser/migration/index.js +307 -0
- package/src/browser/migration/os-crypt-keys.js +106 -0
- package/src/browser/migration/passwords.js +171 -0
- package/src/browser/migration/preferences.js +132 -0
- package/src/browser/migration/sqlite-snapshot.js +138 -0
- package/src/browser/open-in-user-browser.js +112 -0
- package/src/browser/parity.js +439 -0
- package/src/browser/profile-directory.js +102 -0
- package/src/browser/real-browser.js +513 -306
- package/src/browser/restrictions.js +148 -0
- package/src/browser/system-browser.js +232 -0
- package/src/browser/webdriver.js +572 -0
- package/src/cli/args.js +253 -0
- package/src/cli/commands.js +332 -0
- package/src/cli/dispatcher.js +99 -0
- package/src/cli/handle-methods.js +136 -0
- package/src/cli/handles.js +273 -0
- package/src/cli/high-level-methods.js +310 -0
- package/src/cli/main.js +67 -0
- package/src/cli/modules.js +100 -0
- package/src/cli/rpc-error.js +77 -0
- package/src/cli/script.js +77 -0
- package/src/cli/serve.js +144 -0
- package/src/cli/sessions.js +195 -0
- package/src/core/constants.js +7 -1
- package/src/core/engine-adapter.js +107 -1
- package/src/core/engine-detection.js +18 -3
- package/src/core/navigation-safety.js +4 -0
- package/src/core/webdriver-conversions.js +347 -0
- package/src/core/webdriver-events.js +229 -0
- package/src/core/webdriver-page.js +716 -0
- package/src/downloads/attach.js +6 -0
- package/src/elements/selectors.js +3 -2
- package/src/elements/visibility.js +4 -0
- package/src/exports.js +61 -2
- package/src/factory.js +19 -4
- package/src/fingerprint/apply.js +6 -0
- package/src/fingerprint/automation-parity.js +7 -8
- package/src/fingerprint/limitations.js +1 -1
- package/src/fingerprint/limitations.json +45 -2
- package/src/parity/harness.js +303 -0
- package/src/parity/probe.js +838 -0
- package/src/parity/version-page.js +144 -0
- package/src/utilities/subprocess.js +199 -0
- package/tests/e2e/api-coverage.e2e.test.js +174 -0
- package/tests/e2e/cli.e2e.test.js +160 -0
- package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
- package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
- package/tests/e2e/measure-parity.e2e.test.js +118 -0
- package/tests/e2e/playwright.e2e.test.js +25 -17
- package/tests/e2e/puppeteer.e2e.test.js +17 -23
- package/tests/e2e/webdriver.e2e.test.js +239 -0
- package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
- package/tests/helpers/cli-fakes.js +214 -0
- package/tests/helpers/declared-api.js +80 -0
- package/tests/helpers/e2e-browser.js +40 -14
- package/tests/helpers/fake-chrome.js +124 -0
- package/tests/helpers/migration-fixtures.js +242 -0
- package/tests/helpers/temp-directory.js +24 -0
- package/tests/helpers/webdriver-mocks.js +183 -0
- package/tests/unit/browser/attach/extension-relay.test.js +360 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
- package/tests/unit/browser/attach/snapshot.test.js +390 -0
- package/tests/unit/browser/attach/websocket.test.js +240 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -32
- package/tests/unit/browser/cdp-session.test.js +162 -0
- package/tests/unit/browser/connector.test.js +23 -0
- package/tests/unit/browser/launch-building-blocks.test.js +238 -0
- package/tests/unit/browser/launch-options.test.js +41 -22
- package/tests/unit/browser/launcher.test.js +281 -102
- package/tests/unit/browser/media.test.js +13 -1
- package/tests/unit/browser/migration/bookmarks.test.js +69 -0
- package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
- package/tests/unit/browser/migration/cookies.test.js +94 -0
- package/tests/unit/browser/migration/extensions.test.js +104 -0
- package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
- package/tests/unit/browser/migration/firefox.test.js +139 -0
- package/tests/unit/browser/migration/history.test.js +48 -0
- package/tests/unit/browser/migration/index.test.js +117 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
- package/tests/unit/browser/migration/passwords.test.js +144 -0
- package/tests/unit/browser/migration/preferences.test.js +77 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
- package/tests/unit/browser/open-in-user-browser.test.js +123 -0
- package/tests/unit/browser/parity.test.js +281 -0
- package/tests/unit/browser/real-browser.test.js +458 -33
- package/tests/unit/browser/webdriver.test.js +473 -0
- package/tests/unit/cli/bin.test.js +69 -0
- package/tests/unit/cli/declared-api.test.js +49 -0
- package/tests/unit/cli/dispatcher.test.js +429 -0
- package/tests/unit/cli/handles.test.js +170 -0
- package/tests/unit/cli/main.test.js +409 -0
- package/tests/unit/cli/serve.test.js +167 -0
- package/tests/unit/core/webdriver-conversions.test.js +235 -0
- package/tests/unit/core/webdriver-page.test.js +534 -0
- package/tests/unit/fingerprint/automation-parity.test.js +17 -4
- package/tests/unit/fingerprint/limitations.test.js +1 -1
- package/tests/unit/parity/harness.test.js +138 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
- package/tests/unit/scripts/feature-parity.test.js +221 -0
- package/tests/unit/traces/reader.test.js +1 -0
- package/tests/unit/traces/recorder.test.js +1 -0
- package/tests/unit/utilities/command-stream-only.test.js +28 -0
- package/tests/unit/utilities/subprocess.test.js +80 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public methods an engine declares in its TypeScript definitions
|
|
3
|
+
* (issue #104, API coverage).
|
|
4
|
+
*
|
|
5
|
+
* `serve --stdio` promises that the generic handle methods reach every public
|
|
6
|
+
* Playwright and Puppeteer method. The authoritative list of those methods is
|
|
7
|
+
* what each package ships for TypeScript users:
|
|
8
|
+
*
|
|
9
|
+
* - Puppeteer: `puppeteer-core/lib/types.d.ts` (`export declare class …`).
|
|
10
|
+
* - Playwright: `playwright-core/types/types.d.ts` (`export interface …`).
|
|
11
|
+
* Playwright's wire protocol description (`protocol.yml`) is not shipped
|
|
12
|
+
* in the npm package, so the client API declarations are used instead.
|
|
13
|
+
*
|
|
14
|
+
* Both files put every member of a class or interface on a line indented by
|
|
15
|
+
* exactly two spaces, which is all this parser relies on.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from 'node:fs';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
|
|
23
|
+
const BLOCK_START =
|
|
24
|
+
/^export (?:declare )?(?:abstract )?(?:class|interface) ([A-Za-z]\w*)/u;
|
|
25
|
+
const MEMBER =
|
|
26
|
+
/^ {2}(?:(?:public|abstract|async|override)\s+)*([A-Za-z$][\w$]*)\??(?:<|\()/u;
|
|
27
|
+
const NOT_A_METHOD = /^ {2}(?:private|protected|static|get|set|readonly)\s/u;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Methods declared directly on each class or interface of a `.d.ts` file.
|
|
31
|
+
* Private, protected and static members, accessors, the constructor and
|
|
32
|
+
* `_`-prefixed names are left out.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} source - The declaration file contents
|
|
35
|
+
* @returns {Map<string, Set<string>>} Type name to method names
|
|
36
|
+
*/
|
|
37
|
+
export function parseDeclaredMethods(source) {
|
|
38
|
+
const types = new Map();
|
|
39
|
+
let current = null;
|
|
40
|
+
for (const line of source.split(/\r?\n/u)) {
|
|
41
|
+
const start = BLOCK_START.exec(line);
|
|
42
|
+
if (start) {
|
|
43
|
+
current = new Set();
|
|
44
|
+
types.set(start[1], current);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (line === '}') {
|
|
48
|
+
current = null;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!current || NOT_A_METHOD.test(line)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const name = MEMBER.exec(line)?.[1];
|
|
55
|
+
if (name && name !== 'constructor' && !name.startsWith('_')) {
|
|
56
|
+
current.add(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return types;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The declaration file of an installed package. */
|
|
63
|
+
function declarationFile(packageName, relative) {
|
|
64
|
+
const root = path.dirname(require.resolve(`${packageName}/package.json`));
|
|
65
|
+
return readFileSync(path.join(root, relative), 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Puppeteer's declared classes and their methods. */
|
|
69
|
+
export function puppeteerDeclarations() {
|
|
70
|
+
return parseDeclaredMethods(
|
|
71
|
+
declarationFile('puppeteer-core', 'lib/types.d.ts')
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Playwright's declared interfaces and their methods. */
|
|
76
|
+
export function playwrightDeclarations() {
|
|
77
|
+
return parseDeclaredMethods(
|
|
78
|
+
declarationFile('playwright-core', 'types/types.d.ts')
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -8,12 +8,34 @@
|
|
|
8
8
|
* read as the behaviour they check.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import
|
|
12
|
-
import os from 'node:os';
|
|
13
|
-
import path from 'node:path';
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
14
12
|
|
|
15
13
|
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
16
14
|
|
|
15
|
+
/** The installed Chrome the parity suites compare against. */
|
|
16
|
+
export const PARITY_CHROME =
|
|
17
|
+
process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Why a parity suite cannot run here, or `false` when it can.
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} [options]
|
|
23
|
+
* @param {boolean} [options.headless=false] - Headless suites need no display
|
|
24
|
+
* @returns {string|false}
|
|
25
|
+
*/
|
|
26
|
+
export function paritySkipReason({ headless = false } = {}) {
|
|
27
|
+
if (!process.env.RUN_E2E) {
|
|
28
|
+
return 'set RUN_E2E=true to run the parity tests';
|
|
29
|
+
}
|
|
30
|
+
if (!existsSync(PARITY_CHROME)) {
|
|
31
|
+
return `no Chrome binary at ${PARITY_CHROME}; set CHROME_PATH`;
|
|
32
|
+
}
|
|
33
|
+
if (!headless && process.platform === 'linux' && !process.env.DISPLAY) {
|
|
34
|
+
return 'headful parity needs a display; run under xvfb-run';
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* Extra Chrome arguments for environments without a usable sandbox.
|
|
19
41
|
*
|
|
@@ -24,6 +46,17 @@ import { launchBrowser } from '../../src/browser/launcher.js';
|
|
|
24
46
|
export const SANDBOX_ARGS =
|
|
25
47
|
process.env.CHROME_NO_SANDBOX === 'true' ? ['--no-sandbox'] : [];
|
|
26
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The sandbox arguments plus the CHROME_PATH binary, when one is set: the
|
|
51
|
+
* launch options every e2e suite shares.
|
|
52
|
+
*/
|
|
53
|
+
export const CHROME_LAUNCH_OPTIONS = {
|
|
54
|
+
args: SANDBOX_ARGS,
|
|
55
|
+
...(process.env.CHROME_PATH
|
|
56
|
+
? { executablePath: process.env.CHROME_PATH }
|
|
57
|
+
: {}),
|
|
58
|
+
};
|
|
59
|
+
|
|
27
60
|
/**
|
|
28
61
|
* Launch a real browser for one engine, with its own profile.
|
|
29
62
|
*
|
|
@@ -34,19 +67,13 @@ export const SANDBOX_ARGS =
|
|
|
34
67
|
*/
|
|
35
68
|
export async function launchE2EBrowser(options = {}) {
|
|
36
69
|
const { engine, downloadDirectory } = options;
|
|
37
|
-
const userDataDir = await fs.mkdtemp(
|
|
38
|
-
path.join(os.tmpdir(), 'bc-e2e-profile-')
|
|
39
|
-
);
|
|
40
70
|
|
|
71
|
+
// The default launch creates a fresh temporary profile and deletes it on
|
|
72
|
+
// close, so the helper no longer manages one itself.
|
|
41
73
|
const launched = await launchBrowser({
|
|
42
74
|
engine,
|
|
43
|
-
userDataDir,
|
|
44
75
|
headless: process.env.HEADLESS !== 'false',
|
|
45
|
-
|
|
46
|
-
args: SANDBOX_ARGS,
|
|
47
|
-
...(process.env.CHROME_PATH
|
|
48
|
-
? { executablePath: process.env.CHROME_PATH }
|
|
49
|
-
: {}),
|
|
76
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
50
77
|
...(downloadDirectory
|
|
51
78
|
? { downloads: { directory: downloadDirectory } }
|
|
52
79
|
: {}),
|
|
@@ -60,8 +87,7 @@ export async function launchE2EBrowser(options = {}) {
|
|
|
60
87
|
* @returns {Promise<void>} Resolves once both are gone
|
|
61
88
|
*/
|
|
62
89
|
cleanup: async () => {
|
|
63
|
-
await launched.
|
|
64
|
-
await fs.rm(userDataDir, { recursive: true, force: true });
|
|
90
|
+
await launched.close();
|
|
65
91
|
},
|
|
66
92
|
};
|
|
67
93
|
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake `chrome` namespace and browser `WebSocket` for the companion
|
|
3
|
+
* extension tests (issue #102, addendum, mode 2).
|
|
4
|
+
*
|
|
5
|
+
* The extension code in js/extension only touches the handful of APIs below,
|
|
6
|
+
* so the relay handler and its service-worker client run unchanged in Node.
|
|
7
|
+
*/
|
|
8
|
+
import { connectWebSocket } from '../../src/browser/attach/websocket.js';
|
|
9
|
+
|
|
10
|
+
/** A `chrome.*.onSomething` event whose listeners the test can fire. */
|
|
11
|
+
function fakeEvent() {
|
|
12
|
+
const listeners = [];
|
|
13
|
+
return {
|
|
14
|
+
listeners,
|
|
15
|
+
addListener: (listener) => listeners.push(listener),
|
|
16
|
+
fire: (...args) => listeners.forEach((listener) => listener(...args)),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {Object} [options]
|
|
22
|
+
* @param {Array<Object>} [options.tabs] - `chrome.tabs.Tab`-like objects
|
|
23
|
+
* @param {Object} [options.storage] - `chrome.storage.local` contents
|
|
24
|
+
* @returns {Object} The fake namespace; `calls` records every API call
|
|
25
|
+
*/
|
|
26
|
+
export function createFakeChrome({ tabs = [], storage = {} } = {}) {
|
|
27
|
+
const calls = [];
|
|
28
|
+
let nextTabId = 100;
|
|
29
|
+
const chrome = {
|
|
30
|
+
calls,
|
|
31
|
+
tabs: {
|
|
32
|
+
query: async (query) => {
|
|
33
|
+
calls.push(['tabs.query', query]);
|
|
34
|
+
return tabs;
|
|
35
|
+
},
|
|
36
|
+
create: async (properties) => {
|
|
37
|
+
calls.push(['tabs.create', properties]);
|
|
38
|
+
const tab = { id: nextTabId++, pendingUrl: properties.url };
|
|
39
|
+
tabs.push(tab);
|
|
40
|
+
return tab;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
debugger: {
|
|
44
|
+
attach: async (target, version) => {
|
|
45
|
+
calls.push(['debugger.attach', target.tabId, version]);
|
|
46
|
+
},
|
|
47
|
+
detach: async (target) => {
|
|
48
|
+
calls.push(['debugger.detach', target.tabId]);
|
|
49
|
+
},
|
|
50
|
+
sendCommand: async (target, method, params) => {
|
|
51
|
+
calls.push(['debugger.sendCommand', target.tabId, method, params]);
|
|
52
|
+
if (method === 'Bad.method') {
|
|
53
|
+
throw new Error("'Bad.method' wasn't found");
|
|
54
|
+
}
|
|
55
|
+
return method === 'Runtime.evaluate'
|
|
56
|
+
? { result: { type: 'number', value: 2 } }
|
|
57
|
+
: undefined;
|
|
58
|
+
},
|
|
59
|
+
onEvent: fakeEvent(),
|
|
60
|
+
onDetach: fakeEvent(),
|
|
61
|
+
},
|
|
62
|
+
alarms: {
|
|
63
|
+
create: (name, info) => calls.push(['alarms.create', name, info]),
|
|
64
|
+
onAlarm: fakeEvent(),
|
|
65
|
+
},
|
|
66
|
+
storage: {
|
|
67
|
+
local: { get: async (key) => ({ [key]: storage[key] }) },
|
|
68
|
+
onChanged: fakeEvent(),
|
|
69
|
+
},
|
|
70
|
+
runtime: {
|
|
71
|
+
getManifest: () => ({ version: '1.2.3' }),
|
|
72
|
+
onStartup: fakeEvent(),
|
|
73
|
+
onInstalled: fakeEvent(),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return chrome;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The browser `WebSocket` API on top of {@link connectWebSocket}, sending the
|
|
81
|
+
* `Origin` an extension service worker sends.
|
|
82
|
+
*/
|
|
83
|
+
export function createExtensionWebSocket(origin = 'chrome-extension://abc') {
|
|
84
|
+
return class ExtensionWebSocket {
|
|
85
|
+
static CONNECTING = 0;
|
|
86
|
+
static OPEN = 1;
|
|
87
|
+
static CLOSED = 3;
|
|
88
|
+
|
|
89
|
+
constructor(url) {
|
|
90
|
+
this.url = url;
|
|
91
|
+
this.readyState = ExtensionWebSocket.CONNECTING;
|
|
92
|
+
connectWebSocket(url, { headers: { Origin: origin } }).then(
|
|
93
|
+
(connection) => {
|
|
94
|
+
this.connection = connection;
|
|
95
|
+
this.readyState = ExtensionWebSocket.OPEN;
|
|
96
|
+
connection.on('message', (data) => this.onmessage?.({ data }));
|
|
97
|
+
connection.on('error', () => {});
|
|
98
|
+
connection.once('close', () => this.closed());
|
|
99
|
+
this.onopen?.();
|
|
100
|
+
},
|
|
101
|
+
(error) => {
|
|
102
|
+
this.error = error;
|
|
103
|
+
this.onerror?.(error);
|
|
104
|
+
this.closed();
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
send(text) {
|
|
110
|
+
this.connection.send(text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close() {
|
|
114
|
+
this.connection?.close();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
closed() {
|
|
118
|
+
if (this.readyState !== ExtensionWebSocket.CLOSED) {
|
|
119
|
+
this.readyState = ExtensionWebSocket.CLOSED;
|
|
120
|
+
this.onclose?.();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile fixtures for the migration tests.
|
|
3
|
+
*
|
|
4
|
+
* Every migration suite moves data from a source profile into a target one and
|
|
5
|
+
* checks the same report shape, and the Firefox suites (plus the installed
|
|
6
|
+
* browser cookie reader) need the same `cookies.sqlite`, `places.sqlite` and
|
|
7
|
+
* `logins.json` files, so they are built here once instead of per test file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import assert from 'node:assert';
|
|
11
|
+
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
15
|
+
|
|
16
|
+
import { decryptChromiumCookie } from '../../src/browser/browser-cookie-crypto.js';
|
|
17
|
+
import { openSqliteDatabase } from '../../src/browser/browser-cookie-database.js';
|
|
18
|
+
import {
|
|
19
|
+
buildKey4Database,
|
|
20
|
+
buildLoginsJson,
|
|
21
|
+
} from '../fixtures/firefox-nss-fixtures.mjs';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run a Chromium migration step from one profile directory into another.
|
|
25
|
+
*
|
|
26
|
+
* @param {Function} migrate - A `migrate*` data-class function
|
|
27
|
+
* @param {string} source - Source profile directory
|
|
28
|
+
* @param {string} target - Target profile directory
|
|
29
|
+
* @param {Object} [options] - Extra options for the step
|
|
30
|
+
* @returns {Promise<Object>} The step's report
|
|
31
|
+
*/
|
|
32
|
+
export function migrateBetween(migrate, source, target, options = {}) {
|
|
33
|
+
return migrate({
|
|
34
|
+
sourceProfileDir: source,
|
|
35
|
+
targetProfileDir: target,
|
|
36
|
+
...options,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Assert that a step migrated nothing, and why.
|
|
42
|
+
*
|
|
43
|
+
* @param {Object} report - A data-class report
|
|
44
|
+
* @param {string} reason - The expected first skip reason
|
|
45
|
+
*/
|
|
46
|
+
export function assertNothingMigrated(report, reason) {
|
|
47
|
+
assert.equal(report.migrated, 0);
|
|
48
|
+
assert.equal(report.skipped[0].reason, reason);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Assert that `action` leaves a source file untouched. Migration only ever
|
|
53
|
+
* reads a snapshot of the source, so its modification time must not move.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} filePath - The source file
|
|
56
|
+
* @param {() => Promise<void>} action
|
|
57
|
+
*/
|
|
58
|
+
export async function assertSourceUnchanged(filePath, action) {
|
|
59
|
+
const before = (await stat(filePath)).mtimeMs;
|
|
60
|
+
await action();
|
|
61
|
+
const after = (await stat(filePath)).mtimeMs;
|
|
62
|
+
assert.equal(before, after);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Write a JSON profile file such as `Bookmarks` or `Preferences`.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} profileDir
|
|
69
|
+
* @param {string} name - File name relative to the profile
|
|
70
|
+
* @param {*} value - Serialised with `JSON.stringify`
|
|
71
|
+
* @returns {Promise<void>}
|
|
72
|
+
*/
|
|
73
|
+
export function writeProfileJson(profileDir, name, value) {
|
|
74
|
+
return writeFile(path.join(profileDir, name), JSON.stringify(value));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read and parse a JSON profile file a migration wrote.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} profileDir
|
|
81
|
+
* @param {string} name - File name relative to the profile
|
|
82
|
+
* @returns {Promise<*>}
|
|
83
|
+
*/
|
|
84
|
+
export async function readProfileJson(profileDir, name) {
|
|
85
|
+
return JSON.parse(await readFile(path.join(profileDir, name), 'utf8'));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Write a Chromium `History` database with `urlCount` visited URLs.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} profileDir - Chromium profile directory
|
|
92
|
+
* @param {number} urlCount
|
|
93
|
+
* @returns {string} The database path
|
|
94
|
+
*/
|
|
95
|
+
export function writeChromiumHistory(profileDir, urlCount) {
|
|
96
|
+
const historyPath = path.join(profileDir, 'History');
|
|
97
|
+
const db = new BetterSqlite3(historyPath);
|
|
98
|
+
db.exec('CREATE TABLE urls (id INTEGER PRIMARY KEY, url TEXT, title TEXT)');
|
|
99
|
+
const insert = db.prepare('INSERT INTO urls (url, title) VALUES (?, ?)');
|
|
100
|
+
for (let i = 0; i < urlCount; i += 1) {
|
|
101
|
+
insert.run(`https://example.com/${i}`, `Page ${i}`);
|
|
102
|
+
}
|
|
103
|
+
db.close();
|
|
104
|
+
return historyPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Write a Firefox `cookies.sqlite` holding the given rows.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} profileDir - Firefox profile directory
|
|
111
|
+
* @param {Array<Object>} rows - `{name, value, host}` plus optional `path`,
|
|
112
|
+
* `expiry`, `secure`, `httpOnly` and `sameSite`
|
|
113
|
+
* @returns {Promise<string>} The database path
|
|
114
|
+
*/
|
|
115
|
+
export async function writeFirefoxCookies(profileDir, rows) {
|
|
116
|
+
const cookiePath = path.join(profileDir, 'cookies.sqlite');
|
|
117
|
+
const database = await openSqliteDatabase(cookiePath);
|
|
118
|
+
database.exec(`
|
|
119
|
+
CREATE TABLE moz_cookies (
|
|
120
|
+
name TEXT,
|
|
121
|
+
value TEXT,
|
|
122
|
+
host TEXT,
|
|
123
|
+
path TEXT,
|
|
124
|
+
expiry INTEGER,
|
|
125
|
+
isSecure INTEGER,
|
|
126
|
+
isHttpOnly INTEGER,
|
|
127
|
+
sameSite INTEGER
|
|
128
|
+
);
|
|
129
|
+
`);
|
|
130
|
+
const insert = database.prepare(`
|
|
131
|
+
INSERT INTO moz_cookies
|
|
132
|
+
(name, value, host, path, expiry, isSecure, isHttpOnly, sameSite)
|
|
133
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
134
|
+
`);
|
|
135
|
+
for (const row of rows) {
|
|
136
|
+
insert.run(
|
|
137
|
+
row.name,
|
|
138
|
+
row.value,
|
|
139
|
+
row.host,
|
|
140
|
+
row.path ?? '/',
|
|
141
|
+
row.expiry ?? 0,
|
|
142
|
+
row.secure ? 1 : 0,
|
|
143
|
+
row.httpOnly ? 1 : 0,
|
|
144
|
+
row.sameSite ?? 0
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
database.close();
|
|
148
|
+
return cookiePath;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Write a Firefox `places.sqlite` with a toolbar bookmark and, unless
|
|
153
|
+
* `withMenuBookmark` is false, a bookmarks-menu one. Each bookmark has its own
|
|
154
|
+
* `moz_places` row, so the history count equals the bookmark count.
|
|
155
|
+
*
|
|
156
|
+
* @param {string} profileDir - Firefox profile directory
|
|
157
|
+
* @param {Object} [options]
|
|
158
|
+
* @param {boolean} [options.withMenuBookmark=true]
|
|
159
|
+
*/
|
|
160
|
+
export function writeFirefoxPlaces(
|
|
161
|
+
profileDir,
|
|
162
|
+
{ withMenuBookmark = true } = {}
|
|
163
|
+
) {
|
|
164
|
+
const db = new BetterSqlite3(path.join(profileDir, 'places.sqlite'));
|
|
165
|
+
db.exec(
|
|
166
|
+
`CREATE TABLE moz_places (id INTEGER PRIMARY KEY, url TEXT);
|
|
167
|
+
CREATE TABLE moz_bookmarks (id INTEGER PRIMARY KEY, type INTEGER, fk INTEGER,
|
|
168
|
+
parent INTEGER, position INTEGER, title TEXT, guid TEXT);`
|
|
169
|
+
);
|
|
170
|
+
const insertPlace = db.prepare(
|
|
171
|
+
'INSERT INTO moz_places (id, url) VALUES (?, ?)'
|
|
172
|
+
);
|
|
173
|
+
const insertBookmark = db.prepare(
|
|
174
|
+
'INSERT INTO moz_bookmarks (id, type, fk, parent, position, title, guid) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
|
175
|
+
);
|
|
176
|
+
insertBookmark.run(1, 2, null, 0, 0, '', 'root________');
|
|
177
|
+
insertBookmark.run(3, 2, null, 1, 0, 'Bookmarks Toolbar', 'toolbar_____');
|
|
178
|
+
insertPlace.run(10, 'https://toolbar.example/');
|
|
179
|
+
insertBookmark.run(100, 1, 10, 3, 0, 'Toolbar Site', 'aaaaaaaaaaaa');
|
|
180
|
+
if (withMenuBookmark) {
|
|
181
|
+
insertBookmark.run(2, 2, null, 1, 1, 'Bookmarks Menu', 'menu________');
|
|
182
|
+
insertPlace.run(11, 'https://menu.example/');
|
|
183
|
+
insertBookmark.run(101, 1, 11, 2, 0, 'Menu Site', 'bbbbbbbbbbbb');
|
|
184
|
+
}
|
|
185
|
+
db.close();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Write a Firefox `key4.db` and a `logins.json` encrypted with its login key.
|
|
190
|
+
*
|
|
191
|
+
* @param {string} profileDir - Firefox profile directory
|
|
192
|
+
* @param {Object} options
|
|
193
|
+
* @param {Array<{hostname: string, username: string, password: string}>} options.entries
|
|
194
|
+
* @param {Buffer} [options.primaryPassword] - Locks the key when set
|
|
195
|
+
* @returns {Promise<{key4Path: string, loginKey: Buffer}>}
|
|
196
|
+
*/
|
|
197
|
+
export async function writeFirefoxLogins(
|
|
198
|
+
profileDir,
|
|
199
|
+
{ entries, primaryPassword }
|
|
200
|
+
) {
|
|
201
|
+
const fixture = buildKey4Database({ dir: profileDir, primaryPassword });
|
|
202
|
+
await writeFile(
|
|
203
|
+
path.join(profileDir, 'logins.json'),
|
|
204
|
+
buildLoginsJson({ key: fixture.loginKey, entries })
|
|
205
|
+
);
|
|
206
|
+
return fixture;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Read back the logins a migration wrote to a target `Login Data`, decrypting
|
|
211
|
+
* each password with the target profile's key (Linux `v10`/`v11` scheme).
|
|
212
|
+
*
|
|
213
|
+
* @param {string} targetProfileDir
|
|
214
|
+
* @param {Buffer} targetKey
|
|
215
|
+
* @returns {Array<{origin: string, username: string, password: string}>}
|
|
216
|
+
* Sorted by origin
|
|
217
|
+
*/
|
|
218
|
+
export function readMigratedLogins(targetProfileDir, targetKey) {
|
|
219
|
+
const db = new BetterSqlite3(path.join(targetProfileDir, 'Login Data'), {
|
|
220
|
+
readonly: true,
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
return db
|
|
224
|
+
.prepare(
|
|
225
|
+
'SELECT origin_url, username_value, password_value FROM logins ORDER BY origin_url'
|
|
226
|
+
)
|
|
227
|
+
.all()
|
|
228
|
+
.map((row) => ({
|
|
229
|
+
origin: row.origin_url,
|
|
230
|
+
username: row.username_value,
|
|
231
|
+
password: decryptChromiumCookie({
|
|
232
|
+
encryptedValue: row.password_value,
|
|
233
|
+
host: new URL(row.origin_url).hostname,
|
|
234
|
+
databaseVersion: 0,
|
|
235
|
+
platform: 'linux',
|
|
236
|
+
key: targetKey,
|
|
237
|
+
}),
|
|
238
|
+
}));
|
|
239
|
+
} finally {
|
|
240
|
+
db.close();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -42,3 +42,27 @@ export function useTempDirectory(prefix) {
|
|
|
42
42
|
|
|
43
43
|
return holder;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Let each test create as many directories as it needs (a source and a target
|
|
48
|
+
* profile, say), and remove all of them after the test.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} [prefix] - Default name prefix for the directories
|
|
51
|
+
* @returns {(prefix?: string) => Promise<string>} Creates and tracks one more
|
|
52
|
+
* directory, optionally with its own prefix
|
|
53
|
+
*/
|
|
54
|
+
export function useTempDirectories(prefix) {
|
|
55
|
+
const created = [];
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
while (created.length > 0) {
|
|
59
|
+
await fs.rm(created.pop(), { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return async (directoryPrefix = prefix) => {
|
|
64
|
+
const directory = await makeTempDirectory(directoryPrefix);
|
|
65
|
+
created.push(directory);
|
|
66
|
+
return directory;
|
|
67
|
+
};
|
|
68
|
+
}
|