browser-commander 0.33.0 → 0.33.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.
- package/CHANGELOG.md +14 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/browser/browser-handoff.js +4 -0
- package/src/browser/migration/firefox.js +1 -1
- package/src/browser/migration/index.js +10 -0
- package/src/browser/migration/os-crypt-keys.js +36 -6
- package/src/browser/migration/passwords.js +1 -1
- package/src/browser/real-browser.js +24 -5
- package/src/browser/webdriver.js +42 -6
- package/src/exports.js +1 -0
- package/src/tests/index.js +14 -1
- package/tests/e2e/chrome-for-testing.e2e.test.js +105 -0
- package/tests/unit/browser/browser-handoff.test.js +13 -0
- package/tests/unit/browser/launch-executable.test.js +1 -0
- package/tests/unit/browser/migration/os-crypt-keys.test.js +57 -0
- package/tests/unit/browser/migration/safari-data.test.js +43 -19
- package/tests/unit/browser/real-browser.test.js +73 -0
- package/tests/unit/browser/webdriver.test.js +56 -14
- package/tests/unit/scripts/check-web-archive.test.js +77 -2
- package/tests/unit/tests/index.test.js +2 -0
- package/tests/unit/traces/issue-180-screenshots.test.js +63 -12
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.33.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 0013b28: The failure screenshot that `browser-commander/tests` writes for a failed scenario captures the view without a clip for Playwright and Puppeteer pages that have no fixed viewport (issue #180), as trace checkpoints and default screenshots already do. A clipped engine capture could leave a resized headful window drawn at its old size. Pages with a fixed viewport and other engines keep the engine's screenshot.
|
|
8
|
+
|
|
9
|
+
## 0.33.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- d75bce4: Selenium drives Chrome for Testing with the ChromeDriver of its own release (issue #179): a `chromedriver` on `PATH` belongs to the installed Google Chrome and is skipped, Selenium Manager is asked with `--skip-driver-in-path`, and the real-browser launch and the hand-off reattach give ChromeDriver the browser that started. `defaultRestrictionsFor()` is exported.
|
|
14
|
+
|
|
15
|
+
`migrateFrom` re-encrypts passwords with the key of the browser that starts (issue #179): Chrome for Testing reads "Chromium Safe Storage", and with its default `mock-keychain` restriction (or `--password-store=basic` on Linux) Chromium's fixed mock password, so migrated passwords decrypt in it. macOS logins are written with the `v10` prefix Chromium uses there.
|
|
16
|
+
|
|
3
17
|
## 0.33.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -412,6 +412,10 @@ with.
|
|
|
412
412
|
|
|
413
413
|
#### Chrome for Testing by default
|
|
414
414
|
|
|
415
|
+
See [Chrome for Testing by default](../docs/chrome-for-testing.md) for the
|
|
416
|
+
download each engine starts, its matching ChromeDriver and the Ubuntu sandbox
|
|
417
|
+
note.
|
|
418
|
+
|
|
415
419
|
Without `channel` or `executablePath`, a launch starts Chrome for Testing - the
|
|
416
420
|
one Playwright or Puppeteer downloaded, or else another build in its download
|
|
417
421
|
cache (`ms-playwright`, moved by `PLAYWRIGHT_BROWSERS_PATH`; `~/.cache/puppeteer`,
|
package/package.json
CHANGED
|
@@ -249,6 +249,10 @@ export async function reconnectBrowserWithDependencies(
|
|
|
249
249
|
cdpEndpoint: descriptor.cdpEndpoint,
|
|
250
250
|
targetId: descriptor.targetId,
|
|
251
251
|
fallback: false,
|
|
252
|
+
// ChromeDriver must match the browser it reattaches to (issue #179).
|
|
253
|
+
...(engine === 'selenium' && descriptor.executablePath
|
|
254
|
+
? { executablePath: descriptor.executablePath }
|
|
255
|
+
: {}),
|
|
252
256
|
});
|
|
253
257
|
} catch (error) {
|
|
254
258
|
throw new BrowserHandoffError(
|
|
@@ -218,7 +218,7 @@ export async function migrateFirefoxPasswords({
|
|
|
218
218
|
targetProfileDir,
|
|
219
219
|
platform,
|
|
220
220
|
targetKey,
|
|
221
|
-
targetPrefix = platform === '
|
|
221
|
+
targetPrefix = platform === 'linux' ? 'v11' : 'v10',
|
|
222
222
|
primaryPassword = Buffer.alloc(0),
|
|
223
223
|
domains,
|
|
224
224
|
}) {
|
|
@@ -122,6 +122,7 @@ async function resolvePasswordKeys({
|
|
|
122
122
|
keys,
|
|
123
123
|
browser,
|
|
124
124
|
targetBrowser,
|
|
125
|
+
targetKeystore,
|
|
125
126
|
platform,
|
|
126
127
|
sourceProfileDir,
|
|
127
128
|
environment,
|
|
@@ -135,6 +136,7 @@ async function resolvePasswordKeys({
|
|
|
135
136
|
const { key: targetKey, prefix: targetPrefix } = await resolveTargetKey({
|
|
136
137
|
browser: targetBrowser,
|
|
137
138
|
platform,
|
|
139
|
+
keystore: targetKeystore,
|
|
138
140
|
environment,
|
|
139
141
|
});
|
|
140
142
|
const resolveSourceKey = isChromiumBrowser(browser)
|
|
@@ -159,6 +161,7 @@ async function resolvePasswordKeys({
|
|
|
159
161
|
* @param {boolean} [options.includePaymentCards=false] - Separate explicit payment-card consent
|
|
160
162
|
* @param {string} [options.platform=process.platform]
|
|
161
163
|
* @param {string} [options.targetBrowser] - Launching browser channel (key derivation)
|
|
164
|
+
* @param {'mock'|'os'} [options.targetKeystore='os'] - 'mock' when the launch bypasses the OS keystore (`targetKeystoreFor`)
|
|
162
165
|
* @param {Object} [options.keys] - Injected password keys {resolveSourceKey, targetKey, targetPrefix, primaryPassword}
|
|
163
166
|
* @param {string} [options.homeDir]
|
|
164
167
|
* @param {Object} [options.environment]
|
|
@@ -171,6 +174,7 @@ export async function migrateProfile({
|
|
|
171
174
|
domains,
|
|
172
175
|
platform = process.platform,
|
|
173
176
|
targetBrowser,
|
|
177
|
+
targetKeystore,
|
|
174
178
|
keys,
|
|
175
179
|
homeDir = os.homedir(),
|
|
176
180
|
environment = process.env,
|
|
@@ -272,6 +276,7 @@ export async function migrateProfile({
|
|
|
272
276
|
keys,
|
|
273
277
|
browser,
|
|
274
278
|
targetBrowser,
|
|
279
|
+
targetKeystore,
|
|
275
280
|
platform,
|
|
276
281
|
sourceProfileDir,
|
|
277
282
|
environment,
|
|
@@ -352,6 +357,7 @@ export async function migrateProfile({
|
|
|
352
357
|
keys,
|
|
353
358
|
browser,
|
|
354
359
|
targetBrowser,
|
|
360
|
+
targetKeystore,
|
|
355
361
|
platform,
|
|
356
362
|
sourceProfileDir,
|
|
357
363
|
environment,
|
|
@@ -377,6 +383,7 @@ async function migrateSafariProfile({
|
|
|
377
383
|
selected,
|
|
378
384
|
sourceProfileDir,
|
|
379
385
|
targetBrowser,
|
|
386
|
+
targetKeystore,
|
|
380
387
|
to,
|
|
381
388
|
}) {
|
|
382
389
|
const passwordKeys =
|
|
@@ -385,6 +392,7 @@ async function migrateSafariProfile({
|
|
|
385
392
|
keys,
|
|
386
393
|
browser,
|
|
387
394
|
targetBrowser: targetBrowser ?? 'chrome',
|
|
395
|
+
targetKeystore,
|
|
388
396
|
platform,
|
|
389
397
|
sourceProfileDir,
|
|
390
398
|
environment,
|
|
@@ -442,6 +450,7 @@ async function migratePasswordClass(
|
|
|
442
450
|
platform,
|
|
443
451
|
sourceProfileDir,
|
|
444
452
|
targetBrowser,
|
|
453
|
+
targetKeystore,
|
|
445
454
|
to,
|
|
446
455
|
}
|
|
447
456
|
) {
|
|
@@ -464,6 +473,7 @@ async function migratePasswordClass(
|
|
|
464
473
|
keys,
|
|
465
474
|
browser,
|
|
466
475
|
targetBrowser: targetBrowser ?? (isFirefox ? 'chrome' : browser),
|
|
476
|
+
targetKeystore,
|
|
467
477
|
platform,
|
|
468
478
|
sourceProfileDir,
|
|
469
479
|
environment,
|
|
@@ -73,12 +73,34 @@ export function createSourceKeyResolver({
|
|
|
73
73
|
};
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Which keystore a launch encrypts its new profile with: `'mock'` when the
|
|
78
|
+
* launch switches bypass the OS keystore (`--use-mock-keychain` on macOS, as
|
|
79
|
+
* Chrome for Testing gets by default, or `--password-store=basic` on Linux),
|
|
80
|
+
* `'os'` otherwise.
|
|
81
|
+
*
|
|
82
|
+
* @param {string[]} args - The browser command-line switches
|
|
83
|
+
* @param {string} platform
|
|
84
|
+
* @returns {'mock'|'os'}
|
|
85
|
+
*/
|
|
86
|
+
export function targetKeystoreFor(args, platform) {
|
|
87
|
+
const bypass =
|
|
88
|
+
platform === 'darwin' ? '--use-mock-keychain' : '--password-store=basic';
|
|
89
|
+
return platform !== 'win32' && args.includes(bypass) ? 'mock' : 'os';
|
|
90
|
+
}
|
|
91
|
+
|
|
76
92
|
/**
|
|
77
93
|
* Resolve the target (launching) profile's encryption key on macOS/Linux.
|
|
78
94
|
*
|
|
95
|
+
* macOS Chromium only knows the `v10` tag; Linux tags keyring keys `v11` and
|
|
96
|
+
* its fixed basic-store key `v10`. A `'mock'` keystore uses Chromium's fixed
|
|
97
|
+
* mock passwords: `mock_password` for the mock keychain, `peanuts` for the
|
|
98
|
+
* basic store.
|
|
99
|
+
*
|
|
79
100
|
* @param {Object} options
|
|
80
101
|
* @param {string} options.browser - Launching browser channel
|
|
81
102
|
* @param {string} options.platform
|
|
103
|
+
* @param {'mock'|'os'} [options.keystore='os'] - See `targetKeystoreFor`
|
|
82
104
|
* @param {Object} [options.environment]
|
|
83
105
|
* @param {Function} [options.readSafeStoragePassword]
|
|
84
106
|
* @returns {Promise<{key: Buffer, prefix: string}>}
|
|
@@ -86,16 +108,24 @@ export function createSourceKeyResolver({
|
|
|
86
108
|
export async function resolveTargetKey({
|
|
87
109
|
browser,
|
|
88
110
|
platform,
|
|
111
|
+
keystore = 'os',
|
|
89
112
|
environment = process.env,
|
|
90
113
|
readSafeStoragePassword: readSafeStorage = readSafeStoragePassword,
|
|
91
114
|
}) {
|
|
92
|
-
if (platform
|
|
93
|
-
|
|
94
|
-
|
|
115
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`resolveTargetKey does not derive a Windows key; use createWindowsProfileKey`
|
|
118
|
+
);
|
|
95
119
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
if (keystore === 'mock') {
|
|
121
|
+
const password = platform === 'darwin' ? 'mock_password' : 'peanuts';
|
|
122
|
+
return { key: deriveChromiumCookieKey(password, platform), prefix: 'v10' };
|
|
123
|
+
}
|
|
124
|
+
const password = await readSafeStorage({ browser, platform, environment });
|
|
125
|
+
return {
|
|
126
|
+
key: deriveChromiumCookieKey(password, platform),
|
|
127
|
+
prefix: platform === 'darwin' ? 'v10' : 'v11',
|
|
128
|
+
};
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
/** Convenience: the Local State path next to a profile directory. */
|
|
@@ -108,7 +108,7 @@ export async function migratePasswords({
|
|
|
108
108
|
platform = process.platform,
|
|
109
109
|
resolveSourceKey,
|
|
110
110
|
targetKey,
|
|
111
|
-
targetPrefix = platform === '
|
|
111
|
+
targetPrefix = platform === 'linux' ? 'v11' : 'v10',
|
|
112
112
|
domains,
|
|
113
113
|
}) {
|
|
114
114
|
const sourcePath = path.join(sourceProfileDir, 'Login Data');
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from './attach/snapshot-launch.js';
|
|
24
24
|
import { connectBrowser } from './connector.js';
|
|
25
25
|
import { migrateProfile } from './migration/index.js';
|
|
26
|
+
import { targetKeystoreFor } from './migration/os-crypt-keys.js';
|
|
26
27
|
import {
|
|
27
28
|
assertFixedDebuggingPort,
|
|
28
29
|
classifyDevToolsOwnership,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
} from './system-browser.js';
|
|
51
52
|
import {
|
|
52
53
|
defaultRestrictionsFor,
|
|
54
|
+
isChromeForTesting,
|
|
53
55
|
resolveLaunchExecutable,
|
|
54
56
|
} from './launch-executable.js';
|
|
55
57
|
|
|
@@ -372,10 +374,12 @@ function createCloser({
|
|
|
372
374
|
|
|
373
375
|
/**
|
|
374
376
|
* Selenium attaches through a chromedriver the launch starts; the driver
|
|
375
|
-
* follows the browser's interruption ownership (issue #174)
|
|
377
|
+
* follows the browser's interruption ownership (issue #174) and must match
|
|
378
|
+
* the browser just started, not the installed Chrome Selenium Manager would
|
|
379
|
+
* otherwise pick (issue #179).
|
|
376
380
|
*/
|
|
377
|
-
function
|
|
378
|
-
return engine === 'selenium' ? { interruption } : {};
|
|
381
|
+
function driverOptions(engine, { interruption, executablePath }) {
|
|
382
|
+
return engine === 'selenium' ? { interruption, executablePath } : {};
|
|
379
383
|
}
|
|
380
384
|
|
|
381
385
|
/**
|
|
@@ -602,6 +606,9 @@ async function runPreLaunchMigration({
|
|
|
602
606
|
migrateFrom,
|
|
603
607
|
userDataDir,
|
|
604
608
|
channel,
|
|
609
|
+
executablePath,
|
|
610
|
+
argOptions,
|
|
611
|
+
platform,
|
|
605
612
|
temporaryProfile,
|
|
606
613
|
migrate,
|
|
607
614
|
}) {
|
|
@@ -610,6 +617,14 @@ async function runPreLaunchMigration({
|
|
|
610
617
|
}
|
|
611
618
|
const { include, domains, passwordCsv, includePaymentCards, ...from } =
|
|
612
619
|
migrateFrom;
|
|
620
|
+
// Passwords are re-encrypted with the key of the browser that starts:
|
|
621
|
+
// Chrome for Testing keeps "Chromium Safe Storage", and its default mock
|
|
622
|
+
// keychain uses Chromium's fixed mock password (issue #179).
|
|
623
|
+
const launchArgs = [
|
|
624
|
+
...resolveRestrictions(argOptions.restrictions).args,
|
|
625
|
+
...argOptions.args,
|
|
626
|
+
...argOptions.extraArgs,
|
|
627
|
+
];
|
|
613
628
|
try {
|
|
614
629
|
const report = await migrate({
|
|
615
630
|
from,
|
|
@@ -618,7 +633,8 @@ async function runPreLaunchMigration({
|
|
|
618
633
|
domains,
|
|
619
634
|
passwordCsv,
|
|
620
635
|
includePaymentCards,
|
|
621
|
-
targetBrowser: channel,
|
|
636
|
+
targetBrowser: isChromeForTesting(executablePath) ? 'chromium' : channel,
|
|
637
|
+
targetKeystore: targetKeystoreFor(launchArgs, platform),
|
|
622
638
|
});
|
|
623
639
|
const { cookies, ...migration } = report;
|
|
624
640
|
return { migration, migratedCookies: cookies ?? [] };
|
|
@@ -753,6 +769,9 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
753
769
|
migrateFrom,
|
|
754
770
|
userDataDir,
|
|
755
771
|
channel,
|
|
772
|
+
executablePath,
|
|
773
|
+
argOptions,
|
|
774
|
+
platform: dependencies.platform ?? process.platform,
|
|
756
775
|
temporaryProfile,
|
|
757
776
|
migrate: dependencies.migrateProfile ?? migrateProfile,
|
|
758
777
|
});
|
|
@@ -806,7 +825,7 @@ export async function launchAndConnectRealBrowserWithDependencies(
|
|
|
806
825
|
engine,
|
|
807
826
|
cdpEndpoint: resolvedCdpEndpoint,
|
|
808
827
|
...connectionOptions,
|
|
809
|
-
...
|
|
828
|
+
...driverOptions(engine, { interruption, executablePath }),
|
|
810
829
|
seedCookies,
|
|
811
830
|
verbose,
|
|
812
831
|
});
|
package/src/browser/webdriver.js
CHANGED
|
@@ -42,7 +42,10 @@ import {
|
|
|
42
42
|
} from './profile-directory.js';
|
|
43
43
|
import { buildRealBrowserArgs } from './real-browser.js';
|
|
44
44
|
import { resolveRestrictions } from './restrictions.js';
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
defaultRestrictionsFor,
|
|
47
|
+
isChromeForTesting,
|
|
48
|
+
} from './launch-executable.js';
|
|
46
49
|
import { findBrowserSource } from './browser-sources.js';
|
|
47
50
|
import {
|
|
48
51
|
isSafariChannel,
|
|
@@ -219,7 +222,12 @@ export async function resolveWebDriverExecutable({
|
|
|
219
222
|
};
|
|
220
223
|
}
|
|
221
224
|
|
|
222
|
-
|
|
225
|
+
// A driver on PATH belongs to an installed browser; Chrome for Testing gets
|
|
226
|
+
// the driver of its own release from Selenium Manager (issue #179).
|
|
227
|
+
const ownDriver = isChromeForTesting(executablePath);
|
|
228
|
+
const onPath =
|
|
229
|
+
!ownDriver &&
|
|
230
|
+
(await findOnPath(name, { environment, platform, checkAccess }));
|
|
223
231
|
if (onPath) {
|
|
224
232
|
return {
|
|
225
233
|
driverPath: onPath,
|
|
@@ -228,11 +236,39 @@ export async function resolveWebDriverExecutable({
|
|
|
228
236
|
};
|
|
229
237
|
}
|
|
230
238
|
|
|
239
|
+
return await askSeleniumManager({
|
|
240
|
+
browser,
|
|
241
|
+
name,
|
|
242
|
+
executablePath,
|
|
243
|
+
ownDriver,
|
|
244
|
+
environment,
|
|
245
|
+
platform,
|
|
246
|
+
runCommand,
|
|
247
|
+
managerPath,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Ask Selenium Manager for the driver of `browser` (and the browser itself). */
|
|
252
|
+
async function askSeleniumManager({
|
|
253
|
+
browser,
|
|
254
|
+
name,
|
|
255
|
+
executablePath,
|
|
256
|
+
ownDriver,
|
|
257
|
+
environment,
|
|
258
|
+
platform,
|
|
259
|
+
runCommand,
|
|
260
|
+
managerPath,
|
|
261
|
+
}) {
|
|
231
262
|
const manager = managerPath ?? seleniumManagerPath({ platform, environment });
|
|
232
|
-
const args = [
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
263
|
+
const args = [
|
|
264
|
+
'--browser',
|
|
265
|
+
browser,
|
|
266
|
+
'--output',
|
|
267
|
+
'json',
|
|
268
|
+
...(executablePath ? ['--browser-path', executablePath] : []),
|
|
269
|
+
// Selenium Manager itself prefers a driver on PATH, whatever its release.
|
|
270
|
+
...(ownDriver ? ['--skip-driver-in-path'] : []),
|
|
271
|
+
];
|
|
236
272
|
const { stdout, code } = await runCommand(manager, args, { check: false });
|
|
237
273
|
let output;
|
|
238
274
|
try {
|
package/src/exports.js
CHANGED
package/src/tests/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import { test as baseTest } from 'test-anywhere';
|
|
12
12
|
import { launchBrowser } from '../browser/launcher.js';
|
|
13
13
|
import { makeBrowserCommander } from '../factory.js';
|
|
14
|
+
import { captureStableViewport } from '../capture/viewport.js';
|
|
14
15
|
import {
|
|
15
16
|
finishScenarioTrace,
|
|
16
17
|
resolveTestDownloads,
|
|
@@ -571,7 +572,19 @@ export async function writeFailureArtifacts(options = {}) {
|
|
|
571
572
|
if (page && typeof page.screenshot === 'function') {
|
|
572
573
|
const screenshotPath = path.join(artifactsDir, `${safeName}.png`);
|
|
573
574
|
try {
|
|
574
|
-
|
|
575
|
+
// A clipped engine capture can leave a resized headful window drawn at
|
|
576
|
+
// its old size (issue #180); without a fixed viewport, capture the view.
|
|
577
|
+
if (['playwright', 'puppeteer'].includes(engine)) {
|
|
578
|
+
const shot = await captureStableViewport(page, engine, {
|
|
579
|
+
type: 'png',
|
|
580
|
+
path: screenshotPath,
|
|
581
|
+
});
|
|
582
|
+
if (shot.engine === 'cdp') {
|
|
583
|
+
writeFileSync(screenshotPath, shot.bytes);
|
|
584
|
+
}
|
|
585
|
+
} else {
|
|
586
|
+
await page.screenshot({ path: screenshotPath });
|
|
587
|
+
}
|
|
575
588
|
artifacts.push(screenshotPath);
|
|
576
589
|
} catch (screenshotError) {
|
|
577
590
|
const screenshotErrorPath = path.join(
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #179: without a channel or executablePath every engine starts Chrome
|
|
3
|
+
* for Testing, hides its "only for automated testing" infobar and keeps it
|
|
4
|
+
* away from the user's keychain.
|
|
5
|
+
*/
|
|
6
|
+
// feature-parity: launch.chrome-for-testing@native-typed
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { describe, it } from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { isChromeForTesting } from '../../src/browser/launch-executable.js';
|
|
11
|
+
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
12
|
+
import { readBrowserVersionPage } from '../../src/browser/parity.js';
|
|
13
|
+
import { SANDBOX_ARGS } from '../helpers/e2e-browser.js';
|
|
14
|
+
|
|
15
|
+
function skipReason() {
|
|
16
|
+
if (!process.env.RUN_E2E) {
|
|
17
|
+
return 'set RUN_E2E=true';
|
|
18
|
+
}
|
|
19
|
+
if (process.platform === 'linux' && !process.env.DISPLAY) {
|
|
20
|
+
return 'the infobar is only drawn in a window; run under xvfb-run';
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** What chrome://version reports about the browser a session started. */
|
|
26
|
+
async function versionPage(engine, session) {
|
|
27
|
+
if (engine !== 'selenium') {
|
|
28
|
+
return await readBrowserVersionPage(session.page);
|
|
29
|
+
}
|
|
30
|
+
await session.browser.get('chrome://version');
|
|
31
|
+
return await session.browser.executeScript(`
|
|
32
|
+
const text = (id) => (document.getElementById(id)?.textContent ?? '').trim();
|
|
33
|
+
return { commandLine: text('command_line'), executablePath: text('executable_path') };
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Height of the browser UI above the page: tab strip, toolbar, infobars. */
|
|
38
|
+
async function frameHeight(session) {
|
|
39
|
+
await session.page.goto('data:text/html,<title>frame</title>');
|
|
40
|
+
// The infobar appears just after the first window is shown.
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
42
|
+
return await session.page.evaluate(
|
|
43
|
+
() => globalThis.outerHeight - globalThis.innerHeight
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe(
|
|
48
|
+
'Chrome for Testing by default (issue #179)',
|
|
49
|
+
{ skip: skipReason(), timeout: 180000 },
|
|
50
|
+
() => {
|
|
51
|
+
for (const engine of ['playwright', 'puppeteer', 'selenium']) {
|
|
52
|
+
it(`${engine} starts Chrome for Testing without its infobar or keychain`, async () => {
|
|
53
|
+
const session = await launchBrowser({
|
|
54
|
+
engine,
|
|
55
|
+
headless: false,
|
|
56
|
+
args: SANDBOX_ARGS,
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
const { commandLine, executablePath } = await versionPage(
|
|
60
|
+
engine,
|
|
61
|
+
session
|
|
62
|
+
);
|
|
63
|
+
assert.equal(
|
|
64
|
+
isChromeForTesting(executablePath),
|
|
65
|
+
true,
|
|
66
|
+
executablePath
|
|
67
|
+
);
|
|
68
|
+
assert.deepEqual(session.defaultRestrictions, [
|
|
69
|
+
'no-chrome-for-testing-infobar',
|
|
70
|
+
'mock-keychain',
|
|
71
|
+
]);
|
|
72
|
+
assert.match(commandLine, /--test-type=gpu(\s|$)/u);
|
|
73
|
+
assert.match(commandLine, /--use-mock-keychain(\s|$)/u);
|
|
74
|
+
} finally {
|
|
75
|
+
await session.close();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it('the default restrictions remove the infobar the opt-out shows', async () => {
|
|
81
|
+
const heights = {};
|
|
82
|
+
for (const [name, defaultRestrictions] of [
|
|
83
|
+
['defaults', undefined],
|
|
84
|
+
['opt-out', []],
|
|
85
|
+
]) {
|
|
86
|
+
const session = await launchBrowser({
|
|
87
|
+
engine: 'playwright',
|
|
88
|
+
headless: false,
|
|
89
|
+
args: SANDBOX_ARGS,
|
|
90
|
+
...(defaultRestrictions ? { defaultRestrictions } : {}),
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
heights[name] = await frameHeight(session);
|
|
94
|
+
} finally {
|
|
95
|
+
await session.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// The infobar is about 40px tall; nothing else differs between the two.
|
|
99
|
+
assert.ok(
|
|
100
|
+
heights['opt-out'] - heights.defaults >= 20,
|
|
101
|
+
JSON.stringify(heights)
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
);
|
|
@@ -109,6 +109,19 @@ describe('browser hand-off (issue #174)', () => {
|
|
|
109
109
|
assert.deepEqual(await session.handoff(), handoffFor(endpoint));
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
it('reattaches Selenium with the driver of the handed-off browser (issue #179)', async () => {
|
|
113
|
+
const endpoint = await fakeEndpoint();
|
|
114
|
+
const cft =
|
|
115
|
+
'/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
|
|
116
|
+
const calls = [];
|
|
117
|
+
await reconnectBrowserWithDependencies(
|
|
118
|
+
handoffFor(endpoint, { executablePath: cft }),
|
|
119
|
+
{},
|
|
120
|
+
{ connect: fakeConnect(calls) }
|
|
121
|
+
);
|
|
122
|
+
assert.equal(calls[0][1].executablePath, cft);
|
|
123
|
+
});
|
|
124
|
+
|
|
112
125
|
it('reads a hand-off from a JSON file', async () => {
|
|
113
126
|
const endpoint = await fakeEndpoint();
|
|
114
127
|
const directory = await mkdtemp(path.join(os.tmpdir(), 'bc-handoff-'));
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createSourceKeyResolver,
|
|
7
7
|
localStatePathForProfile,
|
|
8
8
|
resolveTargetKey,
|
|
9
|
+
targetKeystoreFor,
|
|
9
10
|
} from '../../../../src/browser/migration/os-crypt-keys.js';
|
|
10
11
|
import { deriveChromiumCookieKey } from '../../../../src/browser/browser-cookie-crypto.js';
|
|
11
12
|
|
|
@@ -77,6 +78,45 @@ describe('resolveTargetKey', () => {
|
|
|
77
78
|
assert.equal(result.prefix, 'v11');
|
|
78
79
|
});
|
|
79
80
|
|
|
81
|
+
it('tags macOS keys v10, the only tag macOS Chromium reads', async () => {
|
|
82
|
+
const result = await resolveTargetKey({
|
|
83
|
+
browser: 'chromium',
|
|
84
|
+
platform: 'darwin',
|
|
85
|
+
readSafeStoragePassword: async () => 'target-pass',
|
|
86
|
+
});
|
|
87
|
+
assert.deepEqual(
|
|
88
|
+
result.key,
|
|
89
|
+
deriveChromiumCookieKey('target-pass', 'darwin')
|
|
90
|
+
);
|
|
91
|
+
assert.equal(result.prefix, 'v10');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("uses Chromium's fixed mock passwords for a mock keystore (issue #179)", async () => {
|
|
95
|
+
const unreadable = async () => {
|
|
96
|
+
throw new Error('the OS keystore must not be asked');
|
|
97
|
+
};
|
|
98
|
+
const mac = await resolveTargetKey({
|
|
99
|
+
browser: 'chromium',
|
|
100
|
+
platform: 'darwin',
|
|
101
|
+
keystore: 'mock',
|
|
102
|
+
readSafeStoragePassword: unreadable,
|
|
103
|
+
});
|
|
104
|
+
assert.deepEqual(mac, {
|
|
105
|
+
key: deriveChromiumCookieKey('mock_password', 'darwin'),
|
|
106
|
+
prefix: 'v10',
|
|
107
|
+
});
|
|
108
|
+
const linux = await resolveTargetKey({
|
|
109
|
+
browser: 'chrome',
|
|
110
|
+
platform: 'linux',
|
|
111
|
+
keystore: 'mock',
|
|
112
|
+
readSafeStoragePassword: unreadable,
|
|
113
|
+
});
|
|
114
|
+
assert.deepEqual(linux, {
|
|
115
|
+
key: deriveChromiumCookieKey('peanuts', 'linux'),
|
|
116
|
+
prefix: 'v10',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
80
120
|
it('refuses to derive a Windows key', async () => {
|
|
81
121
|
await assert.rejects(
|
|
82
122
|
resolveTargetKey({ browser: 'chrome', platform: 'win32' }),
|
|
@@ -85,6 +125,23 @@ describe('resolveTargetKey', () => {
|
|
|
85
125
|
});
|
|
86
126
|
});
|
|
87
127
|
|
|
128
|
+
describe('targetKeystoreFor (issue #179)', () => {
|
|
129
|
+
it('reports the switches that bypass the OS keystore', () => {
|
|
130
|
+
assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'darwin'), 'mock');
|
|
131
|
+
assert.equal(
|
|
132
|
+
targetKeystoreFor(['--password-store=basic'], 'linux'),
|
|
133
|
+
'mock'
|
|
134
|
+
);
|
|
135
|
+
assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'linux'), 'os');
|
|
136
|
+
assert.equal(
|
|
137
|
+
targetKeystoreFor(['--password-store=gnome-libsecret'], 'linux'),
|
|
138
|
+
'os'
|
|
139
|
+
);
|
|
140
|
+
assert.equal(targetKeystoreFor([], 'darwin'), 'os');
|
|
141
|
+
assert.equal(targetKeystoreFor(['--use-mock-keychain'], 'win32'), 'os');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
88
145
|
describe('localStatePathForProfile', () => {
|
|
89
146
|
it('points at Local State next to the profile directory', () => {
|
|
90
147
|
assert.equal(
|
|
@@ -24,6 +24,24 @@ const fixture = (name) =>
|
|
|
24
24
|
)
|
|
25
25
|
);
|
|
26
26
|
|
|
27
|
+
function migratedPasswords(profile) {
|
|
28
|
+
const db = new Database(path.join(profile, 'Login Data'));
|
|
29
|
+
try {
|
|
30
|
+
return db.prepare('SELECT origin_url,password_value FROM logins').all();
|
|
31
|
+
} finally {
|
|
32
|
+
db.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const decryptPassword = (row, key, platform) =>
|
|
37
|
+
decryptChromiumCookie({
|
|
38
|
+
encryptedValue: row.password_value,
|
|
39
|
+
key,
|
|
40
|
+
platform,
|
|
41
|
+
databaseVersion: 0,
|
|
42
|
+
host: 'github.com',
|
|
43
|
+
});
|
|
44
|
+
|
|
27
45
|
describe('Safari data sources', () => {
|
|
28
46
|
for (const format of ['binary', 'xml']) {
|
|
29
47
|
it(`preserves hierarchy and reading list from ${format} plist`, async () => {
|
|
@@ -103,25 +121,31 @@ describe('Safari data sources', () => {
|
|
|
103
121
|
({ reason }) => reason === 'safari-reading-list-translated'
|
|
104
122
|
)
|
|
105
123
|
);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
124
|
+
const rows = migratedPasswords(target);
|
|
125
|
+
assert.equal(rows.length, 1);
|
|
126
|
+
assert.equal(decryptPassword(rows[0], key, 'linux'), 'p"a\nss');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('encrypts migrated passwords with the mock keychain key of a Chrome for Testing launch (issue #179)', async () => {
|
|
130
|
+
const source = await temporary();
|
|
131
|
+
const target = await temporary();
|
|
132
|
+
// No keys are injected and the OS keystore is never asked: Chromium's
|
|
133
|
+
// --use-mock-keychain derives its key from the fixed "mock_password".
|
|
134
|
+
const report = await migrateProfile({
|
|
135
|
+
from: { browser: 'safari', userDataDir: source },
|
|
136
|
+
to: target,
|
|
137
|
+
include: ['passwords'],
|
|
138
|
+
domains: ['github.com'],
|
|
139
|
+
passwordCsv: fixture('Passwords.csv'),
|
|
140
|
+
platform: 'darwin',
|
|
141
|
+
targetBrowser: 'chromium',
|
|
142
|
+
targetKeystore: 'mock',
|
|
143
|
+
});
|
|
144
|
+
assert.equal(report.migrated.passwords, 1);
|
|
145
|
+
const [row] = migratedPasswords(target);
|
|
146
|
+
assert.equal(row.password_value.subarray(0, 3).toString(), 'v10');
|
|
147
|
+
const key = deriveChromiumCookieKey('mock_password', 'darwin');
|
|
148
|
+
assert.equal(decryptPassword(row, key, 'darwin'), 'p"a\nss');
|
|
125
149
|
});
|
|
126
150
|
|
|
127
151
|
it('names the executing app and retry link for non-cookie protection failures', async () => {
|
|
@@ -282,6 +282,28 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
282
282
|
await access(path.join(temporaryDirectory, 'First Run'));
|
|
283
283
|
});
|
|
284
284
|
|
|
285
|
+
it('gives Selenium the started browser so its driver matches it (issue #179)', async () => {
|
|
286
|
+
temporaryDirectory = await makeDedicatedProfile();
|
|
287
|
+
const cft =
|
|
288
|
+
'/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
|
|
289
|
+
let connectOptions;
|
|
290
|
+
await launchAndConnectRealBrowserWithDependencies(
|
|
291
|
+
{ engine: 'selenium', userDataDir: temporaryDirectory },
|
|
292
|
+
{
|
|
293
|
+
resolveExecutable: async () => cft,
|
|
294
|
+
reservePort: async () => 9444,
|
|
295
|
+
spawnBrowser: () => fakeProcess([]),
|
|
296
|
+
waitForEndpoint: async () => 'http://127.0.0.1:9444',
|
|
297
|
+
connect: async (options) => {
|
|
298
|
+
connectOptions = options;
|
|
299
|
+
return { browser: null, page: null };
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
assert.equal(connectOptions.engine, 'selenium');
|
|
304
|
+
assert.equal(connectOptions.executablePath, cft);
|
|
305
|
+
});
|
|
306
|
+
|
|
285
307
|
it('migrates a profile before launch and seeds the migrated cookies', async () => {
|
|
286
308
|
temporaryDirectory = await makeDedicatedProfile();
|
|
287
309
|
const calls = [];
|
|
@@ -568,6 +590,57 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
568
590
|
]);
|
|
569
591
|
});
|
|
570
592
|
|
|
593
|
+
it('migrates passwords with the key of the browser that starts (issue #179)', async () => {
|
|
594
|
+
const cft =
|
|
595
|
+
'/Users/u/Library/Caches/ms-playwright/chromium-1248/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
|
596
|
+
const migrateTarget = async (executablePath, options = {}) => {
|
|
597
|
+
let migrateOptions;
|
|
598
|
+
const result = await launchAndConnectRealBrowserWithDependencies(
|
|
599
|
+
{
|
|
600
|
+
closeTimeout: 50,
|
|
601
|
+
migrateFrom: { browser: 'chrome', include: ['passwords'] },
|
|
602
|
+
...options,
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
platform: 'darwin',
|
|
606
|
+
resolveExecutable: async () => executablePath,
|
|
607
|
+
reservePort: async () => 40007,
|
|
608
|
+
spawnBrowser: () => fakeProcess([]),
|
|
609
|
+
waitForEndpoint: async () => 'http://127.0.0.1:40007',
|
|
610
|
+
connect: async () => ({ browser: null, page: null }),
|
|
611
|
+
migrateProfile: async (migration) => {
|
|
612
|
+
migrateOptions = migration;
|
|
613
|
+
return { migrated: {}, skipped: [], warnings: [], cookies: [] };
|
|
614
|
+
},
|
|
615
|
+
}
|
|
616
|
+
);
|
|
617
|
+
await result.close();
|
|
618
|
+
const { targetBrowser, targetKeystore } = migrateOptions;
|
|
619
|
+
return { targetBrowser, targetKeystore };
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
// Chrome for Testing keeps "Chromium Safe Storage", and its default
|
|
623
|
+
// mock keychain encrypts with Chromium's fixed mock password.
|
|
624
|
+
assert.deepEqual(await migrateTarget(cft), {
|
|
625
|
+
targetBrowser: 'chromium',
|
|
626
|
+
targetKeystore: 'mock',
|
|
627
|
+
});
|
|
628
|
+
assert.deepEqual(await migrateTarget(cft, { defaultRestrictions: [] }), {
|
|
629
|
+
targetBrowser: 'chromium',
|
|
630
|
+
targetKeystore: 'os',
|
|
631
|
+
});
|
|
632
|
+
assert.deepEqual(await migrateTarget('/opt/google/chrome'), {
|
|
633
|
+
targetBrowser: 'chrome',
|
|
634
|
+
targetKeystore: 'os',
|
|
635
|
+
});
|
|
636
|
+
assert.deepEqual(
|
|
637
|
+
await migrateTarget('/opt/google/chrome', {
|
|
638
|
+
restrictions: ['mock-keychain'],
|
|
639
|
+
}),
|
|
640
|
+
{ targetBrowser: 'chrome', targetKeystore: 'mock' }
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
|
|
571
644
|
it('terminates the spawned browser when connection fails', async () => {
|
|
572
645
|
temporaryDirectory = await makeDedicatedProfile();
|
|
573
646
|
let killed = false;
|
|
@@ -166,6 +166,31 @@ function launchDependencies(overrides = {}) {
|
|
|
166
166
|
};
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
+
// Resolve through a fake Selenium Manager that answers browserPath.
|
|
170
|
+
async function managerResolve(
|
|
171
|
+
options,
|
|
172
|
+
{ checkAccess = accessOnly(), browserPath }
|
|
173
|
+
) {
|
|
174
|
+
const commands = [];
|
|
175
|
+
const resolved = await resolveWebDriverExecutable({
|
|
176
|
+
managerPath: '/sm',
|
|
177
|
+
checkAccess,
|
|
178
|
+
runCommand: async (file, args) => {
|
|
179
|
+
commands.push([file, ...args]);
|
|
180
|
+
return managerOutput({
|
|
181
|
+
driver_path: '/cache/chromedriver',
|
|
182
|
+
browser_path: browserPath,
|
|
183
|
+
});
|
|
184
|
+
},
|
|
185
|
+
...options,
|
|
186
|
+
});
|
|
187
|
+
return { commands, resolved };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const SELENIUM_CFT = '/home/u/.cache/selenium/chrome/linux64/156.0/chrome';
|
|
191
|
+
const PLAYWRIGHT_CFT =
|
|
192
|
+
'/home/u/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome';
|
|
193
|
+
|
|
169
194
|
describe('resolveWebDriverExecutable', () => {
|
|
170
195
|
it('uses an explicit driverPath first', async () => {
|
|
171
196
|
const resolved = await resolveWebDriverExecutable({
|
|
@@ -210,21 +235,38 @@ describe('resolveWebDriverExecutable', () => {
|
|
|
210
235
|
assert.equal(resolved.driverPath, 'C:\\b\\chromedriver.exe');
|
|
211
236
|
});
|
|
212
237
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
238
|
+
// A chromedriver on PATH drives the installed Google Chrome: never used
|
|
239
|
+
// for Chrome for Testing (issue #179). Selenium Manager returns a PATH
|
|
240
|
+
// driver of another release too, so it is told to skip it (run
|
|
241
|
+
// 39651456089: ChromeDriver 155 for Chrome 153).
|
|
242
|
+
for (const cft of [PLAYWRIGHT_CFT, SELENIUM_CFT]) {
|
|
243
|
+
it(`matches ${cft} with its own driver, not the one on PATH (issue #179)`, async () => {
|
|
244
|
+
const { commands, resolved } = await managerResolve(
|
|
245
|
+
{ executablePath: cft, environment: { PATH: '/bin' } },
|
|
246
|
+
{ checkAccess: accessOnly('/bin/chromedriver'), browserPath: cft }
|
|
247
|
+
);
|
|
248
|
+
assert.deepEqual(commands, [
|
|
249
|
+
[
|
|
250
|
+
'/sm',
|
|
251
|
+
'--browser',
|
|
252
|
+
'chrome',
|
|
253
|
+
'--output',
|
|
254
|
+
'json',
|
|
255
|
+
'--browser-path',
|
|
256
|
+
cft,
|
|
257
|
+
'--skip-driver-in-path',
|
|
258
|
+
],
|
|
259
|
+
]);
|
|
260
|
+
assert.equal(resolved.driverPath, '/cache/chromedriver');
|
|
261
|
+
assert.equal(resolved.source, 'selenium-manager');
|
|
227
262
|
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
it('then asks Selenium Manager for a matching driver', async () => {
|
|
266
|
+
const { commands, resolved } = await managerResolve(
|
|
267
|
+
{ executablePath: '/usr/bin/google-chrome', environment: { PATH: '' } },
|
|
268
|
+
{ browserPath: '/usr/bin/google-chrome' }
|
|
269
|
+
);
|
|
228
270
|
assert.deepEqual(commands, [
|
|
229
271
|
[
|
|
230
272
|
'/sm',
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
classifyStatus,
|
|
17
17
|
extractRejectedLinks,
|
|
18
18
|
judge,
|
|
19
|
+
ownRepositoryPath,
|
|
19
20
|
} from '../../../../scripts/check-web-archive.mjs';
|
|
20
21
|
|
|
21
22
|
// Verbatim shape of lychee/out.md from run 33959793880.
|
|
@@ -131,8 +132,8 @@ const waybackHas = (timestamp, archiveUrl) => async () => ({
|
|
|
131
132
|
timestamp,
|
|
132
133
|
});
|
|
133
134
|
|
|
134
|
-
const verdictFor = (link, recheckImpl, waybackImpl) =>
|
|
135
|
-
judge(link, { recheckImpl, waybackImpl });
|
|
135
|
+
const verdictFor = (link, recheckImpl, waybackImpl, extra = {}) =>
|
|
136
|
+
judge(link, { recheckImpl, waybackImpl, ...extra });
|
|
136
137
|
|
|
137
138
|
describe('judging one rejected link', () => {
|
|
138
139
|
const playwright = {
|
|
@@ -151,6 +152,14 @@ describe('judging one rejected link', () => {
|
|
|
151
152
|
assert.equal(verdict.verdict, 'transient');
|
|
152
153
|
});
|
|
153
154
|
|
|
155
|
+
it('does not fail for a host that answered 5xx and then timed out', async () => {
|
|
156
|
+
// chromium.googlesource.com answered 503 to lychee and then let the
|
|
157
|
+
// re-check time out: neither said the link is gone, and a host that
|
|
158
|
+
// answered at all still resolves.
|
|
159
|
+
const verdict = await verdictFor(playwright, unreachable(), waybackNever());
|
|
160
|
+
assert.equal(verdict.verdict, 'transient');
|
|
161
|
+
});
|
|
162
|
+
|
|
154
163
|
it('does not fail for a host that keeps rate-limiting the runner', async () => {
|
|
155
164
|
const verdict = await verdictFor(
|
|
156
165
|
{ url: 'https://example.com/x', status: '429' },
|
|
@@ -189,3 +198,69 @@ describe('judging one rejected link', () => {
|
|
|
189
198
|
assert.equal(verdict.verdict, 'gone');
|
|
190
199
|
});
|
|
191
200
|
});
|
|
201
|
+
|
|
202
|
+
describe('a link to a file this branch adds', () => {
|
|
203
|
+
// Run 39651456194: the READMEs link the new docs/chrome-for-testing.md on
|
|
204
|
+
// main, which is published to npm, PyPI and crates.io, so it must be an
|
|
205
|
+
// absolute URL. Until the pull request merges, main has no such file.
|
|
206
|
+
const repository = 'link-foundation/browser-commander';
|
|
207
|
+
const newDoc = {
|
|
208
|
+
url: 'https://github.com/link-foundation/browser-commander/blob/main/docs/chrome-for-testing.md',
|
|
209
|
+
status: '404',
|
|
210
|
+
};
|
|
211
|
+
const checkout = (...files) => ({
|
|
212
|
+
repository,
|
|
213
|
+
fileExists: (relative) => files.includes(relative),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('maps this repository’s main-branch links to checkout paths', () => {
|
|
217
|
+
assert.equal(
|
|
218
|
+
ownRepositoryPath(`${newDoc.url}#verifying-it`, repository),
|
|
219
|
+
'docs/chrome-for-testing.md'
|
|
220
|
+
);
|
|
221
|
+
assert.equal(
|
|
222
|
+
ownRepositoryPath(
|
|
223
|
+
'https://github.com/link-foundation/browser-commander/tree/main/docs/case%20studies',
|
|
224
|
+
repository
|
|
225
|
+
),
|
|
226
|
+
'docs/case studies'
|
|
227
|
+
);
|
|
228
|
+
assert.equal(
|
|
229
|
+
ownRepositoryPath(
|
|
230
|
+
'https://github.com/link-foundation/browser-commander/blob/v1/x.md',
|
|
231
|
+
repository
|
|
232
|
+
),
|
|
233
|
+
null
|
|
234
|
+
);
|
|
235
|
+
assert.equal(
|
|
236
|
+
ownRepositoryPath(
|
|
237
|
+
'https://github.com/other/repo/blob/main/x.md',
|
|
238
|
+
repository
|
|
239
|
+
),
|
|
240
|
+
null
|
|
241
|
+
);
|
|
242
|
+
assert.equal(ownRepositoryPath(newDoc.url, undefined), null);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('does not fail when the checkout has the file main is about to get', async () => {
|
|
246
|
+
const verdict = await verdictFor(
|
|
247
|
+
newDoc,
|
|
248
|
+
() => {
|
|
249
|
+
throw new Error('a file in the checkout needs no network request');
|
|
250
|
+
},
|
|
251
|
+
waybackNever(),
|
|
252
|
+
checkout('docs/chrome-for-testing.md')
|
|
253
|
+
);
|
|
254
|
+
assert.equal(verdict.verdict, 'unmerged');
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('still fails when the checkout does not have the file either', async () => {
|
|
258
|
+
const verdict = await verdictFor(
|
|
259
|
+
newDoc,
|
|
260
|
+
answers(404),
|
|
261
|
+
waybackNothing(),
|
|
262
|
+
checkout('docs/feature-parity.md')
|
|
263
|
+
);
|
|
264
|
+
assert.equal(verdict.verdict, 'gone');
|
|
265
|
+
});
|
|
266
|
+
});
|
|
@@ -232,6 +232,7 @@ describe('browser-commander/tests', () => {
|
|
|
232
232
|
page: {
|
|
233
233
|
screenshot: async ({ path: screenshotPath }) => {
|
|
234
234
|
assert.ok(screenshotPath.endsWith('.png'));
|
|
235
|
+
return Buffer.from('png');
|
|
235
236
|
},
|
|
236
237
|
},
|
|
237
238
|
error: new Error('broken'),
|
|
@@ -241,6 +242,7 @@ describe('browser-commander/tests', () => {
|
|
|
241
242
|
});
|
|
242
243
|
|
|
243
244
|
assert.strictEqual(artifacts.length, 2);
|
|
245
|
+
assert.ok(artifacts[1].endsWith('.png'), artifacts[1]);
|
|
244
246
|
const errorJson = JSON.parse(readFileSync(artifacts[0], 'utf8'));
|
|
245
247
|
assert.strictEqual(errorJson.message, 'broken');
|
|
246
248
|
assert.strictEqual(errorJson.engine, 'playwright');
|
|
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|
|
5
5
|
import { startTrace } from '../../../src/traces/recorder.js';
|
|
6
6
|
import { TRACE_MODE } from '../../../src/traces/schema.js';
|
|
7
7
|
import { screenshot } from '../../../src/capture/index.js';
|
|
8
|
+
import { writeFailureArtifacts } from '../../../src/tests/index.js';
|
|
8
9
|
import {
|
|
9
10
|
createFakeCommander,
|
|
10
11
|
createFakePage,
|
|
@@ -45,6 +46,22 @@ function cdpFake({ viewport = null, failWith, engine = 'playwright' } = {}) {
|
|
|
45
46
|
return { page, calls };
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** One clip-less CDP capture of the view, and no engine screenshot. */
|
|
50
|
+
function assertViewCapture(calls) {
|
|
51
|
+
assert.deepEqual(calls.cdp, [
|
|
52
|
+
{
|
|
53
|
+
method: 'Page.captureScreenshot',
|
|
54
|
+
params: {
|
|
55
|
+
format: 'png',
|
|
56
|
+
fromSurface: false,
|
|
57
|
+
captureBeyondViewport: false,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
]);
|
|
61
|
+
assert.equal(calls.engine.length, 0);
|
|
62
|
+
assert.equal(calls.detached, 1);
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
describe('trace checkpoint screenshots (issue #180)', () => {
|
|
49
66
|
const directory = useTempTraceDirectory();
|
|
50
67
|
|
|
@@ -74,18 +91,7 @@ describe('trace checkpoint screenshots (issue #180)', () => {
|
|
|
74
91
|
await checkpointShot(page, { withCommander, engine }),
|
|
75
92
|
'cdp-png'
|
|
76
93
|
);
|
|
77
|
-
|
|
78
|
-
{
|
|
79
|
-
method: 'Page.captureScreenshot',
|
|
80
|
-
params: {
|
|
81
|
-
format: 'png',
|
|
82
|
-
fromSurface: false,
|
|
83
|
-
captureBeyondViewport: false,
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
]);
|
|
87
|
-
assert.equal(calls.engine.length, 0);
|
|
88
|
-
assert.equal(calls.detached, 1);
|
|
94
|
+
assertViewCapture(calls);
|
|
89
95
|
});
|
|
90
96
|
}
|
|
91
97
|
}
|
|
@@ -171,3 +177,48 @@ describe('default commander screenshots (issue #180)', () => {
|
|
|
171
177
|
});
|
|
172
178
|
}
|
|
173
179
|
});
|
|
180
|
+
|
|
181
|
+
describe('failure artifact screenshots (issue #180)', () => {
|
|
182
|
+
const directory = useTempTraceDirectory();
|
|
183
|
+
|
|
184
|
+
/** The PNG artifact a failed scenario writes, read back as text. */
|
|
185
|
+
async function failureShot(page, engine) {
|
|
186
|
+
page.screenshot = async (options) => {
|
|
187
|
+
page.engineCalls.push(options);
|
|
188
|
+
await fs.writeFile(options.path, 'engine-png');
|
|
189
|
+
return Buffer.from('engine-png');
|
|
190
|
+
};
|
|
191
|
+
const [, shot] = await writeFailureArtifacts({
|
|
192
|
+
page,
|
|
193
|
+
error: new Error('broken'),
|
|
194
|
+
testId: 'resized',
|
|
195
|
+
engine,
|
|
196
|
+
artifactsDir: directory.path,
|
|
197
|
+
});
|
|
198
|
+
assert.ok(shot.endsWith('.png'), shot);
|
|
199
|
+
return (await fs.readFile(shot)).toString();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
203
|
+
it(`${engine} without a fixed viewport sends no clip`, async () => {
|
|
204
|
+
const { page, calls } = cdpFake({ engine });
|
|
205
|
+
page.engineCalls = calls.engine;
|
|
206
|
+
assert.equal(await failureShot(page, engine), 'cdp-png');
|
|
207
|
+
assertViewCapture(calls);
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const [name, engine, viewport] of [
|
|
212
|
+
['playwright with a fixed viewport', 'playwright', { width: 8, height: 6 }],
|
|
213
|
+
['puppeteer with a fixed viewport', 'puppeteer', { width: 8, height: 6 }],
|
|
214
|
+
['selenium', 'selenium', null],
|
|
215
|
+
]) {
|
|
216
|
+
it(`${name} keeps the engine screenshot`, async () => {
|
|
217
|
+
const page =
|
|
218
|
+
engine === 'selenium' ? {} : cdpFake({ engine, viewport }).page;
|
|
219
|
+
page.engineCalls = [];
|
|
220
|
+
assert.equal(await failureShot(page, engine), 'engine-png');
|
|
221
|
+
assert.equal(page.engineCalls.length, 1);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
});
|