browser-commander 0.26.2 → 0.27.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/.jscpd-baseline.json +24 -23
- package/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/package.json +5 -3
- package/src/bindings.js +19 -0
- package/src/browser/attach/snapshot-launch.js +8 -6
- package/src/browser/attach/snapshot.js +39 -2
- package/src/browser/browser-cookie-database.js +71 -2
- package/src/browser/browser-cookie-session.js +183 -0
- package/src/browser/browser-cookies.js +29 -0
- package/src/browser/browser-sources.js +13 -0
- package/src/browser/browser-sources.json +13 -0
- package/src/browser/debugging-port.js +4 -2
- package/src/browser/default-browser.js +33 -9
- package/src/browser/launch-diagnostics.js +102 -0
- package/src/browser/launcher.js +28 -8
- package/src/browser/migration/sqlite-snapshot.js +5 -27
- package/src/browser/navigation.js +173 -98
- package/src/browser/real-browser.js +135 -68
- package/src/browser/session-cookies.js +78 -0
- package/src/browser/session-persistence.js +72 -0
- package/src/browser/storage-state.js +32 -6
- package/src/browser/system-browser.js +2 -2
- package/src/core/dialog-manager.js +7 -0
- package/src/core/managed-navigation.js +101 -0
- package/src/core/navigation-manager.js +62 -78
- package/src/core/navigation-operation.js +25 -0
- package/src/core/navigation-readiness.js +40 -16
- package/src/core/network-tracker.js +39 -92
- package/src/core/page-session.js +3 -2
- package/src/core/page-trigger-manager.js +15 -12
- package/src/core/readiness.js +67 -13
- package/src/core/subscriptions.js +20 -0
- package/src/core/webdriver-page.js +38 -0
- package/src/elements/locators.js +35 -5
- package/src/elements/reusable.js +92 -0
- package/src/elements/visibility.js +6 -11
- package/src/exports.js +14 -0
- package/src/factory.js +4 -4
- package/src/high-level/universal-logic.js +57 -21
- package/src/interactions/click.js +3 -0
- package/src/interactions/scroll.js +8 -2
- package/src/utilities/subprocess.js +15 -4
- package/tests/e2e/navigation-budget.e2e.test.js +132 -0
- package/tests/e2e/reusable-session.e2e.test.js +231 -0
- package/tests/unit/browser/attach/snapshot-launch.test.js +2 -2
- package/tests/unit/browser/attach/snapshot.test.js +33 -0
- package/tests/unit/browser/catalogue-launch.test.js +50 -2
- package/tests/unit/browser/default-browser.test.js +18 -0
- package/tests/unit/browser/launch-diagnostics.test.js +52 -0
- package/tests/unit/browser/real-browser.test.js +1 -1
- package/tests/unit/browser/session-workflows.test.js +206 -0
- package/tests/unit/browser/storage-state.test.js +7 -2
- package/tests/unit/core/navigation-budget.test.js +120 -0
- package/tests/unit/core/navigation-manager.test.js +23 -1
- package/tests/unit/core/webdriver-page.test.js +33 -0
- package/tests/unit/elements/reusable-helpers.test.js +83 -0
- package/tests/unit/traces/links.test.js +2 -2
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { count, isVisible } from './visibility.js';
|
|
2
|
+
import { getLocatorOrElement } from './locators.js';
|
|
3
|
+
import { clickButton } from '../interactions/click.js';
|
|
4
|
+
import { evaluate } from '../utilities/wait.js';
|
|
5
|
+
|
|
6
|
+
/** Return the first selector in caller order with a match (optionally visible). */
|
|
7
|
+
export async function findFirst({
|
|
8
|
+
selectors,
|
|
9
|
+
visible = false,
|
|
10
|
+
...options
|
|
11
|
+
} = {}) {
|
|
12
|
+
if (!Array.isArray(selectors)) {
|
|
13
|
+
throw new TypeError('selectors must be an array');
|
|
14
|
+
}
|
|
15
|
+
for (const selector of selectors) {
|
|
16
|
+
if (
|
|
17
|
+
(await count({ ...options, selector })) > 0 &&
|
|
18
|
+
(!visible || (await isVisible({ ...options, selector })))
|
|
19
|
+
) {
|
|
20
|
+
return selector;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Match any phrase in body text; Unicode whitespace includes nonbreaking spaces. */
|
|
27
|
+
export function hasText({
|
|
28
|
+
texts,
|
|
29
|
+
normalizeWhitespace = false,
|
|
30
|
+
...options
|
|
31
|
+
} = {}) {
|
|
32
|
+
if (!Array.isArray(texts) || texts.some((text) => typeof text !== 'string')) {
|
|
33
|
+
throw new TypeError('texts must be strings');
|
|
34
|
+
}
|
|
35
|
+
return evaluate({
|
|
36
|
+
...options,
|
|
37
|
+
fn: (phrases, normalize) => {
|
|
38
|
+
const clean = (text) =>
|
|
39
|
+
normalize ? text.replace(/\s+/gu, ' ').trim() : text;
|
|
40
|
+
const body = clean(document.body?.textContent ?? '');
|
|
41
|
+
return phrases.some((text) => body.includes(clean(text)));
|
|
42
|
+
},
|
|
43
|
+
args: [texts, normalizeWhitespace],
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Observe checked state of the selected checkbox or radio. */
|
|
48
|
+
export async function isChecked(options = {}) {
|
|
49
|
+
const element = await getLocatorOrElement(options);
|
|
50
|
+
if (!element) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const probe = (el) => Boolean(el.checked);
|
|
54
|
+
return options.engine === 'playwright'
|
|
55
|
+
? element.evaluate(probe)
|
|
56
|
+
: options.page.evaluate(probe, element);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Idempotently set a checkbox or radio and verify the resulting state. */
|
|
60
|
+
export async function check({ checked = true, ...options } = {}) {
|
|
61
|
+
const element = await getLocatorOrElement(options);
|
|
62
|
+
if (!element) {
|
|
63
|
+
return { checked: false, changed: false, verified: false };
|
|
64
|
+
}
|
|
65
|
+
const type =
|
|
66
|
+
options.engine === 'playwright'
|
|
67
|
+
? await element.evaluate((el) => el.type)
|
|
68
|
+
: await options.page.evaluate((el) => el.type, element);
|
|
69
|
+
if (!['checkbox', 'radio'].includes(type)) {
|
|
70
|
+
throw new TypeError('check requires a checkbox or radio');
|
|
71
|
+
}
|
|
72
|
+
if (type === 'radio' && !checked) {
|
|
73
|
+
throw new TypeError('A radio cannot be unchecked by clicking');
|
|
74
|
+
}
|
|
75
|
+
const before = await isChecked({ ...options, selector: element });
|
|
76
|
+
if (before === checked) {
|
|
77
|
+
return { checked: before, changed: false, verified: true };
|
|
78
|
+
}
|
|
79
|
+
await clickButton({
|
|
80
|
+
...options,
|
|
81
|
+
selector: element,
|
|
82
|
+
verify: false,
|
|
83
|
+
waitAfterClick: 0,
|
|
84
|
+
waitForNavigation: false,
|
|
85
|
+
});
|
|
86
|
+
const after = await isChecked({ ...options, selector: element });
|
|
87
|
+
return {
|
|
88
|
+
checked: after,
|
|
89
|
+
changed: before !== after,
|
|
90
|
+
verified: after === checked,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -19,7 +19,7 @@ export async function isVisible(options = {}) {
|
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
21
|
if (engine === 'playwright') {
|
|
22
|
-
const locator = await getLocatorOrElement(
|
|
22
|
+
const locator = await getLocatorOrElement(options);
|
|
23
23
|
try {
|
|
24
24
|
await locator.waitFor({
|
|
25
25
|
state: 'visible',
|
|
@@ -30,7 +30,7 @@ export async function isVisible(options = {}) {
|
|
|
30
30
|
return false;
|
|
31
31
|
}
|
|
32
32
|
} else {
|
|
33
|
-
const element = await getLocatorOrElement(
|
|
33
|
+
const element = await getLocatorOrElement(options);
|
|
34
34
|
if (!element) {
|
|
35
35
|
return false;
|
|
36
36
|
}
|
|
@@ -56,16 +56,11 @@ export async function isVisible(options = {}) {
|
|
|
56
56
|
* @param {Object} options.page - Browser page object
|
|
57
57
|
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
58
58
|
* @param {string|Object} options.selector - CSS selector or locator
|
|
59
|
-
* @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['
|
|
59
|
+
* @param {Array<string>} options.disabledClasses - Additional CSS classes that indicate disabled state (default: ['disabled'])
|
|
60
60
|
* @returns {Promise<boolean>} - True if enabled
|
|
61
61
|
*/
|
|
62
62
|
export async function isEnabled(options = {}) {
|
|
63
|
-
const {
|
|
64
|
-
page,
|
|
65
|
-
engine,
|
|
66
|
-
selector,
|
|
67
|
-
disabledClasses = ['magritte-button_loading'],
|
|
68
|
-
} = options;
|
|
63
|
+
const { page, engine, selector, disabledClasses = ['disabled'] } = options;
|
|
69
64
|
|
|
70
65
|
if (!selector) {
|
|
71
66
|
throw new Error('selector is required in options');
|
|
@@ -76,7 +71,7 @@ export async function isEnabled(options = {}) {
|
|
|
76
71
|
// For Playwright, use locator API
|
|
77
72
|
const locator =
|
|
78
73
|
typeof selector === 'string'
|
|
79
|
-
?
|
|
74
|
+
? (await getLocatorOrElement(options)).first()
|
|
80
75
|
: selector;
|
|
81
76
|
return await locator.evaluate((el, classes) => {
|
|
82
77
|
const isDisabled =
|
|
@@ -87,7 +82,7 @@ export async function isEnabled(options = {}) {
|
|
|
87
82
|
}, disabledClasses);
|
|
88
83
|
} else {
|
|
89
84
|
// For Puppeteer (selector should already be normalized by withTextSelectorSupport wrapper)
|
|
90
|
-
const element = await getLocatorOrElement(
|
|
85
|
+
const element = await getLocatorOrElement(options);
|
|
91
86
|
if (!element) {
|
|
92
87
|
return false;
|
|
93
88
|
}
|
package/src/exports.js
CHANGED
|
@@ -328,3 +328,17 @@ export {
|
|
|
328
328
|
SafariUnsupportedError,
|
|
329
329
|
openSafariSettings,
|
|
330
330
|
} from './browser/safari-support.js';
|
|
331
|
+
|
|
332
|
+
export { BrowserLaunchError } from './browser/launch-diagnostics.js';
|
|
333
|
+
|
|
334
|
+
export { findFirst, hasText, isChecked, check } from './elements/reusable.js';
|
|
335
|
+
export { setCookies, clearCookies } from './browser/session-cookies.js';
|
|
336
|
+
export {
|
|
337
|
+
readFlag,
|
|
338
|
+
uninstallClickListener,
|
|
339
|
+
} from './high-level/universal-logic.js';
|
|
340
|
+
|
|
341
|
+
export {
|
|
342
|
+
findSiteSessions,
|
|
343
|
+
readBrowserCookieSession,
|
|
344
|
+
} from './browser/browser-cookie-session.js';
|
package/src/factory.js
CHANGED
|
@@ -215,16 +215,16 @@ export function makeBrowserCommander(options = {}) {
|
|
|
215
215
|
// Subscribe to navigation events (legacy API)
|
|
216
216
|
onNavigationStart: navigationManager
|
|
217
217
|
? (fn) => navigationManager.on('onNavigationStart', fn)
|
|
218
|
-
: () => {},
|
|
218
|
+
: () => () => {},
|
|
219
219
|
onNavigationComplete: navigationManager
|
|
220
220
|
? (fn) => navigationManager.on('onNavigationComplete', fn)
|
|
221
|
-
: () => {},
|
|
221
|
+
: () => () => {},
|
|
222
222
|
onUrlChange: navigationManager
|
|
223
223
|
? (fn) => navigationManager.on('onUrlChange', fn)
|
|
224
|
-
: () => {},
|
|
224
|
+
: () => () => {},
|
|
225
225
|
onPageReady: navigationManager
|
|
226
226
|
? (fn) => navigationManager.on('onPageReady', fn)
|
|
227
|
-
: () => {},
|
|
227
|
+
: () => () => {},
|
|
228
228
|
|
|
229
229
|
// Abort handling - check these to stop operations when navigation occurs
|
|
230
230
|
shouldAbort: navigationManager
|
|
@@ -101,26 +101,29 @@ export async function installClickListener(options = {}) {
|
|
|
101
101
|
|
|
102
102
|
const result = await safeEvaluate({
|
|
103
103
|
fn: (text, key) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
|
|
104
|
+
window.__browserCommanderClickListeners ??= new Map();
|
|
105
|
+
const previous = window.__browserCommanderClickListeners.get(key);
|
|
106
|
+
if (previous) {
|
|
107
|
+
document.removeEventListener('click', previous, true);
|
|
108
|
+
}
|
|
109
|
+
const listener = (event) => {
|
|
110
|
+
let element = event.target;
|
|
111
|
+
while (element && element !== document.body) {
|
|
112
|
+
const elementText = element.textContent?.trim() || '';
|
|
113
|
+
if (
|
|
114
|
+
elementText === text ||
|
|
115
|
+
((element.tagName === 'A' || element.tagName === 'BUTTON') &&
|
|
116
|
+
elementText.includes(text))
|
|
117
|
+
) {
|
|
118
|
+
console.log(`[Click Listener] Detected click on ${text} button!`);
|
|
119
|
+
window.sessionStorage.setItem(key, 'true');
|
|
120
|
+
break;
|
|
120
121
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
122
|
+
element = element.parentElement;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
window.__browserCommanderClickListeners.set(key, listener);
|
|
126
|
+
document.addEventListener('click', listener, true);
|
|
124
127
|
},
|
|
125
128
|
args: [buttonText, storageKey],
|
|
126
129
|
});
|
|
@@ -176,6 +179,7 @@ export async function findToggleButton(options = {}) {
|
|
|
176
179
|
findByText,
|
|
177
180
|
dataQaSelectors = [],
|
|
178
181
|
textToFind,
|
|
182
|
+
texts = textToFind ? [textToFind] : [],
|
|
179
183
|
elementTypes = ['button', 'a', 'span'],
|
|
180
184
|
} = options;
|
|
181
185
|
|
|
@@ -188,10 +192,10 @@ export async function findToggleButton(options = {}) {
|
|
|
188
192
|
}
|
|
189
193
|
|
|
190
194
|
// Fallback to text search
|
|
191
|
-
|
|
195
|
+
for (const text of texts) {
|
|
192
196
|
for (const elementType of elementTypes) {
|
|
193
197
|
const selector = await findByText({
|
|
194
|
-
text
|
|
198
|
+
text,
|
|
195
199
|
selector: elementType,
|
|
196
200
|
});
|
|
197
201
|
const elemCount = await count({ selector });
|
|
@@ -203,3 +207,35 @@ export async function findToggleButton(options = {}) {
|
|
|
203
207
|
|
|
204
208
|
return null;
|
|
205
209
|
}
|
|
210
|
+
|
|
211
|
+
/** Read without clearing; interruption is distinct from an unset flag. */
|
|
212
|
+
export async function readFlag({ evaluate, storageKey } = {}) {
|
|
213
|
+
try {
|
|
214
|
+
const set = await evaluate({
|
|
215
|
+
fn: (key) => window.sessionStorage.getItem(key) === 'true',
|
|
216
|
+
args: [storageKey],
|
|
217
|
+
});
|
|
218
|
+
return { status: 'observed', set };
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (isNavigationError(error)) {
|
|
221
|
+
return { status: 'interrupted', set: null };
|
|
222
|
+
}
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Uninstall only the click listener installed for this storage key. */
|
|
228
|
+
export function uninstallClickListener({ evaluate, storageKey } = {}) {
|
|
229
|
+
return evaluate({
|
|
230
|
+
fn: (key) => {
|
|
231
|
+
const listener = window.__browserCommanderClickListeners?.get(key);
|
|
232
|
+
if (!listener) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
document.removeEventListener('click', listener, true);
|
|
236
|
+
window.__browserCommanderClickListeners.delete(key);
|
|
237
|
+
return true;
|
|
238
|
+
},
|
|
239
|
+
args: [storageKey],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -709,6 +709,7 @@ async function prepareElement(options = {}) {
|
|
|
709
709
|
log,
|
|
710
710
|
verbose = false,
|
|
711
711
|
selector,
|
|
712
|
+
index,
|
|
712
713
|
scrollIntoView: shouldScroll = true,
|
|
713
714
|
waitAfterScroll,
|
|
714
715
|
smoothScroll = true,
|
|
@@ -719,6 +720,7 @@ async function prepareElement(options = {}) {
|
|
|
719
720
|
page,
|
|
720
721
|
engine,
|
|
721
722
|
selector,
|
|
723
|
+
index,
|
|
722
724
|
timeout,
|
|
723
725
|
});
|
|
724
726
|
|
|
@@ -982,6 +984,7 @@ export async function clickButton(options = {}) {
|
|
|
982
984
|
log,
|
|
983
985
|
verbose,
|
|
984
986
|
selector,
|
|
987
|
+
index: options.index,
|
|
985
988
|
scrollIntoView: activationOptions.scroll === CLICK_SCROLL.AUTO,
|
|
986
989
|
waitAfterScroll,
|
|
987
990
|
smoothScroll,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getLocatorOrElement } from '../elements/locators.js';
|
|
1
2
|
import { TIMING } from '../core/constants.js';
|
|
2
3
|
import { isNavigationError } from '../core/navigation-safety.js';
|
|
3
4
|
import { isActionStoppedError } from '../core/page-trigger-manager.js';
|
|
@@ -146,7 +147,7 @@ export async function scrollIntoView(options = {}) {
|
|
|
146
147
|
const {
|
|
147
148
|
page,
|
|
148
149
|
engine,
|
|
149
|
-
locatorOrElement,
|
|
150
|
+
locatorOrElement: suppliedElement,
|
|
150
151
|
behavior = 'smooth',
|
|
151
152
|
verify = true,
|
|
152
153
|
verifyFn,
|
|
@@ -154,8 +155,13 @@ export async function scrollIntoView(options = {}) {
|
|
|
154
155
|
log = { debug: () => {} },
|
|
155
156
|
} = options;
|
|
156
157
|
|
|
158
|
+
const locatorOrElement =
|
|
159
|
+
suppliedElement ??
|
|
160
|
+
(options.selector ? await getLocatorOrElement(options) : null);
|
|
157
161
|
if (!locatorOrElement) {
|
|
158
|
-
throw new Error(
|
|
162
|
+
throw new Error(
|
|
163
|
+
'locatorOrElement is required (or provide selector) in options'
|
|
164
|
+
);
|
|
159
165
|
}
|
|
160
166
|
|
|
161
167
|
try {
|
|
@@ -14,12 +14,16 @@ import { ProcessRunner } from 'command-stream/process-runner';
|
|
|
14
14
|
* optional PTY and terminal-rendering modules are never loaded.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
function runnerFor(
|
|
17
|
+
function runnerFor(
|
|
18
|
+
file,
|
|
19
|
+
args,
|
|
20
|
+
{ env, cwd, stdin = 'ignore', killGrace, capture = true }
|
|
21
|
+
) {
|
|
18
22
|
return new ProcessRunner(
|
|
19
23
|
{ mode: 'exec', file, args },
|
|
20
24
|
{
|
|
21
25
|
mirror: false,
|
|
22
|
-
capture
|
|
26
|
+
capture,
|
|
23
27
|
stdin,
|
|
24
28
|
...(env ? { env } : {}),
|
|
25
29
|
...(cwd ? { cwd } : {}),
|
|
@@ -102,7 +106,7 @@ export async function runCommand(file, args = [], options = {}) {
|
|
|
102
106
|
*/
|
|
103
107
|
export function startProcess(file, args = [], options = {}) {
|
|
104
108
|
const { env, cwd, forwardOutput = false, killGrace = 2000 } = options;
|
|
105
|
-
const runner = runnerFor(file, args, { env, cwd, killGrace });
|
|
109
|
+
const runner = runnerFor(file, args, { env, cwd, killGrace, capture: false });
|
|
106
110
|
return new ManagedProcess(runner, { forwardOutput });
|
|
107
111
|
}
|
|
108
112
|
|
|
@@ -136,6 +140,8 @@ export class ManagedProcess {
|
|
|
136
140
|
this.runner = runner;
|
|
137
141
|
this.exitCode = null;
|
|
138
142
|
this.signalCode = null;
|
|
143
|
+
this.stderrTail = '';
|
|
144
|
+
this.spawnError = null;
|
|
139
145
|
this.stdout = new OutputChannel();
|
|
140
146
|
this.stderr = new OutputChannel();
|
|
141
147
|
this.exitListeners = new Set();
|
|
@@ -146,6 +152,7 @@ export class ManagedProcess {
|
|
|
146
152
|
this.stdout.emit(chunk);
|
|
147
153
|
});
|
|
148
154
|
runner.on('stderr', (chunk) => {
|
|
155
|
+
this.stderrTail = (this.stderrTail + String(chunk)).slice(-8192);
|
|
149
156
|
if (forwardOutput) {
|
|
150
157
|
process.stderr.write(chunk);
|
|
151
158
|
}
|
|
@@ -153,9 +160,13 @@ export class ManagedProcess {
|
|
|
153
160
|
});
|
|
154
161
|
runner.start();
|
|
155
162
|
this.exited = Promise.resolve(runner)
|
|
156
|
-
.catch((error) =>
|
|
163
|
+
.catch((error) => {
|
|
164
|
+
this.spawnError = error;
|
|
165
|
+
return { code: error?.code ?? 1 };
|
|
166
|
+
})
|
|
157
167
|
.then((result) => {
|
|
158
168
|
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
169
|
+
this.signalCode = result.signal ?? null;
|
|
159
170
|
for (const listener of this.exitListeners) {
|
|
160
171
|
listener(this.exitCode);
|
|
161
172
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
4
|
+
import { createCommander, predicate } from '../../src/index.js';
|
|
5
|
+
import { sendHtml, startFixtureHost } from '../helpers/fixture-server.js';
|
|
6
|
+
|
|
7
|
+
// Authored local fixtures only. Each deliberately stalled connection is closed
|
|
8
|
+
// by server teardown; browser actions and suites have finite budgets.
|
|
9
|
+
for (const engine of ['playwright', 'puppeteer']) {
|
|
10
|
+
describe(
|
|
11
|
+
`navigation budgets: ${engine}`,
|
|
12
|
+
{ skip: !process.env.RUN_E2E, timeout: 60000 },
|
|
13
|
+
() => {
|
|
14
|
+
let browser;
|
|
15
|
+
let page;
|
|
16
|
+
let commander;
|
|
17
|
+
let server;
|
|
18
|
+
before(async () => {
|
|
19
|
+
server = await startFixtureHost((route, _req, res) => {
|
|
20
|
+
if (route === '/stall' || route === '/pending') {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (route === '/redirect') {
|
|
24
|
+
res.writeHead(302, { location: '/ready' });
|
|
25
|
+
res.end();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (route === '/continuous') {
|
|
29
|
+
sendHtml(
|
|
30
|
+
res,
|
|
31
|
+
'<h1>Continuous requests</h1><script>setInterval(() => fetch("/pending"), 60);</script>'
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
sendHtml(res, '<h1 id="ready">Ready</h1>');
|
|
36
|
+
});
|
|
37
|
+
const { chromium } = await import('playwright');
|
|
38
|
+
const launchOptions = { headless: true, ...CHROME_LAUNCH_OPTIONS };
|
|
39
|
+
if (engine === 'playwright') {
|
|
40
|
+
browser = await chromium.launch(launchOptions);
|
|
41
|
+
page = await browser.newPage();
|
|
42
|
+
} else {
|
|
43
|
+
const { default: puppeteer } = await import('puppeteer');
|
|
44
|
+
browser = await puppeteer.launch({
|
|
45
|
+
...launchOptions,
|
|
46
|
+
executablePath:
|
|
47
|
+
CHROME_LAUNCH_OPTIONS.executablePath ?? chromium.executablePath(),
|
|
48
|
+
});
|
|
49
|
+
page = await browser.newPage();
|
|
50
|
+
}
|
|
51
|
+
commander = createCommander({ page, engine });
|
|
52
|
+
});
|
|
53
|
+
after(async () => {
|
|
54
|
+
await commander?.destroy();
|
|
55
|
+
await browser?.close();
|
|
56
|
+
await server?.close();
|
|
57
|
+
});
|
|
58
|
+
const navigate = async (url, options = {}) => {
|
|
59
|
+
const start = performance.now();
|
|
60
|
+
const result = await commander.goto({
|
|
61
|
+
url,
|
|
62
|
+
timeout: 800,
|
|
63
|
+
verify: false,
|
|
64
|
+
waitForStableUrlBefore: false,
|
|
65
|
+
waitForStableUrlAfter: false,
|
|
66
|
+
waitForNetworkIdle: false,
|
|
67
|
+
...options,
|
|
68
|
+
});
|
|
69
|
+
assert.ok(
|
|
70
|
+
performance.now() - start < 1600,
|
|
71
|
+
'end-to-end budget must bound elapsed time'
|
|
72
|
+
);
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
it('loads a data page quickly with tracking enabled', async () => {
|
|
76
|
+
assert.equal(
|
|
77
|
+
(await navigate('data:text/html,<h1>Authored fixture</h1>')).status,
|
|
78
|
+
'ready'
|
|
79
|
+
);
|
|
80
|
+
assert.ok(commander.networkTracker);
|
|
81
|
+
assert.ok(page.listenerCount?.('request') > 0);
|
|
82
|
+
});
|
|
83
|
+
it('follows redirects under the same deadline', async () => {
|
|
84
|
+
const result = await navigate(`${server.baseUrl}/redirect`);
|
|
85
|
+
assert.equal(result.status, 'ready');
|
|
86
|
+
assert.equal(page.url(), `${server.baseUrl}/ready`);
|
|
87
|
+
});
|
|
88
|
+
it('times out continuous network activity and cleans navigation state', async () => {
|
|
89
|
+
const result = await navigate(`${server.baseUrl}/continuous`, {
|
|
90
|
+
waitForNetworkIdle: true,
|
|
91
|
+
});
|
|
92
|
+
assert.equal(result.status, 'timed_out');
|
|
93
|
+
assert.equal(commander.navigationManager.isNavigating(), false);
|
|
94
|
+
});
|
|
95
|
+
it('bounds a stalled engine navigation', async () => {
|
|
96
|
+
assert.equal(
|
|
97
|
+
(await navigate(`${server.baseUrl}/stall`)).status,
|
|
98
|
+
'timed_out'
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
it('observes a positive selector check', async () => {
|
|
102
|
+
const ready = predicate({
|
|
103
|
+
name: 'selector_ready',
|
|
104
|
+
fn: async () =>
|
|
105
|
+
page.evaluate(() => Boolean(document.querySelector('#ready'))),
|
|
106
|
+
});
|
|
107
|
+
assert.equal(
|
|
108
|
+
(await navigate(`${server.baseUrl}/ready`, { checks: [ready] }))
|
|
109
|
+
.status,
|
|
110
|
+
'ready'
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
it('cancels a stalled call and preserves only the session listeners', async () => {
|
|
114
|
+
const abort = new AbortController();
|
|
115
|
+
const event =
|
|
116
|
+
engine === 'playwright' ? 'framenavigated' : 'framenavigated';
|
|
117
|
+
const count = page.listenerCount?.(event);
|
|
118
|
+
const timer = setTimeout(() => abort.abort(), 80);
|
|
119
|
+
try {
|
|
120
|
+
const result = await navigate(`${server.baseUrl}/stall`, {
|
|
121
|
+
signal: abort.signal,
|
|
122
|
+
});
|
|
123
|
+
assert.equal(result.status, 'interrupted');
|
|
124
|
+
assert.equal(commander.navigationManager.isNavigating(), false);
|
|
125
|
+
assert.equal(page.listenerCount?.(event), count);
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
);
|
|
132
|
+
}
|