browser-commander 0.11.0 → 0.12.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 +10 -0
- package/README.md +37 -0
- package/examples/pdf-generation.js +7 -4
- package/package.json +1 -1
- package/scripts/run-tests.mjs +19 -1
- package/src/README.md +13 -0
- package/src/bindings.js +8 -0
- package/src/browser/launcher.js +17 -0
- package/src/browser/navigation.js +58 -0
- package/src/browser/storage-state.js +133 -0
- package/src/core/navigation-manager.js +57 -24
- package/src/exports.js +2 -0
- package/tests/e2e/playwright.e2e.test.js +19 -0
- package/tests/e2e/puppeteer.e2e.test.js +19 -0
- package/tests/helpers/mocks.js +3 -0
- package/tests/unit/bindings.test.js +4 -0
- package/tests/unit/browser/navigation.test.js +61 -0
- package/tests/unit/browser/storage-state.test.js +148 -0
- package/tests/unit/core/navigation-manager.test.js +62 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +73 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +20 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Add `commander.setContent()` for safely loading in-memory HTML through the managed navigation lifecycle.
|
|
8
|
+
|
|
9
|
+
Add portable cookie and localStorage restoration through `launchBrowser({ storageState })` and the `saveStorageState()` helper for Playwright and Puppeteer.
|
|
10
|
+
|
|
11
|
+
Avoid intermittent Node 24 test-runner transport failures on macOS by running test files without child-process isolation.
|
|
12
|
+
|
|
3
13
|
## 0.11.0
|
|
4
14
|
|
|
5
15
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -82,6 +82,12 @@ commander.pageTrigger({
|
|
|
82
82
|
// 4. Navigate - action auto-starts when page is ready
|
|
83
83
|
await commander.goto({ url: 'https://example.com' });
|
|
84
84
|
|
|
85
|
+
// Or safely replace the document with in-memory HTML
|
|
86
|
+
await commander.setContent({
|
|
87
|
+
html: '<h1>Hi</h1>',
|
|
88
|
+
waitUntil: 'networkidle',
|
|
89
|
+
});
|
|
90
|
+
|
|
85
91
|
// 5. Cleanup
|
|
86
92
|
await commander.destroy();
|
|
87
93
|
await browser.close();
|
|
@@ -100,6 +106,22 @@ const { browser, page } = await launchBrowser({
|
|
|
100
106
|
});
|
|
101
107
|
```
|
|
102
108
|
|
|
109
|
+
Reuse a saved authenticated session by passing Playwright-compatible storage
|
|
110
|
+
state as a JSON file path or object. Cookies and localStorage are restored for
|
|
111
|
+
both engines:
|
|
112
|
+
|
|
113
|
+
```javascript
|
|
114
|
+
import { launchBrowser, saveStorageState } from 'browser-commander';
|
|
115
|
+
|
|
116
|
+
const { browser, page } = await launchBrowser({
|
|
117
|
+
engine: 'playwright',
|
|
118
|
+
storageState: './gmail-state.json',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Save the current session for a later launch.
|
|
122
|
+
await saveStorageState(page, './gmail-state.json');
|
|
123
|
+
```
|
|
124
|
+
|
|
103
125
|
## Browser Commander Tests
|
|
104
126
|
|
|
105
127
|
`browser-commander/tests` adds browser fixtures and scheduling helpers on top of
|
|
@@ -271,11 +293,26 @@ const { browser, page } = await launchBrowser({
|
|
|
271
293
|
slowMo: 150, // Slow down operations (ms)
|
|
272
294
|
verbose: false, // Enable debug logging
|
|
273
295
|
args: ['--no-sandbox', '--disable-setuid-sandbox'], // Custom Chrome args to append
|
|
296
|
+
storageState: './session-state.json', // Saved cookies and localStorage, as a path or object
|
|
274
297
|
});
|
|
275
298
|
```
|
|
276
299
|
|
|
277
300
|
The `args` option allows passing custom Chrome arguments, which is useful for headless server environments (Docker, CI/CD) that require flags like `--no-sandbox`.
|
|
278
301
|
|
|
302
|
+
The `storageState` option accepts a Playwright-compatible JSON path or object.
|
|
303
|
+
Each engine restores its cookies and origin-specific localStorage before
|
|
304
|
+
navigation, including when Playwright uses a persistent context.
|
|
305
|
+
|
|
306
|
+
### saveStorageState(page, filePath)
|
|
307
|
+
|
|
308
|
+
Save the current cookies and localStorage in Playwright's portable storage
|
|
309
|
+
state format. The helper supports pages from either engine and also returns the
|
|
310
|
+
saved object:
|
|
311
|
+
|
|
312
|
+
```javascript
|
|
313
|
+
const state = await saveStorageState(page, './session-state.json');
|
|
314
|
+
```
|
|
315
|
+
|
|
279
316
|
The `colorScheme` option allows setting the initial color scheme (`'light'`, `'dark'`, or `'no-preference'`) at launch time for screenshot services and testing tools:
|
|
280
317
|
|
|
281
318
|
```javascript
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Example: PDF generation via browser-commander
|
|
3
3
|
*
|
|
4
|
-
* This example shows how to
|
|
5
|
-
*
|
|
4
|
+
* This example shows how to load in-memory HTML and generate a PDF through the
|
|
5
|
+
* unified commander API.
|
|
6
6
|
*
|
|
7
7
|
* Supports both Playwright and Puppeteer engines.
|
|
8
8
|
*
|
|
@@ -45,8 +45,11 @@ async function generatePdf() {
|
|
|
45
45
|
try {
|
|
46
46
|
const commander = makeBrowserCommander({ page, verbose: false });
|
|
47
47
|
|
|
48
|
-
//
|
|
49
|
-
await
|
|
48
|
+
// Load HTML assembled in memory using the managed navigation lifecycle.
|
|
49
|
+
await commander.setContent({
|
|
50
|
+
html: '<main><h1>Browser Commander</h1><p>Generated in memory.</p></main>',
|
|
51
|
+
waitUntil: 'load',
|
|
52
|
+
});
|
|
50
53
|
|
|
51
54
|
// Generate PDF using the unified API (no workarounds needed!)
|
|
52
55
|
const pdfBuffer = await commander.pdf({
|
package/package.json
CHANGED
package/scripts/run-tests.mjs
CHANGED
|
@@ -137,9 +137,27 @@ export function resolveTestFiles(targets, cwd = process.cwd()) {
|
|
|
137
137
|
return uniqueFiles.map((file) => relative(cwd, file));
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
function supportsTestIsolation(nodeVersion) {
|
|
141
|
+
const majorVersion = Number.parseInt(nodeVersion.split('.')[0], 10);
|
|
142
|
+
return Number.isInteger(majorVersion) && majorVersion >= 24;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildNodeTestArgs(
|
|
146
|
+
options,
|
|
147
|
+
testFiles,
|
|
148
|
+
nodeVersion = process.versions.node
|
|
149
|
+
) {
|
|
141
150
|
const args = ['--test'];
|
|
142
151
|
|
|
152
|
+
// Node's default process isolation serializes each test file's results over
|
|
153
|
+
// a child-process channel. Node 24 on macOS can intermittently corrupt that
|
|
154
|
+
// stream (ERR_TEST_FAILURE: "Unable to deserialize cloned data"). Running
|
|
155
|
+
// supported runtimes in-process removes that transport while keeping the
|
|
156
|
+
// Node 20-compatible fallback for consumers on the minimum engine version.
|
|
157
|
+
if (supportsTestIsolation(nodeVersion)) {
|
|
158
|
+
args.push('--test-isolation=none');
|
|
159
|
+
}
|
|
160
|
+
|
|
143
161
|
if (options.coverage) {
|
|
144
162
|
args.push('--experimental-test-coverage');
|
|
145
163
|
}
|
package/src/README.md
CHANGED
|
@@ -417,6 +417,19 @@ await commander.goto({
|
|
|
417
417
|
});
|
|
418
418
|
```
|
|
419
419
|
|
|
420
|
+
### commander.setContent(options)
|
|
421
|
+
|
|
422
|
+
Load an in-memory HTML document while preserving the same page-trigger cleanup
|
|
423
|
+
and readiness guarantees as `goto()`:
|
|
424
|
+
|
|
425
|
+
```javascript
|
|
426
|
+
await commander.setContent({
|
|
427
|
+
html: '<h1>Hi</h1>',
|
|
428
|
+
waitUntil: 'networkidle', // Playwright/Puppeteer option
|
|
429
|
+
timeout: 60000,
|
|
430
|
+
});
|
|
431
|
+
```
|
|
432
|
+
|
|
420
433
|
### commander.clickButton(options)
|
|
421
434
|
|
|
422
435
|
```javascript
|
package/src/bindings.js
CHANGED
|
@@ -8,6 +8,7 @@ import { getUrl, unfocusAddressBar } from './utilities/url.js';
|
|
|
8
8
|
import {
|
|
9
9
|
waitForUrlStabilization,
|
|
10
10
|
goto,
|
|
11
|
+
setContent,
|
|
11
12
|
waitForNavigation,
|
|
12
13
|
waitForPageReady,
|
|
13
14
|
waitAfterAction,
|
|
@@ -115,6 +116,12 @@ export function createBoundFunctions(options = {}) {
|
|
|
115
116
|
waitForUrlStabilization: waitForUrlStabilizationBound,
|
|
116
117
|
navigationManager,
|
|
117
118
|
});
|
|
119
|
+
const setContentBound = (opts) =>
|
|
120
|
+
setContent({
|
|
121
|
+
...opts,
|
|
122
|
+
page,
|
|
123
|
+
navigationManager,
|
|
124
|
+
});
|
|
118
125
|
const waitForNavigationBound = (opts) =>
|
|
119
126
|
waitForNavigation({
|
|
120
127
|
...opts,
|
|
@@ -312,6 +319,7 @@ export function createBoundFunctions(options = {}) {
|
|
|
312
319
|
querySelectorAll: querySelectorAllBound,
|
|
313
320
|
waitForUrlStabilization: waitForUrlStabilizationBound,
|
|
314
321
|
goto: gotoBound,
|
|
322
|
+
setContent: setContentBound,
|
|
315
323
|
getUrl: getUrlBound,
|
|
316
324
|
waitForNavigation: waitForNavigationBound,
|
|
317
325
|
waitForPageReady: waitForPageReadyBound,
|
package/src/browser/launcher.js
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
buildPlaywrightLaunchOptions,
|
|
8
8
|
buildPuppeteerLaunchOptions,
|
|
9
9
|
} from './launch-options.js';
|
|
10
|
+
import {
|
|
11
|
+
loadStorageState,
|
|
12
|
+
restorePlaywrightStorageState,
|
|
13
|
+
restorePuppeteerStorageState,
|
|
14
|
+
} from './storage-state.js';
|
|
10
15
|
|
|
11
16
|
/**
|
|
12
17
|
* Launch browser with default configuration
|
|
@@ -20,6 +25,7 @@ import {
|
|
|
20
25
|
* @param {string|null} [options.colorScheme] - Emulate color scheme: 'light', 'dark', 'no-preference', or null to reset
|
|
21
26
|
* @param {string} [options.channel] - Installed browser channel, such as 'chrome', 'chrome-beta', or 'msedge'
|
|
22
27
|
* @param {string} [options.executablePath] - Explicit path to a Chrome or Chromium executable
|
|
28
|
+
* @param {string|Object} [options.storageState] - Playwright-compatible storage state path or object
|
|
23
29
|
* @returns {Promise<Object>} - Object with browser and page
|
|
24
30
|
*/
|
|
25
31
|
export async function launchBrowser(options = {}) {
|
|
@@ -33,6 +39,7 @@ export async function launchBrowser(options = {}) {
|
|
|
33
39
|
colorScheme,
|
|
34
40
|
channel,
|
|
35
41
|
executablePath,
|
|
42
|
+
storageState,
|
|
36
43
|
} = options;
|
|
37
44
|
|
|
38
45
|
// Combine default CHROME_ARGS with custom args
|
|
@@ -44,6 +51,8 @@ export async function launchBrowser(options = {}) {
|
|
|
44
51
|
);
|
|
45
52
|
}
|
|
46
53
|
|
|
54
|
+
const resolvedStorageState = await loadStorageState(storageState);
|
|
55
|
+
|
|
47
56
|
// Set environment variables to suppress warnings
|
|
48
57
|
process.env.GOOGLE_API_KEY = 'no';
|
|
49
58
|
process.env.GOOGLE_DEFAULT_CLIENT_ID = 'no';
|
|
@@ -74,6 +83,10 @@ export async function launchBrowser(options = {}) {
|
|
|
74
83
|
contextOptions
|
|
75
84
|
);
|
|
76
85
|
page = browser.pages()[0];
|
|
86
|
+
await restorePlaywrightStorageState({
|
|
87
|
+
context: browser,
|
|
88
|
+
storageState: resolvedStorageState,
|
|
89
|
+
});
|
|
77
90
|
} else {
|
|
78
91
|
const puppeteer = await import('puppeteer');
|
|
79
92
|
browser = await puppeteer.default.launch(
|
|
@@ -87,6 +100,10 @@ export async function launchBrowser(options = {}) {
|
|
|
87
100
|
);
|
|
88
101
|
const pages = await browser.pages();
|
|
89
102
|
page = pages[0];
|
|
103
|
+
await restorePuppeteerStorageState({
|
|
104
|
+
page,
|
|
105
|
+
storageState: resolvedStorageState,
|
|
106
|
+
});
|
|
90
107
|
}
|
|
91
108
|
|
|
92
109
|
if (verbose) {
|
|
@@ -348,6 +348,64 @@ export async function goto(options = {}) {
|
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Replace the current document with an in-memory HTML string.
|
|
353
|
+
* When NavigationManager is available, this uses the same managed lifecycle as
|
|
354
|
+
* goto so active page triggers are stopped before the document is replaced.
|
|
355
|
+
*
|
|
356
|
+
* @param {Object} options - Configuration options
|
|
357
|
+
* @param {Object} options.page - Browser page object
|
|
358
|
+
* @param {Object} options.navigationManager - NavigationManager instance (preferred)
|
|
359
|
+
* @param {string} options.html - HTML to load
|
|
360
|
+
* @param {string} options.waitUntil - Wait until condition (default: 'load')
|
|
361
|
+
* @param {number} options.timeout - Content loading timeout in ms (default: 60000)
|
|
362
|
+
* @returns {Promise<Object>} Content loading result
|
|
363
|
+
*/
|
|
364
|
+
export async function setContent(options = {}) {
|
|
365
|
+
const {
|
|
366
|
+
page,
|
|
367
|
+
navigationManager,
|
|
368
|
+
html,
|
|
369
|
+
waitUntil = 'load',
|
|
370
|
+
timeout = 60000,
|
|
371
|
+
} = options;
|
|
372
|
+
|
|
373
|
+
if (typeof html !== 'string') {
|
|
374
|
+
throw new Error('html is required in options');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
let loaded;
|
|
379
|
+
if (navigationManager) {
|
|
380
|
+
loaded = await navigationManager.setContent({
|
|
381
|
+
html,
|
|
382
|
+
waitUntil,
|
|
383
|
+
timeout,
|
|
384
|
+
});
|
|
385
|
+
} else {
|
|
386
|
+
await page.setContent(html, { waitUntil, timeout });
|
|
387
|
+
loaded = true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return loaded
|
|
391
|
+
? { loaded: true, actualUrl: page.url() }
|
|
392
|
+
: {
|
|
393
|
+
loaded: false,
|
|
394
|
+
actualUrl: page.url(),
|
|
395
|
+
reason: 'content loading stopped/interrupted',
|
|
396
|
+
};
|
|
397
|
+
} catch (error) {
|
|
398
|
+
if (isNavigationError(error) || isActionStoppedError(error)) {
|
|
399
|
+
return {
|
|
400
|
+
loaded: false,
|
|
401
|
+
actualUrl: page.url(),
|
|
402
|
+
reason: 'content loading stopped/interrupted',
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
351
409
|
/**
|
|
352
410
|
* Wait for navigation
|
|
353
411
|
* @param {Object} options - Configuration options
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Load a Playwright-compatible storage state from a JSON path or object.
|
|
5
|
+
* @param {string|Object|undefined} storageState - State path or parsed state
|
|
6
|
+
* @returns {Promise<Object|undefined>} Parsed state
|
|
7
|
+
*/
|
|
8
|
+
export async function loadStorageState(storageState) {
|
|
9
|
+
if (storageState === undefined) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
if (typeof storageState === 'string') {
|
|
13
|
+
return JSON.parse(await readFile(storageState, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
if (storageState && typeof storageState === 'object') {
|
|
16
|
+
return storageState;
|
|
17
|
+
}
|
|
18
|
+
throw new TypeError('storageState must be a file path or an object');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function restoreOriginLocalStorage(origins) {
|
|
22
|
+
const originState = origins.find(
|
|
23
|
+
({ origin }) => origin === globalThis.location.origin
|
|
24
|
+
);
|
|
25
|
+
if (!originState) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
for (const { name, value } of originState.localStorage ?? []) {
|
|
29
|
+
globalThis.localStorage.setItem(name, value);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply Playwright-compatible storage state to a Puppeteer page.
|
|
35
|
+
* @param {Object} options - Restore options
|
|
36
|
+
* @param {Object} options.page - Puppeteer page
|
|
37
|
+
* @param {Object|undefined} options.storageState - Parsed storage state
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
export async function restorePuppeteerStorageState({ page, storageState }) {
|
|
41
|
+
if (!storageState) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const cookies = storageState.cookies ?? [];
|
|
46
|
+
if (cookies.length > 0) {
|
|
47
|
+
await page.setCookie(...cookies);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const origins = storageState.origins ?? [];
|
|
51
|
+
if (origins.length > 0) {
|
|
52
|
+
await page.evaluateOnNewDocument(restoreOriginLocalStorage, origins);
|
|
53
|
+
await page.evaluate(restoreOriginLocalStorage, origins);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Apply storage state to a Playwright persistent browser context.
|
|
59
|
+
* @param {Object} options - Restore options
|
|
60
|
+
* @param {Object} options.context - Playwright browser context
|
|
61
|
+
* @param {Object|undefined} options.storageState - Parsed storage state
|
|
62
|
+
* @returns {Promise<void>}
|
|
63
|
+
*/
|
|
64
|
+
export async function restorePlaywrightStorageState({ context, storageState }) {
|
|
65
|
+
if (!storageState) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const cookies = storageState.cookies ?? [];
|
|
70
|
+
if (cookies.length > 0) {
|
|
71
|
+
await context.addCookies(cookies);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const origins = storageState.origins ?? [];
|
|
75
|
+
if (origins.length > 0) {
|
|
76
|
+
await context.addInitScript(restoreOriginLocalStorage, origins);
|
|
77
|
+
await Promise.all(
|
|
78
|
+
context
|
|
79
|
+
.pages()
|
|
80
|
+
.map((page) => page.evaluate(restoreOriginLocalStorage, origins))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readCurrentLocalStorage() {
|
|
86
|
+
return Array.from({ length: globalThis.localStorage.length }, (_, index) => {
|
|
87
|
+
const name = globalThis.localStorage.key(index);
|
|
88
|
+
return { name, value: globalThis.localStorage.getItem(name) };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function collectPuppeteerStorageState(page) {
|
|
93
|
+
const cookies = await page.cookies();
|
|
94
|
+
const pageUrl = page.url();
|
|
95
|
+
const origin = new URL(pageUrl).origin;
|
|
96
|
+
const origins = [];
|
|
97
|
+
|
|
98
|
+
if (origin !== 'null') {
|
|
99
|
+
origins.push({
|
|
100
|
+
origin,
|
|
101
|
+
localStorage: await page.evaluate(readCurrentLocalStorage),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { cookies, origins };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save a page's cookies and localStorage in Playwright's storage-state format.
|
|
110
|
+
* @param {Object} page - Playwright or Puppeteer page
|
|
111
|
+
* @param {string} [filePath] - Optional JSON output path
|
|
112
|
+
* @returns {Promise<Object>} Saved storage state
|
|
113
|
+
*/
|
|
114
|
+
export async function saveStorageState(page, filePath) {
|
|
115
|
+
const context = typeof page.context === 'function' ? page.context() : null;
|
|
116
|
+
if (typeof context?.storageState === 'function') {
|
|
117
|
+
return context.storageState(filePath ? { path: filePath } : undefined);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
typeof page.cookies !== 'function' ||
|
|
122
|
+
typeof page.evaluate !== 'function' ||
|
|
123
|
+
typeof page.url !== 'function'
|
|
124
|
+
) {
|
|
125
|
+
throw new TypeError('page must be a Playwright or Puppeteer page');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const storageState = await collectPuppeteerStorageState(page);
|
|
129
|
+
if (filePath) {
|
|
130
|
+
await writeFile(filePath, `${JSON.stringify(storageState, null, 2)}\n`);
|
|
131
|
+
}
|
|
132
|
+
return storageState;
|
|
133
|
+
}
|
|
@@ -11,6 +11,47 @@
|
|
|
11
11
|
import { isNavigationError } from './navigation-safety.js';
|
|
12
12
|
import { TIMING } from './constants.js';
|
|
13
13
|
|
|
14
|
+
async function setManagedContent(options = {}) {
|
|
15
|
+
const {
|
|
16
|
+
page,
|
|
17
|
+
log,
|
|
18
|
+
html,
|
|
19
|
+
waitUntil = 'load',
|
|
20
|
+
timeout = 60000,
|
|
21
|
+
currentUrl,
|
|
22
|
+
triggerNavigationStart,
|
|
23
|
+
updateCurrentUrl,
|
|
24
|
+
waitForPageReady,
|
|
25
|
+
completeNavigation,
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
if (typeof html !== 'string') {
|
|
29
|
+
throw new Error('html is required in options');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
log.debug(() => '🚀 Loading in-memory HTML content');
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
// Treat document replacement as a controlled navigation so active page
|
|
36
|
+
// triggers stop before the old document is discarded.
|
|
37
|
+
await triggerNavigationStart({ url: currentUrl, isExternal: false });
|
|
38
|
+
await page.setContent(html, { waitUntil, timeout });
|
|
39
|
+
|
|
40
|
+
// setContent normally preserves the URL, but inline scripts can navigate.
|
|
41
|
+
updateCurrentUrl();
|
|
42
|
+
await waitForPageReady({ timeout, reason: 'after setContent' });
|
|
43
|
+
return true;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// Do not leave the manager in its loading state when setContent fails.
|
|
46
|
+
completeNavigation();
|
|
47
|
+
if (isNavigationError(error)) {
|
|
48
|
+
log.debug(() => '⚠️ Content loading was interrupted, recovering...');
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
14
55
|
/**
|
|
15
56
|
* Create a NavigationManager instance for a page
|
|
16
57
|
* @param {Object} options - Configuration options
|
|
@@ -341,6 +382,18 @@ export function createNavigationManager(options = {}) {
|
|
|
341
382
|
}
|
|
342
383
|
}
|
|
343
384
|
|
|
385
|
+
const setContent = (opts = {}) =>
|
|
386
|
+
setManagedContent({
|
|
387
|
+
page,
|
|
388
|
+
log,
|
|
389
|
+
...opts,
|
|
390
|
+
currentUrl,
|
|
391
|
+
triggerNavigationStart,
|
|
392
|
+
updateCurrentUrl: () => (currentUrl = page.url()),
|
|
393
|
+
waitForPageReady,
|
|
394
|
+
completeNavigation,
|
|
395
|
+
});
|
|
396
|
+
|
|
344
397
|
/**
|
|
345
398
|
* Wait for any pending navigation to complete
|
|
346
399
|
* @param {Object} options - Configuration options
|
|
@@ -373,27 +426,6 @@ export function createNavigationManager(options = {}) {
|
|
|
373
426
|
return await navigationPromise;
|
|
374
427
|
}
|
|
375
428
|
|
|
376
|
-
/**
|
|
377
|
-
* Check if we're currently navigating
|
|
378
|
-
*/
|
|
379
|
-
function isCurrentlyNavigating() {
|
|
380
|
-
return isNavigating;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Get current URL
|
|
385
|
-
*/
|
|
386
|
-
function getCurrentUrl() {
|
|
387
|
-
return currentUrl;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Get current session ID
|
|
392
|
-
*/
|
|
393
|
-
function getSessionId() {
|
|
394
|
-
return sessionId;
|
|
395
|
-
}
|
|
396
|
-
|
|
397
429
|
/**
|
|
398
430
|
* Get the current abort signal
|
|
399
431
|
* Use this to check if operations should be aborted due to navigation
|
|
@@ -476,13 +508,14 @@ export function createNavigationManager(options = {}) {
|
|
|
476
508
|
return {
|
|
477
509
|
// Navigation
|
|
478
510
|
navigate,
|
|
511
|
+
setContent,
|
|
479
512
|
waitForNavigation,
|
|
480
513
|
waitForPageReady,
|
|
481
514
|
|
|
482
515
|
// State
|
|
483
|
-
isNavigating:
|
|
484
|
-
getCurrentUrl,
|
|
485
|
-
getSessionId,
|
|
516
|
+
isNavigating: () => isNavigating,
|
|
517
|
+
getCurrentUrl: () => currentUrl,
|
|
518
|
+
getSessionId: () => sessionId,
|
|
486
519
|
|
|
487
520
|
// Abort handling - use these to stop operations when navigation occurs
|
|
488
521
|
getAbortSignal,
|
package/src/exports.js
CHANGED
|
@@ -43,10 +43,12 @@ export {
|
|
|
43
43
|
|
|
44
44
|
// Re-export browser management
|
|
45
45
|
export { launchBrowser } from './browser/launcher.js';
|
|
46
|
+
export { saveStorageState } from './browser/storage-state.js';
|
|
46
47
|
export { emulateMedia } from './browser/media.js';
|
|
47
48
|
export {
|
|
48
49
|
waitForUrlStabilization,
|
|
49
50
|
goto,
|
|
51
|
+
setContent,
|
|
50
52
|
waitForNavigation,
|
|
51
53
|
waitForPageReady,
|
|
52
54
|
waitAfterAction,
|
|
@@ -522,5 +522,24 @@ describe(
|
|
|
522
522
|
// Test passes if no error is thrown
|
|
523
523
|
});
|
|
524
524
|
});
|
|
525
|
+
|
|
526
|
+
describe('In-memory Content', () => {
|
|
527
|
+
it('should load HTML through commander.setContent', async () => {
|
|
528
|
+
if (!commander) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const result = await commander.setContent({
|
|
533
|
+
html: '<main><h1 data-testid="content-title">In memory</h1></main>',
|
|
534
|
+
waitUntil: 'load',
|
|
535
|
+
});
|
|
536
|
+
const title = await commander.textContent({
|
|
537
|
+
selector: '[data-testid="content-title"]',
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
assert.strictEqual(result.loaded, true);
|
|
541
|
+
assert.strictEqual(title, 'In memory');
|
|
542
|
+
});
|
|
543
|
+
});
|
|
525
544
|
}
|
|
526
545
|
);
|
|
@@ -476,4 +476,23 @@ describe('E2E Tests - Puppeteer Engine', { skip: !process.env.RUN_E2E }, () => {
|
|
|
476
476
|
assert.strictEqual(modalClosed, false);
|
|
477
477
|
});
|
|
478
478
|
});
|
|
479
|
+
|
|
480
|
+
describe('In-memory Content', () => {
|
|
481
|
+
it('should load HTML through commander.setContent', async () => {
|
|
482
|
+
if (!commander) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const result = await commander.setContent({
|
|
487
|
+
html: '<main><h1 data-testid="content-title">In memory</h1></main>',
|
|
488
|
+
waitUntil: 'load',
|
|
489
|
+
});
|
|
490
|
+
const title = await commander.textContent({
|
|
491
|
+
selector: '[data-testid="content-title"]',
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
assert.strictEqual(result.loaded, true);
|
|
495
|
+
assert.strictEqual(title, 'In memory');
|
|
496
|
+
});
|
|
497
|
+
});
|
|
479
498
|
});
|
package/tests/helpers/mocks.js
CHANGED
|
@@ -90,6 +90,7 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
90
90
|
_isPlaywrightPage: true,
|
|
91
91
|
url: () => url,
|
|
92
92
|
goto: async (targetUrl, opts = {}) => {},
|
|
93
|
+
setContent: async (html, opts = {}) => {},
|
|
93
94
|
waitForNavigation: async (opts = {}) => {},
|
|
94
95
|
waitForSelector: async (sel, opts = {}) => locatorMock(sel),
|
|
95
96
|
$: async (sel) => locatorMock(sel),
|
|
@@ -196,6 +197,7 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
196
197
|
_isPuppeteerPage: true,
|
|
197
198
|
url: () => url,
|
|
198
199
|
goto: async (targetUrl, opts = {}) => {},
|
|
200
|
+
setContent: async (html, opts = {}) => {},
|
|
199
201
|
waitForNavigation: async (opts = {}) => {},
|
|
200
202
|
waitForSelector: async (sel, opts = {}) => elementMock(sel),
|
|
201
203
|
$: async (sel) => {
|
|
@@ -441,6 +443,7 @@ export function createMockNavigationManager(options = {}) {
|
|
|
441
443
|
url = opts.url || url;
|
|
442
444
|
return true;
|
|
443
445
|
},
|
|
446
|
+
setContent: async (opts = {}) => true,
|
|
444
447
|
waitForNavigation: async (opts = {}) => true,
|
|
445
448
|
waitForPageReady: async (opts = {}) => true,
|
|
446
449
|
isNavigating: () => navigating,
|
|
@@ -61,6 +61,10 @@ describe('bindings', () => {
|
|
|
61
61
|
typeof bindings.goto === 'function',
|
|
62
62
|
'goto should be a function'
|
|
63
63
|
);
|
|
64
|
+
assert.ok(
|
|
65
|
+
typeof bindings.setContent === 'function',
|
|
66
|
+
'setContent should be a function'
|
|
67
|
+
);
|
|
64
68
|
assert.ok(
|
|
65
69
|
typeof bindings.getUrl === 'function',
|
|
66
70
|
'getUrl should be a function'
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
verifyNavigation,
|
|
6
6
|
waitForUrlStabilization,
|
|
7
7
|
goto,
|
|
8
|
+
setContent,
|
|
8
9
|
waitForNavigation,
|
|
9
10
|
waitForPageReady,
|
|
10
11
|
waitAfterAction,
|
|
@@ -203,6 +204,66 @@ describe('navigation', () => {
|
|
|
203
204
|
});
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
describe('setContent', () => {
|
|
208
|
+
it('should throw when html is not provided', async () => {
|
|
209
|
+
const page = createMockPlaywrightPage();
|
|
210
|
+
|
|
211
|
+
await assert.rejects(() => setContent({ page }), /html is required/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should load content using navigationManager', async () => {
|
|
215
|
+
const page = createMockPlaywrightPage();
|
|
216
|
+
const navigationManager = createMockNavigationManager();
|
|
217
|
+
let receivedOptions;
|
|
218
|
+
navigationManager.setContent = async (options) => {
|
|
219
|
+
receivedOptions = options;
|
|
220
|
+
return true;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const result = await setContent({
|
|
224
|
+
page,
|
|
225
|
+
navigationManager,
|
|
226
|
+
html: '<h1>Hi</h1>',
|
|
227
|
+
waitUntil: 'networkidle',
|
|
228
|
+
timeout: 5000,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
assert.deepStrictEqual(receivedOptions, {
|
|
232
|
+
html: '<h1>Hi</h1>',
|
|
233
|
+
waitUntil: 'networkidle',
|
|
234
|
+
timeout: 5000,
|
|
235
|
+
});
|
|
236
|
+
assert.deepStrictEqual(result, {
|
|
237
|
+
loaded: true,
|
|
238
|
+
actualUrl: 'https://example.com',
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should load content directly without navigationManager', async () => {
|
|
243
|
+
const page = createMockPlaywrightPage();
|
|
244
|
+
let receivedArguments;
|
|
245
|
+
page.setContent = async (...args) => {
|
|
246
|
+
receivedArguments = args;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const result = await setContent({
|
|
250
|
+
page,
|
|
251
|
+
html: '',
|
|
252
|
+
waitUntil: 'load',
|
|
253
|
+
timeout: 5000,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
assert.deepStrictEqual(receivedArguments, [
|
|
257
|
+
'',
|
|
258
|
+
{ waitUntil: 'load', timeout: 5000 },
|
|
259
|
+
]);
|
|
260
|
+
assert.deepStrictEqual(result, {
|
|
261
|
+
loaded: true,
|
|
262
|
+
actualUrl: 'https://example.com',
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
206
267
|
describe('waitForNavigation', () => {
|
|
207
268
|
it('should delegate to navigationManager if available', async () => {
|
|
208
269
|
const page = createMockPlaywrightPage();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, describe, it } from 'node:test';
|
|
5
|
+
import assert from 'node:assert';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
loadStorageState,
|
|
9
|
+
restorePlaywrightStorageState,
|
|
10
|
+
restorePuppeteerStorageState,
|
|
11
|
+
saveStorageState,
|
|
12
|
+
} from '../../../src/browser/storage-state.js';
|
|
13
|
+
import { saveStorageState as publicSaveStorageState } from '../../../src/index.js';
|
|
14
|
+
|
|
15
|
+
describe('browser storage state', () => {
|
|
16
|
+
let temporaryDirectory;
|
|
17
|
+
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
if (temporaryDirectory) {
|
|
20
|
+
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
21
|
+
temporaryDirectory = undefined;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('loads storage state from a path and accepts an object unchanged', async () => {
|
|
26
|
+
temporaryDirectory = await mkdtemp(
|
|
27
|
+
path.join(os.tmpdir(), 'browser-commander-storage-state-')
|
|
28
|
+
);
|
|
29
|
+
const filePath = path.join(temporaryDirectory, 'state.json');
|
|
30
|
+
const state = {
|
|
31
|
+
cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
|
|
32
|
+
origins: [],
|
|
33
|
+
};
|
|
34
|
+
await writeFile(filePath, JSON.stringify(state));
|
|
35
|
+
|
|
36
|
+
assert.deepEqual(await loadStorageState(filePath), state);
|
|
37
|
+
assert.equal(await loadStorageState(state), state);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects invalid storage state input', async () => {
|
|
41
|
+
await assert.rejects(
|
|
42
|
+
() => loadStorageState(null),
|
|
43
|
+
/storageState must be a file path or an object/
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('exports saveStorageState from the package API', () => {
|
|
48
|
+
assert.equal(publicSaveStorageState, saveStorageState);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('restores Puppeteer cookies and localStorage before navigation', async () => {
|
|
52
|
+
const calls = [];
|
|
53
|
+
const state = {
|
|
54
|
+
cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
|
|
55
|
+
origins: [
|
|
56
|
+
{
|
|
57
|
+
origin: 'https://example.com',
|
|
58
|
+
localStorage: [{ name: 'theme', value: 'dark' }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
const page = {
|
|
63
|
+
setCookie: async (...cookies) => calls.push(['cookies', cookies]),
|
|
64
|
+
evaluateOnNewDocument: async (fn, value) =>
|
|
65
|
+
calls.push(['new-document', fn, value]),
|
|
66
|
+
evaluate: async (fn, value) => calls.push(['current-page', fn, value]),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
await restorePuppeteerStorageState({ page, storageState: state });
|
|
70
|
+
|
|
71
|
+
assert.deepEqual(calls[0], ['cookies', state.cookies]);
|
|
72
|
+
assert.equal(calls[1][0], 'new-document');
|
|
73
|
+
assert.equal(calls[1][2], state.origins);
|
|
74
|
+
assert.equal(calls[2][0], 'current-page');
|
|
75
|
+
assert.equal(calls[2][2], state.origins);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('restores Playwright cookies and localStorage before navigation', async () => {
|
|
79
|
+
const calls = [];
|
|
80
|
+
const state = {
|
|
81
|
+
cookies: [{ name: 'session', value: 'saved', domain: '.example.com' }],
|
|
82
|
+
origins: [
|
|
83
|
+
{
|
|
84
|
+
origin: 'https://example.com',
|
|
85
|
+
localStorage: [{ name: 'theme', value: 'dark' }],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
const page = {
|
|
90
|
+
evaluate: async (fn, value) => calls.push(['current-page', fn, value]),
|
|
91
|
+
};
|
|
92
|
+
const context = {
|
|
93
|
+
addCookies: async (cookies) => calls.push(['cookies', cookies]),
|
|
94
|
+
addInitScript: async (fn, value) =>
|
|
95
|
+
calls.push(['new-document', fn, value]),
|
|
96
|
+
pages: () => [page],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
await restorePlaywrightStorageState({ context, storageState: state });
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(calls[0], ['cookies', state.cookies]);
|
|
102
|
+
assert.equal(calls[1][0], 'new-document');
|
|
103
|
+
assert.equal(calls[1][2], state.origins);
|
|
104
|
+
assert.equal(calls[2][0], 'current-page');
|
|
105
|
+
assert.equal(calls[2][2], state.origins);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('delegates Playwright saves to the page context', async () => {
|
|
109
|
+
const savedState = { cookies: [], origins: [] };
|
|
110
|
+
const calls = [];
|
|
111
|
+
const page = {
|
|
112
|
+
context: () => ({
|
|
113
|
+
storageState: async (options) => {
|
|
114
|
+
calls.push(options);
|
|
115
|
+
return savedState;
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
assert.equal(await saveStorageState(page, '/tmp/state.json'), savedState);
|
|
121
|
+
assert.deepEqual(calls, [{ path: '/tmp/state.json' }]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('saves Puppeteer cookies and current-origin localStorage', async () => {
|
|
125
|
+
temporaryDirectory = await mkdtemp(
|
|
126
|
+
path.join(os.tmpdir(), 'browser-commander-storage-state-')
|
|
127
|
+
);
|
|
128
|
+
const filePath = path.join(temporaryDirectory, 'state.json');
|
|
129
|
+
const cookies = [
|
|
130
|
+
{ name: 'session', value: 'saved', domain: '.example.com' },
|
|
131
|
+
];
|
|
132
|
+
const localStorage = [{ name: 'theme', value: 'dark' }];
|
|
133
|
+
const page = {
|
|
134
|
+
browserContext: () => ({}),
|
|
135
|
+
cookies: async () => cookies,
|
|
136
|
+
url: () => 'https://example.com/inbox',
|
|
137
|
+
evaluate: async () => localStorage,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const state = await saveStorageState(page, filePath);
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(state, {
|
|
143
|
+
cookies,
|
|
144
|
+
origins: [{ origin: 'https://example.com', localStorage }],
|
|
145
|
+
});
|
|
146
|
+
assert.deepEqual(JSON.parse(await readFile(filePath, 'utf8')), state);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { createNavigationManager } from '../../../src/core/navigation-manager.js';
|
|
4
|
+
import {
|
|
5
|
+
createMockPlaywrightPage,
|
|
6
|
+
createMockLogger,
|
|
7
|
+
} from '../../helpers/mocks.js';
|
|
8
|
+
|
|
9
|
+
describe('navigation manager', () => {
|
|
10
|
+
describe('setContent', () => {
|
|
11
|
+
it('should apply the full managed navigation lifecycle', async () => {
|
|
12
|
+
const page = createMockPlaywrightPage();
|
|
13
|
+
const log = createMockLogger();
|
|
14
|
+
const calls = [];
|
|
15
|
+
page.setContent = async (...args) => calls.push(['setContent', ...args]);
|
|
16
|
+
|
|
17
|
+
const manager = createNavigationManager({
|
|
18
|
+
page,
|
|
19
|
+
engine: 'playwright',
|
|
20
|
+
log,
|
|
21
|
+
});
|
|
22
|
+
manager.configure({ redirectStabilizationTime: 0 });
|
|
23
|
+
manager.onSessionCleanup(() => calls.push(['cleanup']));
|
|
24
|
+
manager.on('onNavigationStart', () => calls.push(['navigationStart']));
|
|
25
|
+
manager.on('onPageReady', () => calls.push(['pageReady']));
|
|
26
|
+
|
|
27
|
+
const loaded = await manager.setContent({
|
|
28
|
+
html: '<h1>Hi</h1>',
|
|
29
|
+
waitUntil: 'networkidle',
|
|
30
|
+
timeout: 5000,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.strictEqual(loaded, true);
|
|
34
|
+
assert.strictEqual(manager.getSessionId(), 1);
|
|
35
|
+
assert.strictEqual(manager.isNavigating(), false);
|
|
36
|
+
assert.deepStrictEqual(calls, [
|
|
37
|
+
['cleanup'],
|
|
38
|
+
['navigationStart'],
|
|
39
|
+
[
|
|
40
|
+
'setContent',
|
|
41
|
+
'<h1>Hi</h1>',
|
|
42
|
+
{ waitUntil: 'networkidle', timeout: 5000 },
|
|
43
|
+
],
|
|
44
|
+
['pageReady'],
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should require html while accepting an empty string', async () => {
|
|
49
|
+
const page = createMockPlaywrightPage();
|
|
50
|
+
const log = createMockLogger();
|
|
51
|
+
const manager = createNavigationManager({
|
|
52
|
+
page,
|
|
53
|
+
engine: 'playwright',
|
|
54
|
+
log,
|
|
55
|
+
});
|
|
56
|
+
manager.configure({ redirectStabilizationTime: 0 });
|
|
57
|
+
|
|
58
|
+
await assert.rejects(() => manager.setContent(), /html is required/);
|
|
59
|
+
await assert.doesNotReject(() => manager.setContent({ html: '' }));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { checkWorkflow } from '../../../../scripts/check-ci-workflows.mjs';
|
|
8
|
+
|
|
9
|
+
function withWorkflow(content, callback) {
|
|
10
|
+
const directory = mkdtempSync(join(tmpdir(), 'browser-commander-ci-'));
|
|
11
|
+
const filePath = join(directory, 'workflow.yml');
|
|
12
|
+
writeFileSync(filePath, content);
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
return callback(filePath);
|
|
16
|
+
} finally {
|
|
17
|
+
rmSync(directory, { force: true, recursive: true });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('CI workflow policy', () => {
|
|
22
|
+
it('accepts bounded job-scoped checks', () => {
|
|
23
|
+
const workflow = `name: Test
|
|
24
|
+
on: push
|
|
25
|
+
permissions:
|
|
26
|
+
contents: read
|
|
27
|
+
env:
|
|
28
|
+
GIT_CONFIG_KEY_0: init.defaultBranch
|
|
29
|
+
jobs:
|
|
30
|
+
test:
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
timeout-minutes: 5
|
|
33
|
+
concurrency:
|
|
34
|
+
group: \${{ github.workflow }}-\${{ github.ref }}-test
|
|
35
|
+
cancel-in-progress: true
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v6
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
withWorkflow(workflow, (filePath) => {
|
|
41
|
+
assert.equal(checkWorkflow(filePath), 0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('rejects workflow-wide cancellation and unbounded jobs', () => {
|
|
46
|
+
const workflow = `name: Test
|
|
47
|
+
on: push
|
|
48
|
+
concurrency:
|
|
49
|
+
group: test
|
|
50
|
+
cancel-in-progress: true
|
|
51
|
+
jobs:
|
|
52
|
+
test:
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/checkout@v6
|
|
56
|
+
- run: npm install
|
|
57
|
+
- run: echo "\${{ github.head_ref }}"
|
|
58
|
+
- uses: codecov/codecov-action@v6
|
|
59
|
+
with:
|
|
60
|
+
file: coverage.xml
|
|
61
|
+
`;
|
|
62
|
+
const originalError = console.error;
|
|
63
|
+
console.error = () => {};
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
withWorkflow(workflow, (filePath) => {
|
|
67
|
+
assert.equal(checkWorkflow(filePath), 7);
|
|
68
|
+
});
|
|
69
|
+
} finally {
|
|
70
|
+
console.error = originalError;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -127,12 +127,25 @@ describe('test runner helper', () => {
|
|
|
127
127
|
reporter: 'spec',
|
|
128
128
|
targets: ['tests/unit'],
|
|
129
129
|
});
|
|
130
|
-
assert.deepEqual(
|
|
131
|
-
'
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
buildNodeTestArgs(options, ['tests/unit/a.test.js'], '24.18.0'),
|
|
132
|
+
[
|
|
133
|
+
'--test',
|
|
134
|
+
'--test-isolation=none',
|
|
135
|
+
'--experimental-test-coverage',
|
|
136
|
+
'--test-reporter',
|
|
137
|
+
'spec',
|
|
138
|
+
'tests/unit/a.test.js',
|
|
139
|
+
]
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('keeps process isolation on Node versions without isolation controls', () => {
|
|
144
|
+
const options = parseTestRunnerArgs(['tests/unit']);
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
buildNodeTestArgs(options, ['tests/unit/a.test.js'], '20.20.2'),
|
|
148
|
+
['--test', 'tests/unit/a.test.js']
|
|
149
|
+
);
|
|
137
150
|
});
|
|
138
151
|
});
|