browser-commander 0.9.0 → 0.10.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 +12 -0
- package/README.md +13 -0
- package/eslint.config.js +3 -3
- package/package.json +7 -7
- package/scripts/clean-npm-config.mjs +39 -0
- package/scripts/npm-registry.mjs +31 -0
- package/scripts/publish-to-npm.mjs +12 -13
- package/scripts/run-tests.mjs +192 -0
- package/scripts/setup-npm.mjs +1 -1
- package/src/browser/launch-options.js +51 -0
- package/src/browser/launcher.js +23 -16
- package/src/browser/navigation.js +2 -3
- package/src/core/dialog-manager.js +1 -1
- package/src/core/engine-adapter.js +46 -40
- package/src/core/navigation-manager.js +1 -1
- package/src/core/network-tracker.js +1 -2
- package/src/core/page-session.js +5 -5
- package/src/core/page-trigger-manager.js +4 -2
- package/src/elements/selectors.js +3 -3
- package/src/high-level/universal-logic.js +0 -1
- package/src/interactions/click.js +1 -1
- package/tests/unit/browser/launch-options.test.js +53 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- fe688ac: Add `channel` and `executablePath` launch options for reusing an installed Chrome-family browser.
|
|
8
|
+
|
|
9
|
+
## 0.9.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- cbd4fe1: Remove CI warning noise from workflow actions, release logging, and lint output.
|
|
14
|
+
|
|
3
15
|
## 0.9.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -87,6 +87,19 @@ await commander.destroy();
|
|
|
87
87
|
await browser.close();
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
+
To reuse an installed Chrome-family browser instead of downloading the
|
|
91
|
+
engine's bundled Chromium, select a browser channel or provide its executable
|
|
92
|
+
path. Both options are passed directly to Playwright or Puppeteer:
|
|
93
|
+
|
|
94
|
+
```javascript
|
|
95
|
+
const { browser, page } = await launchBrowser({
|
|
96
|
+
engine: 'playwright',
|
|
97
|
+
channel: 'chrome',
|
|
98
|
+
// Or: executablePath: '/usr/bin/google-chrome',
|
|
99
|
+
headless: true,
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
90
103
|
## Browser Commander Tests
|
|
91
104
|
|
|
92
105
|
`browser-commander/tests` adds browser fixtures and scheduling helpers on top of
|
package/eslint.config.js
CHANGED
|
@@ -88,13 +88,13 @@ export default [
|
|
|
88
88
|
// Comments and documentation
|
|
89
89
|
'spaced-comment': ['error', 'always', { markers: ['/'] }],
|
|
90
90
|
|
|
91
|
-
// Complexity rules -
|
|
92
|
-
complexity: ['warn',
|
|
91
|
+
// Complexity rules - tuned for browser-automation orchestration code
|
|
92
|
+
complexity: ['warn', 30], // Cyclomatic complexity guardrail for unusually branchy functions
|
|
93
93
|
'max-depth': ['warn', 5], // Maximum nesting depth - slightly more lenient than strict 4
|
|
94
94
|
'max-lines-per-function': [
|
|
95
95
|
'warn',
|
|
96
96
|
{
|
|
97
|
-
max:
|
|
97
|
+
max: 300, // Allows orchestration functions while still catching very large functions
|
|
98
98
|
skipBlankLines: true,
|
|
99
99
|
skipComments: true,
|
|
100
100
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
"type": "module",
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
"./tests": "./src/tests/index.js"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
|
-
"test": "node
|
|
13
|
-
"test:unit": "node
|
|
14
|
-
"test:coverage": "node
|
|
15
|
-
"test:e2e": "RUN_E2E=true
|
|
16
|
-
"test:e2e:playwright": "RUN_E2E=true
|
|
17
|
-
"test:e2e:puppeteer": "RUN_E2E=true
|
|
12
|
+
"test": "node scripts/run-tests.mjs --reporter spec tests/unit",
|
|
13
|
+
"test:unit": "node scripts/run-tests.mjs --reporter spec tests/unit",
|
|
14
|
+
"test:coverage": "node scripts/run-tests.mjs --coverage tests/unit",
|
|
15
|
+
"test:e2e": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e",
|
|
16
|
+
"test:e2e:playwright": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/playwright.e2e.test.js",
|
|
17
|
+
"test:e2e:puppeteer": "node scripts/run-tests.mjs --env RUN_E2E=true tests/e2e/puppeteer.e2e.test.js",
|
|
18
18
|
"test:all": "npm run test:unit && npm run test:e2e",
|
|
19
19
|
"lint": "eslint .",
|
|
20
20
|
"lint:fix": "eslint . --fix",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export function removeDeprecatedAlwaysAuth(configPath) {
|
|
7
|
+
if (!configPath || !existsSync(configPath)) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const config = readFileSync(configPath, 'utf8');
|
|
12
|
+
const lines = config.split(/\r?\n/);
|
|
13
|
+
const filteredLines = lines.filter(
|
|
14
|
+
(line) => !/^\s*always-auth\s*=/.test(line)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
if (filteredLines.length === lines.length) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
writeFileSync(configPath, filteredLines.join('\n'));
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function main() {
|
|
26
|
+
const removed = removeDeprecatedAlwaysAuth(process.env.NPM_CONFIG_USERCONFIG);
|
|
27
|
+
if (removed) {
|
|
28
|
+
console.log('Removed deprecated always-auth from npm user config.');
|
|
29
|
+
} else {
|
|
30
|
+
console.log('No deprecated npm always-auth config found.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
process.argv[1] &&
|
|
36
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
37
|
+
) {
|
|
38
|
+
main();
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export function formatRegistryPackagePath(packageName) {
|
|
2
|
+
return encodeURIComponent(packageName);
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export async function isVersionPublished(
|
|
6
|
+
packageName,
|
|
7
|
+
version,
|
|
8
|
+
fetchFn = fetch
|
|
9
|
+
) {
|
|
10
|
+
const packagePath = formatRegistryPackagePath(packageName);
|
|
11
|
+
const versionPath = encodeURIComponent(version);
|
|
12
|
+
const response = await fetchFn(
|
|
13
|
+
`https://registry.npmjs.org/${packagePath}/${versionPath}`,
|
|
14
|
+
{
|
|
15
|
+
headers: { accept: 'application/json' },
|
|
16
|
+
}
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
if (response.status === 404) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (!response.ok) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Failed to check npm registry for ${packageName}@${version}: ${response.status} ${response.statusText}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const metadata = await response.json();
|
|
30
|
+
return metadata?.version === version;
|
|
31
|
+
}
|
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
|
|
14
14
|
import { readFileSync, appendFileSync } from 'fs';
|
|
15
15
|
|
|
16
|
+
import { isVersionPublished } from './npm-registry.mjs';
|
|
17
|
+
|
|
16
18
|
const PACKAGE_NAME = 'browser-commander';
|
|
17
19
|
|
|
18
20
|
// Load use-m dynamically
|
|
@@ -74,26 +76,23 @@ async function main() {
|
|
|
74
76
|
console.log(
|
|
75
77
|
`Checking if version ${currentVersion} is already published...`
|
|
76
78
|
);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Exit code 0 means version exists, non-zero means version not found
|
|
84
|
-
if (checkResult.code === 0) {
|
|
79
|
+
const versionIsPublished = await isVersionPublished(
|
|
80
|
+
PACKAGE_NAME,
|
|
81
|
+
currentVersion
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
if (versionIsPublished) {
|
|
85
85
|
console.log(`Version ${currentVersion} is already published to npm`);
|
|
86
86
|
setOutput('published', 'true');
|
|
87
87
|
setOutput('published_version', currentVersion);
|
|
88
88
|
setOutput('already_published', 'true');
|
|
89
89
|
return;
|
|
90
|
-
} else {
|
|
91
|
-
// Version not found on npm (E404), proceed with publish
|
|
92
|
-
console.log(
|
|
93
|
-
`Version ${currentVersion} not found on npm, proceeding with publish...`
|
|
94
|
-
);
|
|
95
90
|
}
|
|
96
91
|
|
|
92
|
+
console.log(
|
|
93
|
+
`Version ${currentVersion} not found on npm, proceeding with publish...`
|
|
94
|
+
);
|
|
95
|
+
|
|
97
96
|
// Publish to npm using OIDC trusted publishing with retry logic
|
|
98
97
|
for (let i = 1; i <= MAX_RETRIES; i++) {
|
|
99
98
|
console.log(`Publish attempt ${i} of ${MAX_RETRIES}...`);
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve, relative, sep } from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
const TEST_FILE_PATTERN = /\.test\.[cm]?js$/u;
|
|
7
|
+
|
|
8
|
+
function readOptionValue(argv, index, optionName) {
|
|
9
|
+
const value = argv[index + 1];
|
|
10
|
+
|
|
11
|
+
if (!value || value.startsWith('--')) {
|
|
12
|
+
throw new Error(`${optionName} requires a value`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseEnvAssignment(assignment) {
|
|
19
|
+
const separatorIndex = assignment.indexOf('=');
|
|
20
|
+
|
|
21
|
+
if (separatorIndex <= 0) {
|
|
22
|
+
throw new Error(`Invalid environment override: ${assignment}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
name: assignment.slice(0, separatorIndex),
|
|
27
|
+
value: assignment.slice(separatorIndex + 1),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseTestRunnerArgs(argv) {
|
|
32
|
+
const options = {
|
|
33
|
+
coverage: false,
|
|
34
|
+
env: {},
|
|
35
|
+
reporter: null,
|
|
36
|
+
targets: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
40
|
+
const arg = argv[index];
|
|
41
|
+
|
|
42
|
+
if (arg === '--coverage') {
|
|
43
|
+
options.coverage = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (arg === '--reporter') {
|
|
48
|
+
options.reporter = readOptionValue(argv, index, '--reporter');
|
|
49
|
+
index += 1;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (arg === '--env') {
|
|
54
|
+
const { name, value } = parseEnvAssignment(
|
|
55
|
+
readOptionValue(argv, index, '--env')
|
|
56
|
+
);
|
|
57
|
+
options.env[name] = value;
|
|
58
|
+
index += 1;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (arg.startsWith('--env=')) {
|
|
63
|
+
const { name, value } = parseEnvAssignment(arg.slice('--env='.length));
|
|
64
|
+
options.env[name] = value;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
options.targets.push(arg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.targets.length === 0) {
|
|
72
|
+
throw new Error('At least one test file or directory is required');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return options;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeForSort(filePath) {
|
|
79
|
+
return filePath.split(sep).join('/');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isTestFile(filePath) {
|
|
83
|
+
return TEST_FILE_PATTERN.test(filePath);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listFilesRecursively(directory) {
|
|
87
|
+
const entries = readdirSync(directory, { withFileTypes: true });
|
|
88
|
+
const files = [];
|
|
89
|
+
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
const entryPath = resolve(directory, entry.name);
|
|
92
|
+
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
files.push(...listFilesRecursively(entryPath));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (entry.isFile() && isTestFile(entryPath)) {
|
|
99
|
+
files.push(entryPath);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return files;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveTestFiles(targets, cwd = process.cwd()) {
|
|
107
|
+
const files = [];
|
|
108
|
+
|
|
109
|
+
for (const target of targets) {
|
|
110
|
+
const absoluteTarget = resolve(cwd, target);
|
|
111
|
+
|
|
112
|
+
if (!existsSync(absoluteTarget)) {
|
|
113
|
+
throw new Error(`Test target does not exist: ${target}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const targetStats = statSync(absoluteTarget);
|
|
117
|
+
|
|
118
|
+
if (targetStats.isDirectory()) {
|
|
119
|
+
files.push(...listFilesRecursively(absoluteTarget));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (targetStats.isFile() && isTestFile(absoluteTarget)) {
|
|
124
|
+
files.push(absoluteTarget);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const uniqueFiles = [...new Set(files)];
|
|
129
|
+
uniqueFiles.sort((left, right) =>
|
|
130
|
+
normalizeForSort(left).localeCompare(normalizeForSort(right))
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
if (uniqueFiles.length === 0) {
|
|
134
|
+
throw new Error(`No test files found in: ${targets.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return uniqueFiles.map((file) => relative(cwd, file));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function buildNodeTestArgs(options, testFiles) {
|
|
141
|
+
const args = ['--test'];
|
|
142
|
+
|
|
143
|
+
if (options.coverage) {
|
|
144
|
+
args.push('--experimental-test-coverage');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (options.reporter) {
|
|
148
|
+
args.push('--test-reporter', options.reporter);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
args.push(...testFiles);
|
|
152
|
+
return args;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function runNodeTests(options, testFiles) {
|
|
156
|
+
const result = spawnSync(
|
|
157
|
+
process.execPath,
|
|
158
|
+
buildNodeTestArgs(options, testFiles),
|
|
159
|
+
{
|
|
160
|
+
env: { ...process.env, ...options.env },
|
|
161
|
+
stdio: 'inherit',
|
|
162
|
+
}
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
if (result.error) {
|
|
166
|
+
throw result.error;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (result.signal) {
|
|
170
|
+
throw new Error(`Node test runner terminated by signal ${result.signal}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result.status ?? 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function main(argv = process.argv.slice(2)) {
|
|
177
|
+
const options = parseTestRunnerArgs(argv);
|
|
178
|
+
const testFiles = resolveTestFiles(options.targets);
|
|
179
|
+
return runNodeTests(options, testFiles);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (
|
|
183
|
+
process.argv[1] &&
|
|
184
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
185
|
+
) {
|
|
186
|
+
try {
|
|
187
|
+
process.exitCode = main();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(error.message);
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/scripts/setup-npm.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Update npm for OIDC trusted publishing
|
|
5
5
|
* npm trusted publishing requires npm >= 11.5.1
|
|
6
|
-
*
|
|
6
|
+
* Keep the runner npm version current enough for OIDC publishing
|
|
7
7
|
*
|
|
8
8
|
* Uses link-foundation libraries:
|
|
9
9
|
* - use-m: Dynamic package loading without package.json dependencies
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
function setBrowserSelectionOptions(options, { channel, executablePath }) {
|
|
2
|
+
if (channel !== undefined) {
|
|
3
|
+
options.channel = channel;
|
|
4
|
+
}
|
|
5
|
+
if (executablePath !== undefined) {
|
|
6
|
+
options.executablePath = executablePath;
|
|
7
|
+
}
|
|
8
|
+
return options;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildPlaywrightLaunchOptions({
|
|
12
|
+
headless,
|
|
13
|
+
slowMo,
|
|
14
|
+
chromeArgs,
|
|
15
|
+
colorScheme,
|
|
16
|
+
channel,
|
|
17
|
+
executablePath,
|
|
18
|
+
}) {
|
|
19
|
+
const options = {
|
|
20
|
+
headless,
|
|
21
|
+
slowMo,
|
|
22
|
+
chromiumSandbox: true,
|
|
23
|
+
viewport: null,
|
|
24
|
+
args: chromeArgs,
|
|
25
|
+
ignoreDefaultArgs: ['--enable-automation'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
if (colorScheme !== undefined) {
|
|
29
|
+
options.colorScheme = colorScheme;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return setBrowserSelectionOptions(options, { channel, executablePath });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function buildPuppeteerLaunchOptions({
|
|
36
|
+
headless,
|
|
37
|
+
chromeArgs,
|
|
38
|
+
userDataDir,
|
|
39
|
+
channel,
|
|
40
|
+
executablePath,
|
|
41
|
+
}) {
|
|
42
|
+
return setBrowserSelectionOptions(
|
|
43
|
+
{
|
|
44
|
+
headless,
|
|
45
|
+
defaultViewport: null,
|
|
46
|
+
args: ['--start-maximized', ...chromeArgs],
|
|
47
|
+
userDataDir,
|
|
48
|
+
},
|
|
49
|
+
{ channel, executablePath }
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/browser/launcher.js
CHANGED
|
@@ -3,6 +3,10 @@ import os from 'os';
|
|
|
3
3
|
import { CHROME_ARGS } from '../core/constants.js';
|
|
4
4
|
import { disableTranslateInPreferences } from '../core/preferences.js';
|
|
5
5
|
import { emulateMedia } from './media.js';
|
|
6
|
+
import {
|
|
7
|
+
buildPlaywrightLaunchOptions,
|
|
8
|
+
buildPuppeteerLaunchOptions,
|
|
9
|
+
} from './launch-options.js';
|
|
6
10
|
|
|
7
11
|
/**
|
|
8
12
|
* Launch browser with default configuration
|
|
@@ -14,6 +18,8 @@ import { emulateMedia } from './media.js';
|
|
|
14
18
|
* @param {boolean} options.verbose - Enable verbose logging (default: false)
|
|
15
19
|
* @param {string[]} options.args - Custom Chrome arguments to append to the default CHROME_ARGS
|
|
16
20
|
* @param {string|null} [options.colorScheme] - Emulate color scheme: 'light', 'dark', 'no-preference', or null to reset
|
|
21
|
+
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', or 'msedge'
|
|
22
|
+
* @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
|
|
17
23
|
* @returns {Promise<Object>} - Object with browser and page
|
|
18
24
|
*/
|
|
19
25
|
export async function launchBrowser(options = {}) {
|
|
@@ -25,6 +31,8 @@ export async function launchBrowser(options = {}) {
|
|
|
25
31
|
verbose = false,
|
|
26
32
|
args = [],
|
|
27
33
|
colorScheme,
|
|
34
|
+
channel,
|
|
35
|
+
executablePath,
|
|
28
36
|
} = options;
|
|
29
37
|
|
|
30
38
|
// Combine default CHROME_ARGS with custom args
|
|
@@ -53,18 +61,14 @@ export async function launchBrowser(options = {}) {
|
|
|
53
61
|
|
|
54
62
|
if (engine === 'playwright') {
|
|
55
63
|
const { chromium } = await import('playwright');
|
|
56
|
-
const contextOptions = {
|
|
64
|
+
const contextOptions = buildPlaywrightLaunchOptions({
|
|
57
65
|
headless,
|
|
58
66
|
slowMo,
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
};
|
|
64
|
-
// Playwright supports colorScheme as a context-level launch option
|
|
65
|
-
if (colorScheme !== undefined) {
|
|
66
|
-
contextOptions.colorScheme = colorScheme;
|
|
67
|
-
}
|
|
67
|
+
chromeArgs,
|
|
68
|
+
colorScheme,
|
|
69
|
+
channel,
|
|
70
|
+
executablePath,
|
|
71
|
+
});
|
|
68
72
|
browser = await chromium.launchPersistentContext(
|
|
69
73
|
userDataDir,
|
|
70
74
|
contextOptions
|
|
@@ -72,12 +76,15 @@ export async function launchBrowser(options = {}) {
|
|
|
72
76
|
page = browser.pages()[0];
|
|
73
77
|
} else {
|
|
74
78
|
const puppeteer = await import('puppeteer');
|
|
75
|
-
browser = await puppeteer.default.launch(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
browser = await puppeteer.default.launch(
|
|
80
|
+
buildPuppeteerLaunchOptions({
|
|
81
|
+
headless,
|
|
82
|
+
chromeArgs,
|
|
83
|
+
userDataDir,
|
|
84
|
+
channel,
|
|
85
|
+
executablePath,
|
|
86
|
+
})
|
|
87
|
+
);
|
|
81
88
|
const pages = await browser.pages();
|
|
82
89
|
page = pages[0];
|
|
83
90
|
}
|
|
@@ -25,7 +25,7 @@ export async function defaultNavigationVerification(options = {}) {
|
|
|
25
25
|
const { page, expectedUrl, startUrl } = options;
|
|
26
26
|
|
|
27
27
|
try {
|
|
28
|
-
const actualUrl = page.url();
|
|
28
|
+
const actualUrl = await Promise.resolve(page.url());
|
|
29
29
|
|
|
30
30
|
// If expected URL is provided, verify it matches
|
|
31
31
|
if (expectedUrl) {
|
|
@@ -232,7 +232,7 @@ export async function goto(options = {}) {
|
|
|
232
232
|
waitUntil = 'domcontentloaded',
|
|
233
233
|
waitForStableUrlBefore = true,
|
|
234
234
|
waitForStableUrlAfter = true,
|
|
235
|
-
waitForNetworkIdle = true,
|
|
235
|
+
waitForNetworkIdle: _waitForNetworkIdle = true,
|
|
236
236
|
stableChecks = 3,
|
|
237
237
|
checkInterval = 1000,
|
|
238
238
|
timeout = 240000,
|
|
@@ -395,7 +395,6 @@ export async function waitForNavigation(options = {}) {
|
|
|
395
395
|
*/
|
|
396
396
|
export async function waitForPageReady(options = {}) {
|
|
397
397
|
const {
|
|
398
|
-
page,
|
|
399
398
|
navigationManager,
|
|
400
399
|
networkTracker,
|
|
401
400
|
log,
|
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
|
|
14
14
|
import { TIMING } from './constants.js';
|
|
15
15
|
|
|
16
|
+
function notImplemented(methodName) {
|
|
17
|
+
return Promise.reject(
|
|
18
|
+
new Error(`${methodName}() must be implemented by subclass`)
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
16
22
|
/**
|
|
17
23
|
* Base class defining the engine adapter interface
|
|
18
24
|
* All engine-specific operations should be defined here
|
|
@@ -39,7 +45,7 @@ export class EngineAdapter {
|
|
|
39
45
|
* @param {string} selector - CSS selector
|
|
40
46
|
* @returns {Object} - Locator (Playwright) or ElementHandle (Puppeteer)
|
|
41
47
|
*/
|
|
42
|
-
createLocator(
|
|
48
|
+
createLocator(_selector) {
|
|
43
49
|
throw new Error('createLocator() must be implemented by subclass');
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -48,8 +54,8 @@ export class EngineAdapter {
|
|
|
48
54
|
* @param {string} selector - CSS selector
|
|
49
55
|
* @returns {Promise<Object|null>} - Locator/Element or null
|
|
50
56
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
querySelector(_selector) {
|
|
58
|
+
return notImplemented('querySelector');
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
/**
|
|
@@ -57,8 +63,8 @@ export class EngineAdapter {
|
|
|
57
63
|
* @param {string} selector - CSS selector
|
|
58
64
|
* @returns {Promise<Array>} - Array of locators/elements
|
|
59
65
|
*/
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
querySelectorAll(_selector) {
|
|
67
|
+
return notImplemented('querySelectorAll');
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/**
|
|
@@ -67,8 +73,8 @@ export class EngineAdapter {
|
|
|
67
73
|
* @param {Object} options - Wait options {visible, timeout}
|
|
68
74
|
* @returns {Promise<void>}
|
|
69
75
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
waitForSelector(_selector, _options = {}) {
|
|
77
|
+
return notImplemented('waitForSelector');
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -77,8 +83,8 @@ export class EngineAdapter {
|
|
|
77
83
|
* @param {number} timeout - Timeout in ms
|
|
78
84
|
* @returns {Promise<Object>} - The locator/element
|
|
79
85
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
86
|
+
waitForVisible(_locatorOrElement, _timeout = TIMING.DEFAULT_TIMEOUT) {
|
|
87
|
+
return notImplemented('waitForVisible');
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
/**
|
|
@@ -86,8 +92,8 @@ export class EngineAdapter {
|
|
|
86
92
|
* @param {string} selector - CSS selector
|
|
87
93
|
* @returns {Promise<number>} - Number of matching elements
|
|
88
94
|
*/
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
count(_selector) {
|
|
96
|
+
return notImplemented('count');
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
// ============================================================================
|
|
@@ -101,8 +107,8 @@ export class EngineAdapter {
|
|
|
101
107
|
* @param {any} args - Arguments to pass (optional)
|
|
102
108
|
* @returns {Promise<any>} - Result of evaluation
|
|
103
109
|
*/
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
evaluateOnElement(_locatorOrElement, _fn, _args) {
|
|
111
|
+
return notImplemented('evaluateOnElement');
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
/**
|
|
@@ -110,8 +116,8 @@ export class EngineAdapter {
|
|
|
110
116
|
* @param {Object} locatorOrElement - Locator or element
|
|
111
117
|
* @returns {Promise<string|null>} - Text content
|
|
112
118
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
getTextContent(_locatorOrElement) {
|
|
120
|
+
return notImplemented('getTextContent');
|
|
115
121
|
}
|
|
116
122
|
|
|
117
123
|
/**
|
|
@@ -119,8 +125,8 @@ export class EngineAdapter {
|
|
|
119
125
|
* @param {Object} locatorOrElement - Locator or element
|
|
120
126
|
* @returns {Promise<string>} - Input value
|
|
121
127
|
*/
|
|
122
|
-
|
|
123
|
-
|
|
128
|
+
getInputValue(_locatorOrElement) {
|
|
129
|
+
return notImplemented('getInputValue');
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
/**
|
|
@@ -129,8 +135,8 @@ export class EngineAdapter {
|
|
|
129
135
|
* @param {string} attribute - Attribute name
|
|
130
136
|
* @returns {Promise<string|null>} - Attribute value
|
|
131
137
|
*/
|
|
132
|
-
|
|
133
|
-
|
|
138
|
+
getAttribute(_locatorOrElement, _attribute) {
|
|
139
|
+
return notImplemented('getAttribute');
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
// ============================================================================
|
|
@@ -143,8 +149,8 @@ export class EngineAdapter {
|
|
|
143
149
|
* @param {Object} options - Click options {force, etc.}
|
|
144
150
|
* @returns {Promise<void>}
|
|
145
151
|
*/
|
|
146
|
-
|
|
147
|
-
|
|
152
|
+
click(_locatorOrElement, _options = {}) {
|
|
153
|
+
return notImplemented('click');
|
|
148
154
|
}
|
|
149
155
|
|
|
150
156
|
/**
|
|
@@ -153,8 +159,8 @@ export class EngineAdapter {
|
|
|
153
159
|
* @param {string} text - Text to type
|
|
154
160
|
* @returns {Promise<void>}
|
|
155
161
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
162
|
+
type(_locatorOrElement, _text) {
|
|
163
|
+
return notImplemented('type');
|
|
158
164
|
}
|
|
159
165
|
|
|
160
166
|
/**
|
|
@@ -163,8 +169,8 @@ export class EngineAdapter {
|
|
|
163
169
|
* @param {string} text - Text to fill
|
|
164
170
|
* @returns {Promise<void>}
|
|
165
171
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
fill(_locatorOrElement, _text) {
|
|
173
|
+
return notImplemented('fill');
|
|
168
174
|
}
|
|
169
175
|
|
|
170
176
|
/**
|
|
@@ -172,8 +178,8 @@ export class EngineAdapter {
|
|
|
172
178
|
* @param {Object} locatorOrElement - Locator or element
|
|
173
179
|
* @returns {Promise<void>}
|
|
174
180
|
*/
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
focus(_locatorOrElement) {
|
|
182
|
+
return notImplemented('focus');
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
// ============================================================================
|
|
@@ -185,8 +191,8 @@ export class EngineAdapter {
|
|
|
185
191
|
* @param {string} key - Key name (Playwright/Puppeteer key format)
|
|
186
192
|
* @returns {Promise<void>}
|
|
187
193
|
*/
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
keyboardPress(_key) {
|
|
195
|
+
return notImplemented('keyboardPress');
|
|
190
196
|
}
|
|
191
197
|
|
|
192
198
|
/**
|
|
@@ -194,8 +200,8 @@ export class EngineAdapter {
|
|
|
194
200
|
* @param {string} text - Text to type
|
|
195
201
|
* @returns {Promise<void>}
|
|
196
202
|
*/
|
|
197
|
-
|
|
198
|
-
|
|
203
|
+
keyboardType(_text) {
|
|
204
|
+
return notImplemented('keyboardType');
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
/**
|
|
@@ -203,8 +209,8 @@ export class EngineAdapter {
|
|
|
203
209
|
* @param {string} key - Key name
|
|
204
210
|
* @returns {Promise<void>}
|
|
205
211
|
*/
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
keyboardDown(_key) {
|
|
213
|
+
return notImplemented('keyboardDown');
|
|
208
214
|
}
|
|
209
215
|
|
|
210
216
|
/**
|
|
@@ -212,8 +218,8 @@ export class EngineAdapter {
|
|
|
212
218
|
* @param {string} key - Key name
|
|
213
219
|
* @returns {Promise<void>}
|
|
214
220
|
*/
|
|
215
|
-
|
|
216
|
-
|
|
221
|
+
keyboardUp(_key) {
|
|
222
|
+
return notImplemented('keyboardUp');
|
|
217
223
|
}
|
|
218
224
|
|
|
219
225
|
// ============================================================================
|
|
@@ -226,8 +232,8 @@ export class EngineAdapter {
|
|
|
226
232
|
* @param {Array} args - Arguments to pass
|
|
227
233
|
* @returns {Promise<any>} - Result of evaluation
|
|
228
234
|
*/
|
|
229
|
-
|
|
230
|
-
|
|
235
|
+
evaluateOnPage(_fn, _args = []) {
|
|
236
|
+
return notImplemented('evaluateOnPage');
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
/**
|
|
@@ -243,8 +249,8 @@ export class EngineAdapter {
|
|
|
243
249
|
* @param {Object} options - PDF options (format, printBackground, margin, etc.)
|
|
244
250
|
* @returns {Promise<Buffer>} - PDF buffer
|
|
245
251
|
*/
|
|
246
|
-
|
|
247
|
-
|
|
252
|
+
pdf(_options = {}) {
|
|
253
|
+
return notImplemented('pdf');
|
|
248
254
|
}
|
|
249
255
|
}
|
|
250
256
|
|
|
@@ -435,7 +441,7 @@ export class PuppeteerAdapter extends EngineAdapter {
|
|
|
435
441
|
await this.page.waitForSelector(selector, { visible, timeout });
|
|
436
442
|
}
|
|
437
443
|
|
|
438
|
-
|
|
444
|
+
waitForVisible(locatorOrElement, _timeout = TIMING.DEFAULT_TIMEOUT) {
|
|
439
445
|
// For Puppeteer, locatorOrElement is already an ElementHandle
|
|
440
446
|
// We can't wait on it directly, so we just return it
|
|
441
447
|
// The caller should have already used waitForSelector
|
|
@@ -209,7 +209,6 @@ export function createNetworkTracker(options = {}) {
|
|
|
209
209
|
async function waitForNetworkIdle(opts = {}) {
|
|
210
210
|
const { timeout = 30000, idleTime = idleTimeout } = opts;
|
|
211
211
|
|
|
212
|
-
const startTime = Date.now();
|
|
213
212
|
const currentNavId = navigationId;
|
|
214
213
|
|
|
215
214
|
// If already idle, wait for idle time
|
|
@@ -317,7 +316,7 @@ export function createNetworkTracker(options = {}) {
|
|
|
317
316
|
*/
|
|
318
317
|
function getPendingUrls() {
|
|
319
318
|
const urls = [];
|
|
320
|
-
for (const
|
|
319
|
+
for (const req of pendingRequests.values()) {
|
|
321
320
|
const url = typeof req.url === 'function' ? req.url() : req.url;
|
|
322
321
|
urls.push(url);
|
|
323
322
|
}
|
package/src/core/page-session.js
CHANGED
|
@@ -65,7 +65,7 @@ export function createPageSessionFactory(options = {}) {
|
|
|
65
65
|
/**
|
|
66
66
|
* Handle navigation - deactivate session if URL no longer matches
|
|
67
67
|
*/
|
|
68
|
-
function handleUrlChange({
|
|
68
|
+
function handleUrlChange({ newUrl }) {
|
|
69
69
|
if (!isActive) {
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
@@ -115,7 +115,7 @@ export function createPageSessionFactory(options = {}) {
|
|
|
115
115
|
for (const { target, event, handler } of eventListeners) {
|
|
116
116
|
try {
|
|
117
117
|
target.off(event, handler);
|
|
118
|
-
} catch
|
|
118
|
+
} catch {
|
|
119
119
|
// Ignore removal errors
|
|
120
120
|
}
|
|
121
121
|
}
|
|
@@ -166,7 +166,7 @@ export function createPageSessionFactory(options = {}) {
|
|
|
166
166
|
log.debug(() => `⏭️ Skipping action - session "${name}" is inactive`);
|
|
167
167
|
return null;
|
|
168
168
|
}
|
|
169
|
-
return fn();
|
|
169
|
+
return await fn();
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/**
|
|
@@ -214,7 +214,7 @@ export function createPageSessionFactory(options = {}) {
|
|
|
214
214
|
if (!networkTracker) {
|
|
215
215
|
return true;
|
|
216
216
|
}
|
|
217
|
-
return networkTracker.waitForNetworkIdle(opts);
|
|
217
|
+
return await networkTracker.waitForNetworkIdle(opts);
|
|
218
218
|
}
|
|
219
219
|
|
|
220
220
|
/**
|
|
@@ -224,7 +224,7 @@ export function createPageSessionFactory(options = {}) {
|
|
|
224
224
|
if (!isActive) {
|
|
225
225
|
return false;
|
|
226
226
|
}
|
|
227
|
-
return navigationManager.waitForPageReady(opts);
|
|
227
|
+
return await navigationManager.waitForPageReady(opts);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
@@ -310,7 +310,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
310
310
|
* @param {string} url - URL to start action for
|
|
311
311
|
* @param {Object} commander - BrowserCommander instance
|
|
312
312
|
*/
|
|
313
|
-
|
|
313
|
+
function startAction(url, commander) {
|
|
314
314
|
// Create context for condition checking
|
|
315
315
|
const conditionCtx = {
|
|
316
316
|
url,
|
|
@@ -321,7 +321,7 @@ export function createPageTriggerManager(options = {}) {
|
|
|
321
321
|
const matchingTrigger = findMatchingTrigger(conditionCtx);
|
|
322
322
|
if (!matchingTrigger) {
|
|
323
323
|
log.debug(() => `📋 No trigger registered for: ${url}`);
|
|
324
|
-
return;
|
|
324
|
+
return Promise.resolve();
|
|
325
325
|
}
|
|
326
326
|
|
|
327
327
|
log.debug(() => `🚀 Starting action "${matchingTrigger.name}" for: ${url}`);
|
|
@@ -369,6 +369,8 @@ export function createPageTriggerManager(options = {}) {
|
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
371
|
})();
|
|
372
|
+
|
|
373
|
+
return Promise.resolve();
|
|
372
374
|
}
|
|
373
375
|
|
|
374
376
|
/**
|
|
@@ -92,16 +92,16 @@ export async function findByText(options = {}) {
|
|
|
92
92
|
if (engine === 'playwright') {
|
|
93
93
|
// Playwright supports :has-text() natively
|
|
94
94
|
const textSelector = exact ? `:text-is("${text}")` : `:has-text("${text}")`;
|
|
95
|
-
return `${selector}${textSelector}
|
|
95
|
+
return await Promise.resolve(`${selector}${textSelector}`);
|
|
96
96
|
} else {
|
|
97
97
|
// For Puppeteer, we need to use XPath or evaluate
|
|
98
98
|
// Return a special selector marker that will be handled by other methods
|
|
99
|
-
return {
|
|
99
|
+
return await Promise.resolve({
|
|
100
100
|
_isPuppeteerTextSelector: true,
|
|
101
101
|
baseSelector: selector,
|
|
102
102
|
text,
|
|
103
103
|
exact,
|
|
104
|
-
};
|
|
104
|
+
});
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -257,7 +257,7 @@ export async function clickElement(options = {}) {
|
|
|
257
257
|
* @param {Function} options.log - Logger instance
|
|
258
258
|
* @returns {Promise<{navigated: boolean, newUrl: string}>}
|
|
259
259
|
*/
|
|
260
|
-
|
|
260
|
+
function detectNavigation(options = {}) {
|
|
261
261
|
const { page, navigationManager, startUrl, log } = options;
|
|
262
262
|
|
|
263
263
|
const currentUrl = page.url();
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildPlaywrightLaunchOptions,
|
|
6
|
+
buildPuppeteerLaunchOptions,
|
|
7
|
+
} from '../../../src/browser/launch-options.js';
|
|
8
|
+
|
|
9
|
+
describe('browser launch options', () => {
|
|
10
|
+
it('forwards channel and executablePath to Playwright', () => {
|
|
11
|
+
const options = buildPlaywrightLaunchOptions({
|
|
12
|
+
headless: true,
|
|
13
|
+
slowMo: 25,
|
|
14
|
+
chromeArgs: ['--custom'],
|
|
15
|
+
channel: 'chrome-beta',
|
|
16
|
+
executablePath: '/opt/google/chrome-beta',
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
assert.equal(options.channel, 'chrome-beta');
|
|
20
|
+
assert.equal(options.executablePath, '/opt/google/chrome-beta');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('forwards channel and executablePath to Puppeteer', () => {
|
|
24
|
+
const options = buildPuppeteerLaunchOptions({
|
|
25
|
+
headless: true,
|
|
26
|
+
chromeArgs: ['--custom'],
|
|
27
|
+
userDataDir: '/tmp/browser-commander-test',
|
|
28
|
+
channel: 'chrome',
|
|
29
|
+
executablePath: '/usr/bin/google-chrome',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
assert.equal(options.channel, 'chrome');
|
|
33
|
+
assert.equal(options.executablePath, '/usr/bin/google-chrome');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('omits browser selection options by default', () => {
|
|
37
|
+
const playwright = buildPlaywrightLaunchOptions({
|
|
38
|
+
headless: false,
|
|
39
|
+
slowMo: 150,
|
|
40
|
+
chromeArgs: [],
|
|
41
|
+
});
|
|
42
|
+
const puppeteer = buildPuppeteerLaunchOptions({
|
|
43
|
+
headless: false,
|
|
44
|
+
chromeArgs: [],
|
|
45
|
+
userDataDir: '/tmp/browser-commander-test',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
assert.equal('channel' in playwright, false);
|
|
49
|
+
assert.equal('executablePath' in playwright, false);
|
|
50
|
+
assert.equal('channel' in puppeteer, false);
|
|
51
|
+
assert.equal('executablePath' in puppeteer, false);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
mkdtempSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
rmSync,
|
|
6
|
+
writeFileSync,
|
|
7
|
+
} from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
|
|
13
|
+
import { removeDeprecatedAlwaysAuth } from '../../../scripts/clean-npm-config.mjs';
|
|
14
|
+
import {
|
|
15
|
+
formatRegistryPackagePath,
|
|
16
|
+
isVersionPublished,
|
|
17
|
+
} from '../../../scripts/npm-registry.mjs';
|
|
18
|
+
import {
|
|
19
|
+
buildNodeTestArgs,
|
|
20
|
+
parseTestRunnerArgs,
|
|
21
|
+
resolveTestFiles,
|
|
22
|
+
} from '../../../scripts/run-tests.mjs';
|
|
23
|
+
|
|
24
|
+
describe('npm release helpers', () => {
|
|
25
|
+
describe('removeDeprecatedAlwaysAuth', () => {
|
|
26
|
+
it('removes always-auth while preserving other config', () => {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'browser-commander-npmrc-'));
|
|
28
|
+
const npmrc = join(dir, '.npmrc');
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
writeFileSync(
|
|
32
|
+
npmrc,
|
|
33
|
+
[
|
|
34
|
+
'//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}',
|
|
35
|
+
'always-auth=true',
|
|
36
|
+
'registry=https://registry.npmjs.org/',
|
|
37
|
+
'',
|
|
38
|
+
].join('\n')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
assert.equal(removeDeprecatedAlwaysAuth(npmrc), true);
|
|
42
|
+
assert.equal(
|
|
43
|
+
readFileSync(npmrc, 'utf8'),
|
|
44
|
+
[
|
|
45
|
+
'//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}',
|
|
46
|
+
'registry=https://registry.npmjs.org/',
|
|
47
|
+
'',
|
|
48
|
+
].join('\n')
|
|
49
|
+
);
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(dir, { force: true, recursive: true });
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns false when no config path exists', () => {
|
|
56
|
+
assert.equal(removeDeprecatedAlwaysAuth(''), false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('isVersionPublished', () => {
|
|
61
|
+
it('returns false for npm registry 404 responses without logging npm errors', async () => {
|
|
62
|
+
const fetchCalls = [];
|
|
63
|
+
const fetchFn = async (url) => {
|
|
64
|
+
fetchCalls.push(url);
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 404,
|
|
68
|
+
statusText: 'Not Found',
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const published = await isVersionPublished(
|
|
73
|
+
'browser-commander',
|
|
74
|
+
'0.9.0',
|
|
75
|
+
fetchFn
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
assert.equal(published, false);
|
|
79
|
+
assert.deepEqual(fetchCalls, [
|
|
80
|
+
'https://registry.npmjs.org/browser-commander/0.9.0',
|
|
81
|
+
]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('handles scoped package names', () => {
|
|
85
|
+
assert.equal(
|
|
86
|
+
formatRegistryPackagePath('@scope/package'),
|
|
87
|
+
'%40scope%2Fpackage'
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('test runner helper', () => {
|
|
94
|
+
it('expands test directories into stable explicit test files', () => {
|
|
95
|
+
const dir = mkdtempSync(join(tmpdir(), 'browser-commander-tests-'));
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
writeFileSync(join(dir, 'root.test.js'), '');
|
|
99
|
+
writeFileSync(join(dir, 'ignored.js'), '');
|
|
100
|
+
|
|
101
|
+
const nestedDir = join(dir, 'nested');
|
|
102
|
+
mkdirSync(nestedDir);
|
|
103
|
+
writeFileSync(join(nestedDir, 'nested.test.js'), '');
|
|
104
|
+
|
|
105
|
+
assert.deepEqual(resolveTestFiles([dir], dir), [
|
|
106
|
+
join('nested', 'nested.test.js'),
|
|
107
|
+
'root.test.js',
|
|
108
|
+
]);
|
|
109
|
+
} finally {
|
|
110
|
+
rmSync(dir, { force: true, recursive: true });
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('builds node --test arguments with reporter and coverage options', () => {
|
|
115
|
+
const options = parseTestRunnerArgs([
|
|
116
|
+
'--coverage',
|
|
117
|
+
'--reporter',
|
|
118
|
+
'spec',
|
|
119
|
+
'--env',
|
|
120
|
+
'RUN_E2E=true',
|
|
121
|
+
'tests/unit',
|
|
122
|
+
]);
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(options, {
|
|
125
|
+
coverage: true,
|
|
126
|
+
env: { RUN_E2E: 'true' },
|
|
127
|
+
reporter: 'spec',
|
|
128
|
+
targets: ['tests/unit'],
|
|
129
|
+
});
|
|
130
|
+
assert.deepEqual(buildNodeTestArgs(options, ['tests/unit/a.test.js']), [
|
|
131
|
+
'--test',
|
|
132
|
+
'--experimental-test-coverage',
|
|
133
|
+
'--test-reporter',
|
|
134
|
+
'spec',
|
|
135
|
+
'tests/unit/a.test.js',
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|