browser-commander 0.10.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 +16 -0
- package/README.md +58 -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 +29 -1
- 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/engine-adapter.js +33 -0
- package/src/core/navigation-manager.js +57 -24
- package/src/elements/content.js +55 -0
- package/src/exports.js +4 -0
- package/tests/e2e/playwright.e2e.test.js +19 -0
- package/tests/e2e/puppeteer.e2e.test.js +19 -0
- package/tests/helpers/mocks.js +10 -0
- package/tests/unit/bindings.test.js +68 -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/elements/content.test.js +102 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +73 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +20 -7
|
@@ -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/elements/content.js
CHANGED
|
@@ -2,6 +2,61 @@ import { isNavigationError } from '../core/navigation-safety.js';
|
|
|
2
2
|
import { getLocatorOrElement } from './locators.js';
|
|
3
3
|
import { createEngineAdapter } from '../core/engine-adapter.js';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Get the full HTML of the current page
|
|
7
|
+
* @param {Object} options - Configuration options
|
|
8
|
+
* @param {Object} options.page - Browser page object
|
|
9
|
+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
10
|
+
* @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
|
|
11
|
+
* @returns {Promise<string|null>} - Serialized page HTML, or null during navigation
|
|
12
|
+
*/
|
|
13
|
+
export async function content(options = {}) {
|
|
14
|
+
const { page, engine, adapter: providedAdapter } = options;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const adapter = providedAdapter || createEngineAdapter(page, engine);
|
|
18
|
+
return await adapter.getPageContent();
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (isNavigationError(error)) {
|
|
21
|
+
console.log('⚠️ Navigation detected during content, returning null');
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Get rendered text from an element, defaulting to the document body
|
|
30
|
+
* @param {Object} options - Configuration options
|
|
31
|
+
* @param {Object} options.page - Browser page object
|
|
32
|
+
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
33
|
+
* @param {string|Object} [options.selector='body'] - CSS selector or element
|
|
34
|
+
* @param {Object} options.adapter - Engine adapter (optional, will be created if not provided)
|
|
35
|
+
* @returns {Promise<string|null>} - Rendered text, or null if unavailable
|
|
36
|
+
*/
|
|
37
|
+
export async function innerText(options = {}) {
|
|
38
|
+
const { page, engine, selector = 'body', adapter: providedAdapter } = options;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const adapter = providedAdapter || createEngineAdapter(page, engine);
|
|
42
|
+
const locatorOrElement = await getLocatorOrElement({
|
|
43
|
+
page,
|
|
44
|
+
engine,
|
|
45
|
+
selector,
|
|
46
|
+
});
|
|
47
|
+
if (!locatorOrElement) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return await adapter.getInnerText(locatorOrElement);
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isNavigationError(error)) {
|
|
53
|
+
console.log('⚠️ Navigation detected during innerText, returning null');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
5
60
|
/**
|
|
6
61
|
* Get text content
|
|
7
62
|
* @param {Object} options - Configuration options
|
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,
|
|
@@ -78,6 +80,8 @@ export {
|
|
|
78
80
|
export { isVisible, isEnabled, count } from './elements/visibility.js';
|
|
79
81
|
|
|
80
82
|
export {
|
|
83
|
+
content,
|
|
84
|
+
innerText,
|
|
81
85
|
textContent,
|
|
82
86
|
inputValue,
|
|
83
87
|
getAttribute,
|
|
@@ -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
|
@@ -44,6 +44,7 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
44
44
|
},
|
|
45
45
|
focus: async () => {},
|
|
46
46
|
textContent: async () => elementData.textContent,
|
|
47
|
+
innerText: async () => elementData.innerText ?? elementData.textContent,
|
|
47
48
|
inputValue: async () => elementData.value,
|
|
48
49
|
getAttribute: async (name) => elementData[name] || null,
|
|
49
50
|
isVisible: async () => elementData.visible,
|
|
@@ -56,6 +57,7 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
56
57
|
const mockEl = {
|
|
57
58
|
tagName: 'DIV',
|
|
58
59
|
textContent: elementData.textContent,
|
|
60
|
+
innerText: elementData.innerText ?? elementData.textContent,
|
|
59
61
|
value: elementData.value,
|
|
60
62
|
className: elementData.className,
|
|
61
63
|
disabled: !elementData.enabled,
|
|
@@ -88,6 +90,7 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
88
90
|
_isPlaywrightPage: true,
|
|
89
91
|
url: () => url,
|
|
90
92
|
goto: async (targetUrl, opts = {}) => {},
|
|
93
|
+
setContent: async (html, opts = {}) => {},
|
|
91
94
|
waitForNavigation: async (opts = {}) => {},
|
|
92
95
|
waitForSelector: async (sel, opts = {}) => locatorMock(sel),
|
|
93
96
|
$: async (sel) => locatorMock(sel),
|
|
@@ -105,6 +108,7 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
105
108
|
return locs.map((l) => l.evaluate(fn, ...args));
|
|
106
109
|
},
|
|
107
110
|
locator: locatorMock,
|
|
111
|
+
content: async () => '<html><body>Mock page</body></html>',
|
|
108
112
|
// Playwright's page.evaluate() only accepts a single argument (not spread)
|
|
109
113
|
// This is the key difference from Puppeteer
|
|
110
114
|
evaluate: async (fn, arg) => {
|
|
@@ -193,6 +197,7 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
193
197
|
_isPuppeteerPage: true,
|
|
194
198
|
url: () => url,
|
|
195
199
|
goto: async (targetUrl, opts = {}) => {},
|
|
200
|
+
setContent: async (html, opts = {}) => {},
|
|
196
201
|
waitForNavigation: async (opts = {}) => {},
|
|
197
202
|
waitForSelector: async (sel, opts = {}) => elementMock(sel),
|
|
198
203
|
$: async (sel) => {
|
|
@@ -213,6 +218,8 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
213
218
|
const mockEl = {
|
|
214
219
|
tagName: 'DIV',
|
|
215
220
|
textContent: elementData.textContent || 'Mock text',
|
|
221
|
+
innerText:
|
|
222
|
+
elementData.innerText ?? elementData.textContent ?? 'Mock text',
|
|
216
223
|
value: elementData.value || '',
|
|
217
224
|
className: elementData.className || 'mock-class',
|
|
218
225
|
disabled: !elementData.enabled,
|
|
@@ -252,6 +259,7 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
252
259
|
const mockEl = {
|
|
253
260
|
tagName: 'DIV',
|
|
254
261
|
textContent: 'Mock text',
|
|
262
|
+
innerText: 'Mock text',
|
|
255
263
|
value: '',
|
|
256
264
|
className: 'mock-class',
|
|
257
265
|
disabled: false,
|
|
@@ -280,6 +288,7 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
280
288
|
return fn;
|
|
281
289
|
}
|
|
282
290
|
},
|
|
291
|
+
content: async () => '<html><body>Mock page</body></html>',
|
|
283
292
|
mainFrame: () => ({
|
|
284
293
|
url: () => url,
|
|
285
294
|
}),
|
|
@@ -434,6 +443,7 @@ export function createMockNavigationManager(options = {}) {
|
|
|
434
443
|
url = opts.url || url;
|
|
435
444
|
return true;
|
|
436
445
|
},
|
|
446
|
+
setContent: async (opts = {}) => true,
|
|
437
447
|
waitForNavigation: async (opts = {}) => true,
|
|
438
448
|
waitForPageReady: async (opts = {}) => true,
|
|
439
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'
|
|
@@ -129,6 +133,14 @@ describe('bindings', () => {
|
|
|
129
133
|
typeof bindings.textContent === 'function',
|
|
130
134
|
'textContent should be a function'
|
|
131
135
|
);
|
|
136
|
+
assert.ok(
|
|
137
|
+
typeof bindings.content === 'function',
|
|
138
|
+
'content should be a function'
|
|
139
|
+
);
|
|
140
|
+
assert.ok(
|
|
141
|
+
typeof bindings.innerText === 'function',
|
|
142
|
+
'innerText should be a function'
|
|
143
|
+
);
|
|
132
144
|
assert.ok(
|
|
133
145
|
typeof bindings.inputValue === 'function',
|
|
134
146
|
'inputValue should be a function'
|
|
@@ -204,6 +216,62 @@ describe('bindings', () => {
|
|
|
204
216
|
assert.strictEqual(url, 'https://example.com');
|
|
205
217
|
});
|
|
206
218
|
|
|
219
|
+
it('should expose page content with no arguments', async () => {
|
|
220
|
+
const page = createMockPlaywrightPage();
|
|
221
|
+
const bindings = createBoundFunctions({
|
|
222
|
+
page,
|
|
223
|
+
engine: 'playwright',
|
|
224
|
+
log: createMockLogger(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
assert.strictEqual(
|
|
228
|
+
await bindings.content(),
|
|
229
|
+
'<html><body>Mock page</body></html>'
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should expose innerText with a selector or the body by default', async () => {
|
|
234
|
+
const page = createMockPlaywrightPage({
|
|
235
|
+
elements: {
|
|
236
|
+
body: { innerText: 'Body text', count: 1 },
|
|
237
|
+
main: { innerText: 'Main text', count: 1 },
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const bindings = createBoundFunctions({
|
|
241
|
+
page,
|
|
242
|
+
engine: 'playwright',
|
|
243
|
+
log: createMockLogger(),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
assert.strictEqual(await bindings.innerText(), 'Body text');
|
|
247
|
+
assert.strictEqual(await bindings.innerText('main'), 'Main text');
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should evaluate a function with positional arguments', async () => {
|
|
251
|
+
const page = createMockPlaywrightPage();
|
|
252
|
+
const bindings = createBoundFunctions({
|
|
253
|
+
page,
|
|
254
|
+
engine: 'playwright',
|
|
255
|
+
log: createMockLogger(),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
assert.strictEqual(await bindings.evaluate((a, b) => a + b, 2, 3), 5);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should preserve the evaluate options-object API', async () => {
|
|
262
|
+
const page = createMockPlaywrightPage();
|
|
263
|
+
const bindings = createBoundFunctions({
|
|
264
|
+
page,
|
|
265
|
+
engine: 'playwright',
|
|
266
|
+
log: createMockLogger(),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
assert.strictEqual(
|
|
270
|
+
await bindings.evaluate({ fn: (a, b) => a + b, args: [2, 3] }),
|
|
271
|
+
5
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
|
|
207
275
|
it('should pre-bind log to functions', async () => {
|
|
208
276
|
const page = createMockPlaywrightPage();
|
|
209
277
|
const log = createMockLogger({ collectLogs: true });
|
|
@@ -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
|
+
});
|