browser-commander 0.31.1 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/browser/connection-stage.js +23 -0
- package/src/browser/connector.js +29 -15
- package/src/browser/launch-diagnostics.js +4 -1
- package/src/core/engine-adapter.js +0 -7
- package/src/elements/locators.js +1 -10
- package/src/elements/selectors.js +10 -155
- package/src/elements/text-selector.js +23 -0
- package/src/utilities/subprocess.js +37 -2
- package/tests/e2e/selector-identity.e2e.test.js +104 -0
- package/tests/e2e/startup-readiness.e2e.test.js +95 -0
- package/tests/unit/browser/connection-stage.test.js +74 -0
- package/tests/unit/browser/launch-diagnostics.test.js +23 -1
- package/tests/unit/browser/startup-readiness.test.js +129 -0
- package/tests/unit/elements/selector-identity.test.js +50 -0
- package/tests/unit/elements/selectors.test.js +12 -26
- package/tests/unit/scripts/ci-timeout-budgets.test.js +1 -0
- package/tests/unit/utilities/subprocess.test.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.31.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9f486af: Preserve native Playwright text selectors and CSS sibling positions. Resolve nonnative text matches to unique ancestor paths so interactions keep their intended target across Playwright, Puppeteer, and Selenium.
|
|
8
|
+
|
|
9
|
+
Retain sanitized engine failures alongside Chrome stderr in launch error messages. Identify attachment stages and optionally log their timing with verbose mode; wait for actual child exit before profile cleanup, and verify readiness, cleanup ownership, and fresh-profile startup on macOS in CI.
|
|
10
|
+
|
|
3
11
|
## 0.31.1
|
|
4
12
|
|
|
5
13
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Identify attachment failures; optional logs contain stage names and timings. */
|
|
2
|
+
export async function connectionStage(options, stage, work) {
|
|
3
|
+
const start = performance.now();
|
|
4
|
+
const report = (state) => {
|
|
5
|
+
if (options.verbose) {
|
|
6
|
+
console.log(
|
|
7
|
+
`Browser attachment ${stage} ${state} after ${Math.round(performance.now() - start)}ms`
|
|
8
|
+
);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
report('started');
|
|
12
|
+
try {
|
|
13
|
+
const result = await work();
|
|
14
|
+
report('completed');
|
|
15
|
+
return result;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (error && typeof error === 'object' && Object.isExtensible(error)) {
|
|
18
|
+
error.attachmentStage ??= stage;
|
|
19
|
+
}
|
|
20
|
+
report('failed');
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/browser/connector.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { attachDownloads } from '../downloads/attach.js';
|
|
2
|
+
import { connectionStage } from './connection-stage.js';
|
|
2
3
|
import { connectWebDriver, launchWebDriver } from './webdriver.js';
|
|
3
4
|
import {
|
|
4
5
|
loadStorageState,
|
|
@@ -282,7 +283,9 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
282
283
|
const settings = buildPlaywrightConnectOptions(options);
|
|
283
284
|
let browser;
|
|
284
285
|
try {
|
|
285
|
-
browser = await
|
|
286
|
+
browser = await connectionStage(options, 'engine-connect', () =>
|
|
287
|
+
chromium.connectOverCDP(endpoint, settings)
|
|
288
|
+
);
|
|
286
289
|
} catch (error) {
|
|
287
290
|
if (
|
|
288
291
|
settings.noDefaults !== undefined ||
|
|
@@ -291,21 +294,26 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
291
294
|
) {
|
|
292
295
|
throw error;
|
|
293
296
|
}
|
|
294
|
-
browser = await
|
|
295
|
-
...settings,
|
|
296
|
-
|
|
297
|
-
});
|
|
297
|
+
browser = await connectionStage(options, 'engine-connect', () =>
|
|
298
|
+
chromium.connectOverCDP(endpoint, { ...settings, noDefaults: true })
|
|
299
|
+
);
|
|
298
300
|
}
|
|
299
301
|
try {
|
|
300
302
|
const context = browser.contexts()[0];
|
|
301
303
|
if (!context) {
|
|
302
304
|
throw new Error('Connected Playwright browser has no default context');
|
|
303
305
|
}
|
|
304
|
-
const page =
|
|
305
|
-
|
|
306
|
-
|
|
306
|
+
const page = await connectionStage(
|
|
307
|
+
options,
|
|
308
|
+
'page-selection',
|
|
309
|
+
async () =>
|
|
310
|
+
(await pickForegroundPage(context.pages(), options)) ??
|
|
311
|
+
(await context.newPage())
|
|
312
|
+
);
|
|
307
313
|
|
|
308
|
-
await
|
|
314
|
+
await connectionStage(options, 'storage-restore', () =>
|
|
315
|
+
restorePlaywrightStorageState({ context, storageState })
|
|
316
|
+
);
|
|
309
317
|
return { browser, page, detach: browser.close?.bind(browser) };
|
|
310
318
|
} catch (error) {
|
|
311
319
|
await ignoreCleanup(() => browser.close?.());
|
|
@@ -316,15 +324,21 @@ async function connectPlaywright({ options, loadPlaywright, storageState }) {
|
|
|
316
324
|
async function connectPuppeteer({ options, loadPuppeteer, storageState }) {
|
|
317
325
|
const puppeteerModule = await loadPuppeteer();
|
|
318
326
|
const puppeteer = puppeteerModule.default ?? puppeteerModule;
|
|
319
|
-
const browser = await
|
|
320
|
-
buildPuppeteerConnectOptions(options)
|
|
327
|
+
const browser = await connectionStage(options, 'engine-connect', () =>
|
|
328
|
+
puppeteer.connect(buildPuppeteerConnectOptions(options))
|
|
321
329
|
);
|
|
322
330
|
try {
|
|
323
|
-
const page =
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
const page = await connectionStage(
|
|
332
|
+
options,
|
|
333
|
+
'page-selection',
|
|
334
|
+
async () =>
|
|
335
|
+
(await pickForegroundPage(await browser.pages(), options)) ??
|
|
336
|
+
(await browser.newPage())
|
|
337
|
+
);
|
|
326
338
|
|
|
327
|
-
await
|
|
339
|
+
await connectionStage(options, 'storage-restore', () =>
|
|
340
|
+
restorePuppeteerStorageState({ page, storageState })
|
|
341
|
+
);
|
|
328
342
|
return { browser, page, detach: browser.disconnect?.bind(browser) };
|
|
329
343
|
} catch (error) {
|
|
330
344
|
await ignoreCleanup(() => browser.disconnect?.());
|
|
@@ -7,10 +7,11 @@ export class BrowserLaunchError extends Error {
|
|
|
7
7
|
exitCode = null,
|
|
8
8
|
signal = null,
|
|
9
9
|
stderrTail = '',
|
|
10
|
+
attachmentStage,
|
|
10
11
|
cause,
|
|
11
12
|
}) {
|
|
12
13
|
super(
|
|
13
|
-
`Browser launch failed (${phase}: ${category}): ${
|
|
14
|
+
`Browser launch failed (${phase}: ${category}): ${cause?.message || ''}${stderrTail ? `\nBrowser stderr:\n${stderrTail}` : ''}`,
|
|
14
15
|
{ cause }
|
|
15
16
|
);
|
|
16
17
|
this.name = 'BrowserLaunchError';
|
|
@@ -21,6 +22,7 @@ export class BrowserLaunchError extends Error {
|
|
|
21
22
|
exitCode,
|
|
22
23
|
signal,
|
|
23
24
|
stderrTail,
|
|
25
|
+
...(attachmentStage ? { attachmentStage } : {}),
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
28
|
}
|
|
@@ -98,5 +100,6 @@ export function launchFailure(
|
|
|
98
100
|
signal: browserProcess?.signalCode ?? null,
|
|
99
101
|
stderrTail: scrub(browserProcess?.stderrTail ?? cause?.stderrTail ?? ''),
|
|
100
102
|
cause: safeCause,
|
|
103
|
+
attachmentStage: cause?.attachmentStage,
|
|
101
104
|
});
|
|
102
105
|
}
|
|
@@ -284,13 +284,6 @@ export class PlaywrightAdapter extends EngineAdapter {
|
|
|
284
284
|
// ============================================================================
|
|
285
285
|
|
|
286
286
|
createLocator(selector) {
|
|
287
|
-
// Handle :nth-of-type() pseudo-selectors which don't work in Playwright locators
|
|
288
|
-
const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
|
|
289
|
-
if (nthOfTypeMatch) {
|
|
290
|
-
const baseSelector = nthOfTypeMatch[1];
|
|
291
|
-
const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based
|
|
292
|
-
return this.page.locator(baseSelector).nth(index);
|
|
293
|
-
}
|
|
294
287
|
return this.page.locator(selector);
|
|
295
288
|
}
|
|
296
289
|
|
package/src/elements/locators.js
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Helper to create Playwright locator from selector string
|
|
9
|
-
*
|
|
9
|
+
* Preserves native CSS and text selector semantics.
|
|
10
10
|
* @param {Object} options - Configuration options
|
|
11
11
|
* @param {Object} options.page - Browser page object
|
|
12
12
|
* @param {string} options.selector - CSS selector
|
|
@@ -22,15 +22,6 @@ export function createPlaywrightLocator(options = {}) {
|
|
|
22
22
|
const { page, selector } = options;
|
|
23
23
|
|
|
24
24
|
requireSelector(selector);
|
|
25
|
-
// Check if selector has :nth-of-type(n) pattern
|
|
26
|
-
const nthOfTypeMatch = selector.match(/^(.+):nth-of-type\((\d+)\)$/);
|
|
27
|
-
|
|
28
|
-
if (nthOfTypeMatch) {
|
|
29
|
-
const baseSelector = nthOfTypeMatch[1];
|
|
30
|
-
const index = parseInt(nthOfTypeMatch[2], 10) - 1; // Convert to 0-based index
|
|
31
|
-
return page.locator(baseSelector).nth(index);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
25
|
return page.locator(selector);
|
|
35
26
|
}
|
|
36
27
|
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
recoverWaitError,
|
|
5
5
|
} from '../core/navigation-safety.js';
|
|
6
6
|
import { createPlaywrightLocator, requireSelector } from './locators.js';
|
|
7
|
+
import { textSelectorInPage } from './text-selector.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Query single element
|
|
@@ -80,75 +81,18 @@ export async function findByText(options = {}) {
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
/**
|
|
84
|
-
* Check if a selector is a Playwright-specific text selector
|
|
85
|
-
* @param {string} selector - The selector to check
|
|
86
|
-
* @returns {boolean} - True if selector contains Playwright text pseudo-selectors
|
|
87
|
-
*/
|
|
88
|
-
function isPlaywrightTextSelector(selector) {
|
|
89
|
-
if (typeof selector !== 'string') {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
return selector.includes(':has-text(') || selector.includes(':text-is(');
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Split `base:<pseudo>("text")` into its base selector and text.
|
|
97
|
-
*
|
|
98
|
-
* String operations rather than a regular expression: the former
|
|
99
|
-
* /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically on long selectors
|
|
100
|
-
* that do not match (CodeQL js/polynomial-redos). The base is everything before
|
|
101
|
-
* the first `:<pseudo>("`, as the lazy `(.+?)` made it; base and text must be
|
|
102
|
-
* non-empty.
|
|
103
|
-
* @param {string} selector - Selector to split
|
|
104
|
-
* @param {string} pseudo - Pseudo-class name, e.g. 'has-text'
|
|
105
|
-
* @returns {{baseSelector: string, text: string}|null} - Parts, or null
|
|
106
|
-
*/
|
|
107
|
-
function splitTextPseudo(selector, pseudo) {
|
|
108
|
-
const open = `:${pseudo}("`;
|
|
109
|
-
const start = selector.indexOf(open, 1);
|
|
110
|
-
if (start === -1 || !selector.endsWith('")')) {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
const text = selector.slice(start + open.length, -2);
|
|
114
|
-
if (!text) {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
return { baseSelector: selector.slice(0, start), text };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Parse a Playwright text selector to extract base selector and text
|
|
122
|
-
* @param {string} selector - Playwright text selector like 'a:has-text("text")'
|
|
123
|
-
* @returns {Object|null} - { baseSelector, text, exact } or null if not parseable
|
|
124
|
-
*/
|
|
125
|
-
function parsePlaywrightTextSelector(selector) {
|
|
126
|
-
// Match patterns like 'a:has-text("text")' or 'button:text-is("exact text")'
|
|
127
|
-
const hasText = splitTextPseudo(selector, 'has-text');
|
|
128
|
-
if (hasText) {
|
|
129
|
-
return { ...hasText, exact: false };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const textIs = splitTextPseudo(selector, 'text-is');
|
|
133
|
-
if (textIs) {
|
|
134
|
-
return { ...textIs, exact: true };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
84
|
/**
|
|
141
85
|
* Normalize selector to handle both Puppeteer and Playwright text selectors
|
|
142
|
-
*
|
|
86
|
+
* Preserves native Playwright strings; resolves text objects to unique CSS paths.
|
|
143
87
|
*
|
|
144
88
|
* @param {Object} options - Configuration options
|
|
145
89
|
* @param {Object} options.page - Browser page object
|
|
146
90
|
* @param {string} options.engine - Engine type ('playwright' or 'puppeteer')
|
|
147
91
|
* @param {string|Object} options.selector - CSS selector or text selector object
|
|
148
|
-
* @returns {Promise<string|null>} -
|
|
92
|
+
* @returns {Promise<string|null>} - Native selector, unique CSS path, or null
|
|
149
93
|
*/
|
|
150
94
|
export async function normalizeSelector(options = {}) {
|
|
151
|
-
const { page,
|
|
95
|
+
const { page, selector } = options;
|
|
152
96
|
|
|
153
97
|
requireSelector(selector);
|
|
154
98
|
|
|
@@ -171,59 +115,6 @@ export async function normalizeSelector(options = {}) {
|
|
|
171
115
|
return null;
|
|
172
116
|
}
|
|
173
117
|
|
|
174
|
-
// Handle Playwright text selectors (strings containing :has-text or :text-is)
|
|
175
|
-
// These are valid for Playwright's locator API but NOT for document.querySelectorAll
|
|
176
|
-
if (
|
|
177
|
-
typeof selector === 'string' &&
|
|
178
|
-
engine === 'playwright' &&
|
|
179
|
-
isPlaywrightTextSelector(selector)
|
|
180
|
-
) {
|
|
181
|
-
const parsed = parsePlaywrightTextSelector(selector);
|
|
182
|
-
if (!parsed) {
|
|
183
|
-
// Could not parse, return as-is and hope for the best
|
|
184
|
-
return selector;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
// Use page.evaluate to find matching element and generate a valid CSS selector
|
|
189
|
-
const result = await page.evaluate(({ baseSelector, text, exact }) => {
|
|
190
|
-
const elements = Array.from(document.querySelectorAll(baseSelector));
|
|
191
|
-
const matchingElement = elements.find((el) => {
|
|
192
|
-
const elementText = el.textContent.trim();
|
|
193
|
-
return exact ? elementText === text : elementText.includes(text);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
if (!matchingElement) {
|
|
197
|
-
return null;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Generate a unique selector using data-qa or nth-of-type
|
|
201
|
-
const dataQa = matchingElement.getAttribute('data-qa');
|
|
202
|
-
if (dataQa) {
|
|
203
|
-
return `[data-qa="${dataQa}"]`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Use nth-of-type as fallback
|
|
207
|
-
const tagName = matchingElement.tagName.toLowerCase();
|
|
208
|
-
const siblings = Array.from(
|
|
209
|
-
matchingElement.parentElement.children
|
|
210
|
-
).filter((el) => el.tagName.toLowerCase() === tagName);
|
|
211
|
-
const index = siblings.indexOf(matchingElement);
|
|
212
|
-
return `${tagName}:nth-of-type(${index + 1})`;
|
|
213
|
-
}, parsed);
|
|
214
|
-
|
|
215
|
-
return result;
|
|
216
|
-
} catch (error) {
|
|
217
|
-
if (isNavigationError(error)) {
|
|
218
|
-
console.log(
|
|
219
|
-
'⚠️ Navigation detected during normalizeSelector (Playwright), returning null'
|
|
220
|
-
);
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
throw error;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
118
|
// Plain string selector - return as-is
|
|
228
119
|
if (typeof selector === 'string') {
|
|
229
120
|
return selector;
|
|
@@ -233,36 +124,11 @@ export async function normalizeSelector(options = {}) {
|
|
|
233
124
|
if (selector._isPuppeteerTextSelector) {
|
|
234
125
|
try {
|
|
235
126
|
// Find element by text and generate a unique selector
|
|
236
|
-
const result = await page.evaluate(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
return exact ? elementText === text : elementText.includes(text);
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
if (!matchingElement) {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// Generate a unique selector using data-qa or nth-of-type
|
|
249
|
-
const dataQa = matchingElement.getAttribute('data-qa');
|
|
250
|
-
if (dataQa) {
|
|
251
|
-
return `[data-qa="${dataQa}"]`;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// Use nth-of-type as fallback
|
|
255
|
-
const tagName = matchingElement.tagName.toLowerCase();
|
|
256
|
-
const siblings = Array.from(
|
|
257
|
-
matchingElement.parentElement.children
|
|
258
|
-
).filter((el) => el.tagName.toLowerCase() === tagName);
|
|
259
|
-
const index = siblings.indexOf(matchingElement);
|
|
260
|
-
return `${tagName}:nth-of-type(${index + 1})`;
|
|
261
|
-
},
|
|
262
|
-
selector.baseSelector,
|
|
263
|
-
selector.text,
|
|
264
|
-
selector.exact
|
|
265
|
-
);
|
|
127
|
+
const result = await page.evaluate(textSelectorInPage, {
|
|
128
|
+
baseSelector: selector.baseSelector,
|
|
129
|
+
text: selector.text,
|
|
130
|
+
exact: selector.exact,
|
|
131
|
+
});
|
|
266
132
|
|
|
267
133
|
return result;
|
|
268
134
|
} catch (error) {
|
|
@@ -296,6 +162,7 @@ export function withTextSelectorSupport(fn, engine, page) {
|
|
|
296
162
|
// selenium)
|
|
297
163
|
if (
|
|
298
164
|
engine !== 'playwright' &&
|
|
165
|
+
selector &&
|
|
299
166
|
typeof selector === 'object' &&
|
|
300
167
|
selector._isPuppeteerTextSelector
|
|
301
168
|
) {
|
|
@@ -305,18 +172,6 @@ export function withTextSelectorSupport(fn, engine, page) {
|
|
|
305
172
|
}
|
|
306
173
|
}
|
|
307
174
|
|
|
308
|
-
// Normalize Playwright text selectors (string format with :has-text or :text-is)
|
|
309
|
-
if (
|
|
310
|
-
engine === 'playwright' &&
|
|
311
|
-
typeof selector === 'string' &&
|
|
312
|
-
isPlaywrightTextSelector(selector)
|
|
313
|
-
) {
|
|
314
|
-
selector = await normalizeSelector({ page, engine, selector });
|
|
315
|
-
if (!selector) {
|
|
316
|
-
throw new Error('Element with specified text not found');
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
|
|
320
175
|
return fn({ ...options, selector });
|
|
321
176
|
};
|
|
322
177
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Resolve a text match to an unambiguous CSS path without changing the DOM. */
|
|
2
|
+
export function textSelectorInPage({ baseSelector, text, exact }) {
|
|
3
|
+
const matching = Array.from(document.querySelectorAll(baseSelector)).find(
|
|
4
|
+
(element) => {
|
|
5
|
+
const content = element.textContent.trim();
|
|
6
|
+
return exact ? content === text : content.includes(text);
|
|
7
|
+
}
|
|
8
|
+
);
|
|
9
|
+
if (!matching) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const steps = [];
|
|
13
|
+
for (let node = matching; node; node = node.parentElement) {
|
|
14
|
+
const siblings = Array.from(node.parentElement?.children ?? []).filter(
|
|
15
|
+
(sibling) => sibling.localName === node.localName
|
|
16
|
+
);
|
|
17
|
+
const index = siblings.indexOf(node) + 1;
|
|
18
|
+
steps.unshift(
|
|
19
|
+
`${window.CSS.escape(node.localName)}:nth-of-type(${Math.max(1, index)})`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
return steps.join(' > ');
|
|
23
|
+
}
|
|
@@ -177,6 +177,7 @@ export class ManagedProcess {
|
|
|
177
177
|
this.stdout = new OutputChannel();
|
|
178
178
|
this.stderr = new OutputChannel();
|
|
179
179
|
this.exitListeners = new Set();
|
|
180
|
+
this.termination = null;
|
|
180
181
|
runner.on('stdout', (chunk) => {
|
|
181
182
|
if (forwardOutput) {
|
|
182
183
|
process.stdout.write(chunk);
|
|
@@ -196,7 +197,15 @@ export class ManagedProcess {
|
|
|
196
197
|
this.spawnError = error;
|
|
197
198
|
return { code: error?.code ?? 1 };
|
|
198
199
|
})
|
|
199
|
-
.then((result) => {
|
|
200
|
+
.then(async (result) => {
|
|
201
|
+
const actualExit = await this.termination;
|
|
202
|
+
if (actualExit) {
|
|
203
|
+
result = {
|
|
204
|
+
...result,
|
|
205
|
+
code: actualExit.code ?? result.code,
|
|
206
|
+
signal: actualExit.signal,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
200
209
|
this.exitCode = typeof result.code === 'number' ? result.code : 1;
|
|
201
210
|
this.signalCode = result.signal ?? null;
|
|
202
211
|
for (const listener of this.exitListeners) {
|
|
@@ -233,10 +242,36 @@ export class ManagedProcess {
|
|
|
233
242
|
* @returns {boolean} Whether a running process was signalled
|
|
234
243
|
*/
|
|
235
244
|
kill(signal) {
|
|
236
|
-
if (this.exitCode !== null) {
|
|
245
|
+
if (this.exitCode !== null || this.termination) {
|
|
237
246
|
return false;
|
|
238
247
|
}
|
|
248
|
+
// command-stream resolves cancellation immediately and removes the child's
|
|
249
|
+
// listeners. Retain its handle and observe the real exit after signalling,
|
|
250
|
+
// so launchers cannot delete profiles while Chrome still writes to them.
|
|
251
|
+
const child = this.runner.child;
|
|
252
|
+
let resolveExit;
|
|
253
|
+
if (child?.pid) {
|
|
254
|
+
this.termination = new Promise((resolve) => {
|
|
255
|
+
resolveExit = resolve;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
239
258
|
this.runner.kill(signal);
|
|
259
|
+
if (resolveExit) {
|
|
260
|
+
if (child.exited) {
|
|
261
|
+
child.exited.then((code) =>
|
|
262
|
+
resolveExit({ code, signal: child.signalCode ?? null })
|
|
263
|
+
);
|
|
264
|
+
} else if (
|
|
265
|
+
Number.isInteger(child.exitCode) ||
|
|
266
|
+
typeof child.signalCode === 'string'
|
|
267
|
+
) {
|
|
268
|
+
resolveExit({ code: child.exitCode, signal: child.signalCode });
|
|
269
|
+
} else {
|
|
270
|
+
child.once('exit', (code, exitSignal) =>
|
|
271
|
+
resolveExit({ code, signal: exitSignal })
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
240
275
|
return true;
|
|
241
276
|
}
|
|
242
277
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
4
|
+
import { createEngineAdapter } from '../../src/core/engine-adapter.js';
|
|
5
|
+
import { launchE2EBrowser } from '../helpers/e2e-browser.js';
|
|
6
|
+
|
|
7
|
+
for (const engine of ['playwright', 'puppeteer', 'selenium']) {
|
|
8
|
+
it(
|
|
9
|
+
`${engine} keeps scoped text selectors on the intended element`,
|
|
10
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
|
|
11
|
+
async () => {
|
|
12
|
+
const session = await launchE2EBrowser({ engine });
|
|
13
|
+
const commander = makeBrowserCommander({
|
|
14
|
+
page: session.page,
|
|
15
|
+
verbose: false,
|
|
16
|
+
debug: false,
|
|
17
|
+
enableNetworkTracking: false,
|
|
18
|
+
enableNavigationManager: false,
|
|
19
|
+
});
|
|
20
|
+
try {
|
|
21
|
+
await session.page.setContent(`
|
|
22
|
+
<nav><button>A</button><button data-qa='duplicate'
|
|
23
|
+
onclick="window.clicked='nav-2'">B</button></nav>
|
|
24
|
+
<div class="parent"><section><button>A</button>
|
|
25
|
+
<button data-qa='duplicate' data-target='B'
|
|
26
|
+
onclick="window.clicked='B'; this.setAttribute('aria-pressed','true')">B</button>
|
|
27
|
+
<textarea>Edit</textarea>
|
|
28
|
+
</section></div>`);
|
|
29
|
+
for (const exact of [false, true]) {
|
|
30
|
+
const selector = await commander.findByText({
|
|
31
|
+
selector: '.parent button',
|
|
32
|
+
text: 'B',
|
|
33
|
+
exact,
|
|
34
|
+
});
|
|
35
|
+
assert.equal(
|
|
36
|
+
await commander.getAttribute({
|
|
37
|
+
selector,
|
|
38
|
+
attribute: 'data-target',
|
|
39
|
+
}),
|
|
40
|
+
'B'
|
|
41
|
+
);
|
|
42
|
+
assert.equal(await commander.textContent({ selector }), 'B');
|
|
43
|
+
assert.equal(await commander.isVisible({ selector }), true);
|
|
44
|
+
assert.equal(await commander.isEnabled({ selector }), true);
|
|
45
|
+
await commander.clickButton({ selector, verify: false });
|
|
46
|
+
assert.equal(await session.page.evaluate(() => window.clicked), 'B');
|
|
47
|
+
}
|
|
48
|
+
const editable = await commander.findByText({
|
|
49
|
+
selector: '.parent textarea',
|
|
50
|
+
text: 'Edit',
|
|
51
|
+
exact: true,
|
|
52
|
+
});
|
|
53
|
+
await commander.fillTextArea({
|
|
54
|
+
selector: editable,
|
|
55
|
+
text: 'Changed',
|
|
56
|
+
checkEmpty: false,
|
|
57
|
+
simulateTyping: false,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(
|
|
60
|
+
await session.page.evaluate(
|
|
61
|
+
() => document.querySelector('.parent textarea').value
|
|
62
|
+
),
|
|
63
|
+
'Changed'
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
await commander.destroy();
|
|
67
|
+
await session.cleanup();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
it(
|
|
74
|
+
'Playwright preserves CSS sibling position across multiple parents',
|
|
75
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 60000 },
|
|
76
|
+
async () => {
|
|
77
|
+
const session = await launchE2EBrowser({ engine: 'playwright' });
|
|
78
|
+
const commander = makeBrowserCommander({
|
|
79
|
+
page: session.page,
|
|
80
|
+
debug: false,
|
|
81
|
+
enableNetworkTracking: false,
|
|
82
|
+
enableNavigationManager: false,
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
await session.page.setContent(
|
|
86
|
+
'<section><span></span><button>A</button></section><section><button>B</button><button>C</button></section>'
|
|
87
|
+
);
|
|
88
|
+
const matches = await commander.querySelectorAll({
|
|
89
|
+
selector: 'section button:nth-of-type(2)',
|
|
90
|
+
});
|
|
91
|
+
assert.equal(matches.length, 1);
|
|
92
|
+
assert.equal(await matches[0].textContent(), 'C');
|
|
93
|
+
const adapter = createEngineAdapter(session.page, 'playwright');
|
|
94
|
+
const adapterMatches = await adapter.querySelectorAll(
|
|
95
|
+
'section button:nth-of-type(2)'
|
|
96
|
+
);
|
|
97
|
+
assert.equal(adapterMatches.length, 1);
|
|
98
|
+
assert.equal(await adapterMatches[0].textContent(), 'C');
|
|
99
|
+
} finally {
|
|
100
|
+
await commander.destroy();
|
|
101
|
+
await session.cleanup();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
7
|
+
import { launchAndConnectRealBrowserWithDependencies } from '../../src/browser/real-browser.js';
|
|
8
|
+
import { startProcess } from '../../src/utilities/subprocess.js';
|
|
9
|
+
import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
|
|
10
|
+
|
|
11
|
+
it(
|
|
12
|
+
'fresh-profile Chrome starts repeatedly with session persistence',
|
|
13
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
|
|
14
|
+
async () => {
|
|
15
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-startup-smoke-'));
|
|
16
|
+
try {
|
|
17
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
18
|
+
const userDataDir = path.join(root, `profile-${attempt}`);
|
|
19
|
+
const session = await launchBrowser({
|
|
20
|
+
debug: false,
|
|
21
|
+
channel: 'chrome',
|
|
22
|
+
headless: true,
|
|
23
|
+
userDataDir,
|
|
24
|
+
persistSessionCookies: true,
|
|
25
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
assert.equal(await session.page.evaluate(() => 2 + 2), 4);
|
|
29
|
+
} finally {
|
|
30
|
+
await session.close();
|
|
31
|
+
}
|
|
32
|
+
assert.notEqual(session.browserProcess.exitCode, null);
|
|
33
|
+
await access(userDataDir);
|
|
34
|
+
}
|
|
35
|
+
} finally {
|
|
36
|
+
await rm(root, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
it(
|
|
42
|
+
'failure after real DevTools readiness leaves another Chrome and caller profile intact',
|
|
43
|
+
{ skip: process.env.RUN_E2E !== 'true', timeout: 90000 },
|
|
44
|
+
async () => {
|
|
45
|
+
const sibling = await launchBrowser({
|
|
46
|
+
debug: false,
|
|
47
|
+
headless: true,
|
|
48
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
49
|
+
});
|
|
50
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-owned-startup-'));
|
|
51
|
+
const marker = path.join(root, 'keep.txt');
|
|
52
|
+
await writeFile(marker, 'caller profile');
|
|
53
|
+
try {
|
|
54
|
+
for (const suppliedProfile of [false, true]) {
|
|
55
|
+
let child, profile;
|
|
56
|
+
await assert.rejects(
|
|
57
|
+
() =>
|
|
58
|
+
launchAndConnectRealBrowserWithDependencies(
|
|
59
|
+
{
|
|
60
|
+
debug: false,
|
|
61
|
+
headless: true,
|
|
62
|
+
persistSessionCookies: suppliedProfile,
|
|
63
|
+
...(suppliedProfile ? { userDataDir: root } : {}),
|
|
64
|
+
...CHROME_LAUNCH_OPTIONS,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
spawnBrowser: (file, args, options) => {
|
|
68
|
+
profile = args
|
|
69
|
+
.find((arg) => arg.startsWith('--user-data-dir='))
|
|
70
|
+
.slice('--user-data-dir='.length);
|
|
71
|
+
child = startProcess(file, args, { env: options.env });
|
|
72
|
+
return child;
|
|
73
|
+
},
|
|
74
|
+
connect: async () => {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Injected attachment timeout after endpoint readiness'
|
|
77
|
+
);
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
),
|
|
81
|
+
/Injected attachment timeout/
|
|
82
|
+
);
|
|
83
|
+
assert.notEqual(child.exitCode, null);
|
|
84
|
+
assert.equal(await sibling.page.evaluate(() => 2 + 2), 4);
|
|
85
|
+
assert.equal(await readFile(marker, 'utf8'), 'caller profile');
|
|
86
|
+
if (!suppliedProfile) {
|
|
87
|
+
await assert.rejects(() => access(profile), /ENOENT/);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} finally {
|
|
91
|
+
await sibling.close();
|
|
92
|
+
await rm(root, { recursive: true, force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { connectBrowserWithDependencies } from '../../../src/browser/connector.js';
|
|
4
|
+
|
|
5
|
+
it('identifies an engine attachment failure and logs stage timing only when verbose', async (t) => {
|
|
6
|
+
const logs = [];
|
|
7
|
+
t.mock.method(console, 'log', (message) => logs.push(message));
|
|
8
|
+
for (const verbose of [false, true]) {
|
|
9
|
+
logs.length = 0;
|
|
10
|
+
await assert.rejects(
|
|
11
|
+
() =>
|
|
12
|
+
connectBrowserWithDependencies(
|
|
13
|
+
{ cdpEndpoint: 'http://127.0.0.1:9222', verbose, timeout: 123 },
|
|
14
|
+
{
|
|
15
|
+
loadPlaywright: async () => ({
|
|
16
|
+
chromium: {
|
|
17
|
+
connectOverCDP: async (_endpoint, options) => {
|
|
18
|
+
assert.equal(options.timeout, 123);
|
|
19
|
+
throw new Error('connectOverCDP: Timeout 123ms exceeded');
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}),
|
|
23
|
+
}
|
|
24
|
+
),
|
|
25
|
+
(error) => {
|
|
26
|
+
assert.equal(error.attachmentStage, 'engine-connect');
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
if (verbose) {
|
|
31
|
+
assert.ok(logs.some((message) => /engine-connect started/.test(message)));
|
|
32
|
+
assert.ok(
|
|
33
|
+
logs.some((message) =>
|
|
34
|
+
/engine-connect failed after \d+ms/.test(message)
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
assert.deepEqual(logs, []);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('distinguishes page selection from engine connection and detaches on failure', async () => {
|
|
44
|
+
let detached = 0;
|
|
45
|
+
const browser = {
|
|
46
|
+
contexts: () => [
|
|
47
|
+
{
|
|
48
|
+
pages: () => [],
|
|
49
|
+
newPage: async () => {
|
|
50
|
+
throw new Error('newPage timeout');
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
close: async () => {
|
|
55
|
+
detached++;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
await assert.rejects(
|
|
59
|
+
() =>
|
|
60
|
+
connectBrowserWithDependencies(
|
|
61
|
+
{ cdpEndpoint: 'http://127.0.0.1:9222' },
|
|
62
|
+
{
|
|
63
|
+
loadPlaywright: async () => ({
|
|
64
|
+
chromium: { connectOverCDP: async () => browser },
|
|
65
|
+
}),
|
|
66
|
+
}
|
|
67
|
+
),
|
|
68
|
+
(error) => {
|
|
69
|
+
assert.equal(error.attachmentStage, 'page-selection');
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
assert.equal(detached, 1);
|
|
74
|
+
});
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
// feature-parity: launch.diagnostics@native-typed
|
|
2
2
|
import { it } from 'node:test';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
launchFailure,
|
|
5
|
+
redactLaunchEvidence,
|
|
6
|
+
} from '../../../src/browser/launch-diagnostics.js';
|
|
7
|
+
|
|
8
|
+
it('retains the attachment timeout alongside incidental Chrome stderr', () => {
|
|
9
|
+
const error = launchFailure(
|
|
10
|
+
new Error(
|
|
11
|
+
'connectOverCDP: Timeout 30000ms exceeded; <ws connected> ws://127.0.0.1:9222/devtools/browser/private'
|
|
12
|
+
),
|
|
13
|
+
{
|
|
14
|
+
phase: 'connect',
|
|
15
|
+
browserProcess: {
|
|
16
|
+
exitCode: null,
|
|
17
|
+
stderrTail:
|
|
18
|
+
'DevTools listening on ws://127.0.0.1:9222/devtools/browser/private\nCVDisplayLinkCreateWithCGDisplay failed',
|
|
19
|
+
},
|
|
20
|
+
}
|
|
21
|
+
);
|
|
22
|
+
assert.match(error.message, /connectOverCDP: Timeout 30000ms exceeded/);
|
|
23
|
+
assert.match(error.message, /CVDisplayLinkCreateWithCGDisplay failed/);
|
|
24
|
+
assert.doesNotMatch(error.message, /devtools|private|127\.0\.0\.1/);
|
|
25
|
+
});
|
|
4
26
|
|
|
5
27
|
it('diagnostics scrub URL credentials and queries and bound multibyte evidence', () => {
|
|
6
28
|
const redacted = redactLaunchEvidence(
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { PassThrough } from 'node:stream';
|
|
3
|
+
import * as fs from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { it } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
launchAndConnectRealBrowserWithDependencies,
|
|
9
|
+
waitForCdpEndpoint,
|
|
10
|
+
} from '../../../src/browser/real-browser.js';
|
|
11
|
+
|
|
12
|
+
it(
|
|
13
|
+
'waits for HTTP readiness after DevTools is announced, despite incidental warnings',
|
|
14
|
+
{ timeout: 5000 },
|
|
15
|
+
async () => {
|
|
16
|
+
const stderr = new PassThrough();
|
|
17
|
+
stderr.write(
|
|
18
|
+
'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed\n'
|
|
19
|
+
);
|
|
20
|
+
let probes = 0;
|
|
21
|
+
const endpoint = await waitForCdpEndpoint({
|
|
22
|
+
remoteDebuggingPort: 9777,
|
|
23
|
+
userDataDir: '/nonexistent',
|
|
24
|
+
browserProcess: { exitCode: null, stderr },
|
|
25
|
+
timeout: 2000,
|
|
26
|
+
fetchImplementation: async () => {
|
|
27
|
+
probes++;
|
|
28
|
+
if (probes === 1) {
|
|
29
|
+
throw new Error('ECONNREFUSED');
|
|
30
|
+
}
|
|
31
|
+
if (probes === 2) {
|
|
32
|
+
return { ok: false };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
json: async () => ({
|
|
37
|
+
webSocketDebuggerUrl: 'ws://127.0.0.1:9777/devtools/browser/ours',
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
assert.equal(endpoint, 'http://127.0.0.1:9777');
|
|
43
|
+
assert.equal(probes, 3);
|
|
44
|
+
}
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
for (const suppliedProfile of [false, true]) {
|
|
48
|
+
it(
|
|
49
|
+
`attachment timeout cleans only owned state (supplied profile: ${suppliedProfile})`,
|
|
50
|
+
{ timeout: 5000 },
|
|
51
|
+
async () => {
|
|
52
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-startup-test-'));
|
|
53
|
+
const marker = path.join(root, 'keep.txt');
|
|
54
|
+
await fs.writeFile(marker, 'caller owned');
|
|
55
|
+
let profile;
|
|
56
|
+
let kills = 0;
|
|
57
|
+
let launches = 0;
|
|
58
|
+
const child = {
|
|
59
|
+
exitCode: null,
|
|
60
|
+
stderrTail:
|
|
61
|
+
'DevTools listening on ws://127.0.0.1:9777/devtools/browser/ours\nCVDisplayLinkCreateWithCGDisplay failed',
|
|
62
|
+
kill: () => {
|
|
63
|
+
kills++;
|
|
64
|
+
child.exitCode = 143;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
await assert.rejects(
|
|
69
|
+
() =>
|
|
70
|
+
launchAndConnectRealBrowserWithDependencies(
|
|
71
|
+
{
|
|
72
|
+
...(suppliedProfile ? { userDataDir: root } : {}),
|
|
73
|
+
debug: false,
|
|
74
|
+
headless: true,
|
|
75
|
+
persistSessionCookies: suppliedProfile,
|
|
76
|
+
timeout: 123,
|
|
77
|
+
closeTimeout: 50,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
resolveExecutable: async () => '/opt/google/chrome',
|
|
81
|
+
reservePort: async () => 9777,
|
|
82
|
+
spawnBrowser: (_file, args) => {
|
|
83
|
+
launches++;
|
|
84
|
+
profile = args
|
|
85
|
+
.find((arg) => arg.startsWith('--user-data-dir='))
|
|
86
|
+
.slice('--user-data-dir='.length);
|
|
87
|
+
return child;
|
|
88
|
+
},
|
|
89
|
+
waitForEndpoint: async () => 'http://127.0.0.1:9777',
|
|
90
|
+
connect: async (options) => {
|
|
91
|
+
assert.equal(options.timeout, 123);
|
|
92
|
+
const error = new Error(
|
|
93
|
+
'connectOverCDP: Timeout 123ms exceeded'
|
|
94
|
+
);
|
|
95
|
+
error.attachmentStage = 'engine-connect';
|
|
96
|
+
throw error;
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
),
|
|
100
|
+
(error) => {
|
|
101
|
+
assert.equal(error.phase, 'connect');
|
|
102
|
+
assert.equal(error.category, 'startup_timeout');
|
|
103
|
+
assert.equal(error.attachmentStage, 'engine-connect');
|
|
104
|
+
assert.match(
|
|
105
|
+
error.message,
|
|
106
|
+
/connectOverCDP: Timeout 123ms exceeded/
|
|
107
|
+
);
|
|
108
|
+
assert.match(error.message, /CVDisplayLink/);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
);
|
|
112
|
+
assert.equal(kills, 1);
|
|
113
|
+
assert.equal(launches, 1);
|
|
114
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'caller owned');
|
|
115
|
+
if (!suppliedProfile) {
|
|
116
|
+
await assert.rejects(
|
|
117
|
+
() => fs.readFile(path.join(profile, 'First Run')),
|
|
118
|
+
/ENOENT/
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
123
|
+
if (!suppliedProfile && profile) {
|
|
124
|
+
await fs.rm(profile, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
normalizeSelector,
|
|
5
|
+
withTextSelectorSupport,
|
|
6
|
+
} from '../../../src/elements/selectors.js';
|
|
7
|
+
import { createPlaywrightLocator } from '../../../src/elements/locators.js';
|
|
8
|
+
import { PlaywrightAdapter } from '../../../src/core/engine-adapter.js';
|
|
9
|
+
|
|
10
|
+
it('keeps native Playwright text selectors without an eager DOM lookup', async () => {
|
|
11
|
+
const selector = '.parent button:has-text("B")';
|
|
12
|
+
const page = {
|
|
13
|
+
evaluate: () => {
|
|
14
|
+
throw new Error('unexpected DOM lookup');
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
assert.equal(
|
|
18
|
+
await normalizeSelector({ page, engine: 'playwright', selector }),
|
|
19
|
+
selector
|
|
20
|
+
);
|
|
21
|
+
const wrapped = withTextSelectorSupport(
|
|
22
|
+
async ({ selector }) => selector,
|
|
23
|
+
'playwright',
|
|
24
|
+
page
|
|
25
|
+
);
|
|
26
|
+
assert.equal(await wrapped({ selector }), selector);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
for (const [name, create] of [
|
|
30
|
+
[
|
|
31
|
+
'locator helper',
|
|
32
|
+
(page, selector) => createPlaywrightLocator({ page, selector }),
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'shared adapter',
|
|
36
|
+
(page, selector) => new PlaywrightAdapter(page).createLocator(selector),
|
|
37
|
+
],
|
|
38
|
+
]) {
|
|
39
|
+
it(`preserves CSS sibling semantics in the Playwright ${name}`, () => {
|
|
40
|
+
const selector = 'section button:nth-of-type(2)';
|
|
41
|
+
const expected = {};
|
|
42
|
+
const page = {
|
|
43
|
+
locator: (received) => {
|
|
44
|
+
assert.equal(received, selector);
|
|
45
|
+
return expected;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
assert.equal(create(page, selector), expected);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
@@ -205,32 +205,18 @@ describe('selectors', () => {
|
|
|
205
205
|
return page;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
it('
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it('splits :text-is into base selector and exact text', async () => {
|
|
223
|
-
const page = capturingPage();
|
|
224
|
-
await normalizeSelector({
|
|
225
|
-
page,
|
|
226
|
-
engine: 'playwright',
|
|
227
|
-
selector: 'button:text-is("Submit")',
|
|
228
|
-
});
|
|
229
|
-
assert.deepStrictEqual(page.parsed, {
|
|
230
|
-
baseSelector: 'button',
|
|
231
|
-
text: 'Submit',
|
|
232
|
-
exact: true,
|
|
233
|
-
});
|
|
208
|
+
it('preserves native text selectors without an eager lookup', async () => {
|
|
209
|
+
for (const selector of [
|
|
210
|
+
'nav a:has-text("Sign in")',
|
|
211
|
+
'button:text-is("Submit")',
|
|
212
|
+
]) {
|
|
213
|
+
const page = capturingPage();
|
|
214
|
+
assert.equal(
|
|
215
|
+
await normalizeSelector({ page, engine: 'playwright', selector }),
|
|
216
|
+
selector
|
|
217
|
+
);
|
|
218
|
+
assert.equal(page.parsed, null);
|
|
219
|
+
}
|
|
234
220
|
});
|
|
235
221
|
|
|
236
222
|
it('returns an unparseable selector unchanged', async () => {
|
|
@@ -196,6 +196,7 @@ describe('CI execution budgets', () => {
|
|
|
196
196
|
assert.deepEqual(budgeted, [
|
|
197
197
|
'docs.yml build-docs Rust API docs',
|
|
198
198
|
'js.yml test Node.js test suite',
|
|
199
|
+
'js.yml test macOS Chrome startup regression',
|
|
199
200
|
'parity.yml cli CLI and API coverage suites',
|
|
200
201
|
'parity.yml cli Cross-language CLI contract',
|
|
201
202
|
'parity.yml cli Rust CLI build',
|
|
@@ -97,6 +97,39 @@ describe('startProcess', { timeout: 10000 }, () => {
|
|
|
97
97
|
const exitCode = await child.exited;
|
|
98
98
|
assert.notEqual(exitCode, 0);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
it(
|
|
102
|
+
'reports exit only after a signalled child finishes its profile writes',
|
|
103
|
+
{ skip: process.platform === 'win32' && 'Windows termination is forceful' },
|
|
104
|
+
async (t) => {
|
|
105
|
+
const directory = await fs.mkdtemp(
|
|
106
|
+
path.join(tmpdir(), 'bc-shutdown-172-')
|
|
107
|
+
);
|
|
108
|
+
const marker = path.join(directory, 'flushed');
|
|
109
|
+
const child = startProcess(
|
|
110
|
+
node,
|
|
111
|
+
[repoPath('experiments', 'issue-172', 'delayed-shutdown.mjs'), marker],
|
|
112
|
+
{ killGrace: 1000 }
|
|
113
|
+
);
|
|
114
|
+
const processHandle = child.runner.child;
|
|
115
|
+
t.after(async () => {
|
|
116
|
+
if (
|
|
117
|
+
processHandle.exitCode === null &&
|
|
118
|
+
processHandle.signalCode === null
|
|
119
|
+
) {
|
|
120
|
+
processHandle.kill('SIGKILL');
|
|
121
|
+
await new Promise((resolve) => processHandle.once('exit', resolve));
|
|
122
|
+
}
|
|
123
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
124
|
+
});
|
|
125
|
+
await new Promise((resolve) => child.stdout.on('data', resolve));
|
|
126
|
+
child.kill();
|
|
127
|
+
await child.exited;
|
|
128
|
+
assert.equal(await fs.readFile(marker, 'utf8'), 'flushed');
|
|
129
|
+
assert.equal(processHandle.exitCode, 0);
|
|
130
|
+
assert.equal(child.exitCode, 0);
|
|
131
|
+
}
|
|
132
|
+
);
|
|
100
133
|
});
|
|
101
134
|
|
|
102
135
|
describe(
|