browser-commander 0.26.0 → 0.26.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.
Files changed (40) hide show
  1. package/.jscpd-baseline.json +6 -4
  2. package/CHANGELOG.md +30 -0
  3. package/package.json +2 -2
  4. package/scripts/check-duplication.mjs +176 -0
  5. package/scripts/create-manual-changeset.mjs +3 -1
  6. package/scripts/format-release-notes.mjs +8 -3
  7. package/scripts/merge-changesets.mjs +4 -1
  8. package/scripts/validate-changeset.mjs +23 -10
  9. package/scripts/version-and-commit.mjs +8 -10
  10. package/src/browser/webdriver.js +69 -3
  11. package/src/elements/selectors.js +31 -14
  12. package/src/parity/harness.js +4 -1
  13. package/src/tests/index.js +12 -1
  14. package/tests/e2e/engine-matrix.e2e.test.js +2 -1
  15. package/tests/e2e/safari.e2e.test.js +2 -1
  16. package/tests/e2e/webdriver.e2e.test.js +2 -1
  17. package/tests/helpers/fixture-server.js +18 -1
  18. package/tests/unit/browser/webdriver.test.js +100 -0
  19. package/tests/unit/elements/selectors.test.js +75 -0
  20. package/tests/unit/helpers/fixture-server.test.js +60 -0
  21. package/tests/unit/playwright-protocol-coverage.test.js +9 -6
  22. package/tests/unit/puppeteer-bindings-coverage.test.js +28 -7
  23. package/tests/unit/scripts/bot-commit-attribution.test.js +70 -0
  24. package/tests/unit/scripts/check-duplication.test.js +139 -0
  25. package/tests/unit/scripts/check-pipeline-status.test.js +264 -52
  26. package/tests/unit/scripts/check-version-modification.test.js +107 -1
  27. package/tests/unit/scripts/ci-timeout-budgets.test.js +15 -2
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +91 -7
  29. package/tests/unit/scripts/command-stream-errexit.test.js +83 -41
  30. package/tests/unit/scripts/e2e-suite-coverage.test.js +174 -0
  31. package/tests/unit/scripts/github-actions-log.test.js +99 -0
  32. package/tests/unit/scripts/links-workflow.test.js +42 -0
  33. package/tests/unit/scripts/preflight-credentials.test.js +555 -0
  34. package/tests/unit/scripts/run-with-budget-warning.test.js +212 -6
  35. package/tests/unit/scripts/trusted-publishing.test.js +78 -0
  36. package/tests/unit/scripts/use-module-integration.test.js +2 -9
  37. package/tests/unit/scripts/use-module.test.js +20 -0
  38. package/tests/unit/scripts/validate-changeset.test.js +127 -0
  39. package/tests/unit/scripts/zizmor-version.test.js +82 -0
  40. package/tests/unit/tests/index.test.js +43 -0
@@ -3,11 +3,12 @@
3
3
  "fingerprints": {
4
4
  "00d381dd611ae8ac": 1,
5
5
  "029a62ef1f48e856": 1,
6
- "03241c1f33f40d14": 1,
7
6
  "038170e72e0af1f5": 1,
7
+ "0413f601c8911a92": 1,
8
8
  "04b28d01d0bb47dc": 1,
9
9
  "04f605be138bc350": 1,
10
10
  "0796d358ad2562c3": 1,
11
+ "080e47b73f48a044": 1,
11
12
  "08e28c7706cdddd6": 1,
12
13
  "090a69d9528e4cdb": 1,
13
14
  "09694dc8fb9d683c": 1,
@@ -30,6 +31,7 @@
30
31
  "17c26c0db2d35ad1": 1,
31
32
  "193b46c0e7f72053": 1,
32
33
  "19ff70fc80fad524": 1,
34
+ "1a9f0f0f68ed445c": 1,
33
35
  "1ad4dc7e19a77f68": 1,
34
36
  "1c1baf1bc8ea0eae": 1,
35
37
  "1d61f5c49f12d2dc": 1,
@@ -79,7 +81,6 @@
79
81
  "4715a17d690865ee": 1,
80
82
  "4b0d3cc5c27227e7": 1,
81
83
  "4c3b94caa28045d0": 1,
82
- "4e090d6bb30e6551": 1,
83
84
  "4f3c541b3f3e3f23": 1,
84
85
  "4f93b074bc2ac9e8": 1,
85
86
  "5085765e1f103f3e": 1,
@@ -114,6 +115,7 @@
114
115
  "6880d620e0e67229": 1,
115
116
  "69d50e6f6a5f2ee9": 1,
116
117
  "6a7ed9ad3fc00a39": 1,
118
+ "6a9854bcdc9f71d7": 1,
117
119
  "6af3c307939ef75e": 1,
118
120
  "6b35b48512756192": 1,
119
121
  "6cd00d6b6a039022": 1,
@@ -182,7 +184,7 @@
182
184
  "acc95d694eb2f581": 1,
183
185
  "ad4cfa76daa1b6fc": 1,
184
186
  "ad50b48938af8aef": 1,
185
- "ae477ba64bafba3f": 1,
187
+ "add45ff3644fbeeb": 1,
186
188
  "aee1e5c65f783848": 1,
187
189
  "aee45e8b58c2d87f": 1,
188
190
  "af702b20ebd45ef2": 1,
@@ -246,13 +248,13 @@
246
248
  "e5feedc0c24e103e": 1,
247
249
  "e67726a913675120": 1,
248
250
  "e686288f2e9b8a6d": 1,
251
+ "e7d7bb7e163d2327": 1,
249
252
  "ea1195048ded896c": 1,
250
253
  "ea88711c5deb1fe5": 1,
251
254
  "eb9a70c2a21094ff": 1,
252
255
  "ebe03c5f8c2f5147": 1,
253
256
  "ee19c8b9b332ee98": 1,
254
257
  "eeca0ccf8d821f21": 1,
255
- "f022b763ff342b2d": 1,
256
258
  "f07f979f8c2706e8": 1,
257
259
  "f186f73c118b8e8a": 1,
258
260
  "f38f72095a67c95e": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.2
4
+
5
+ ### Patch Changes
6
+
7
+ - e2ef59f: Verify crates.io trusted publishing before releases and mark repository fixture manifests as non-publishable. Document the account setup required for PyPI and crates.io.
8
+
9
+ ## 0.26.1
10
+
11
+ ### Patch Changes
12
+
13
+ - f2988a3: Close the parity probe server only after it stops listening, so a connection
14
+ that arrives during shutdown can no longer keep `close()` waiting. Test fixture
15
+ servers now drop idle browser preconnections too, which stopped the Safari
16
+ smoke test from timing out during cleanup on Node 24.
17
+
18
+ Parse Playwright `:has-text("…")` and `:text-is("…")` selectors with string
19
+ operations instead of a backtracking regular expression. A long selector that
20
+ did not match took quadratic time (16 seconds for 550,000 characters); it is
21
+ now rejected in linear time. CodeQL reported it as `js/polynomial-redos`.
22
+
23
+ Failure artifact names are trimmed of leading and trailing dashes by index, so
24
+ a test name with a long run of dashes no longer takes quadratic time.
25
+
26
+ A Safari launch right after a previous Safari session closed starts a fresh
27
+ `safaridriver`, up to three attempts, when the driver exits before it is ready
28
+ or refuses the connection. safaridriver serves one automation session at a
29
+ time, and CI saw both failures on back-to-back launches. A refused connection
30
+ now also names the driver server and whether it had exited. Authorization
31
+ errors are still reported at once. Set `VERBOSE=1` to log each retry.
32
+
3
33
  ## 0.26.0
4
34
 
5
35
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.26.0",
3
+ "version": "0.26.2",
4
4
  "description": "Universal browser automation library that supports Playwright, Puppeteer, and Selenium with a unified API",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -32,7 +32,7 @@
32
32
  "format": "prettier --write .",
33
33
  "format:check": "prettier --check .",
34
34
  "docs:api": "jsdoc -c jsdoc.json",
35
- "check:duplication": "jscpd . --baseline .jscpd-baseline.json --fail-on-new-clones",
35
+ "check:duplication": "node scripts/check-duplication.mjs",
36
36
  "check": "npm run lint && npm run format:check && npm run check:duplication",
37
37
  "changeset": "changeset",
38
38
  "changeset:version": "node scripts/changeset-version.mjs",
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Duplication gate that prints the clones it fails on, and only those.
5
+ *
6
+ * With the console reporter, `jscpd --baseline … --fail-on-new-clones` lists
7
+ * every clone it finds, accepted or not. A passing run printed 272 "Clone
8
+ * found" blocks, about 840 lines, for clones the baseline already accepts. The
9
+ * one clone that failed a run was marked only by a `[NEW]` tag somewhere in
10
+ * that list (issue #128). This runs jscpd with the JSON reporter instead. It
11
+ * prints the new clones from the report (as error annotations in GitHub
12
+ * Actions) and a one-line total.
13
+ *
14
+ * `npx jscpd .` still prints every clone, and `.jscpd.json` keeps the console
15
+ * reporter for that.
16
+ *
17
+ * Usage (from js/): node scripts/check-duplication.mjs
18
+ */
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+ import { tmpdir } from 'node:os';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const BASELINE = '.jscpd-baseline.json';
28
+
29
+ /**
30
+ * Path to jscpd's CLI entry point, or null when it is not installed. Running
31
+ * it through `process.execPath` avoids the `.bin` shims, which Windows cannot
32
+ * execute directly.
33
+ */
34
+ export function resolveJscpdCli() {
35
+ const require = createRequire(import.meta.url);
36
+ let manifestPath;
37
+ try {
38
+ manifestPath = require.resolve('jscpd/package.json');
39
+ } catch {
40
+ return null;
41
+ }
42
+ const { bin } = JSON.parse(readFileSync(manifestPath, 'utf8'));
43
+ const entry = typeof bin === 'string' ? bin : bin?.jscpd;
44
+ const cli = entry && path.join(path.dirname(manifestPath), entry);
45
+ return cli && existsSync(cli) ? cli : null;
46
+ }
47
+
48
+ function escapeProperty(value) {
49
+ return String(value)
50
+ .replaceAll('%', '%25')
51
+ .replaceAll('\r', '%0D')
52
+ .replaceAll('\n', '%0A')
53
+ .replaceAll(':', '%3A')
54
+ .replaceAll(',', '%2C');
55
+ }
56
+
57
+ function escapeMessage(value) {
58
+ return String(value)
59
+ .replaceAll('%', '%25')
60
+ .replaceAll('\r', '%0D')
61
+ .replaceAll('\n', '%0A');
62
+ }
63
+
64
+ /**
65
+ * The new clones in a jscpd JSON report, one line each.
66
+ *
67
+ * jscpd names code embedded in Markdown `README.md:javascript`, so the
68
+ * language suffix is dropped before the name is used as a path. In GitHub
69
+ * Actions each clone becomes an `::error` annotation on the
70
+ * repository-relative path. `cwd` is where jscpd ran, and `workspace` is the
71
+ * checkout root.
72
+ *
73
+ * @param {{duplicates: Array<object>}} report
74
+ * @param {{githubActions?: boolean, cwd?: string, workspace?: string}} [options]
75
+ */
76
+ export function formatNewClones(report, options = {}) {
77
+ const {
78
+ githubActions = false,
79
+ cwd = process.cwd(),
80
+ workspace = cwd,
81
+ } = options;
82
+ const fileOf = (side) => {
83
+ const name = side.name.replace(/:[a-z]+$/, '');
84
+ return githubActions
85
+ ? path
86
+ .relative(workspace, path.resolve(cwd, name))
87
+ .split(path.sep)
88
+ .join('/')
89
+ : name;
90
+ };
91
+
92
+ return report.duplicates
93
+ .filter((duplicate) => duplicate.isNew)
94
+ .map(({ firstFile, secondFile, lines }) => {
95
+ const first = fileOf(firstFile);
96
+ const second = `${fileOf(secondFile)}:${secondFile.start}-${secondFile.end}`;
97
+ if (!githubActions) {
98
+ return `New clone: ${first}:${firstFile.start}-${firstFile.end} ~ ${second} (${lines} lines)\n`;
99
+ }
100
+ const properties = [
101
+ `file=${escapeProperty(first)}`,
102
+ `line=${firstFile.start}`,
103
+ `endLine=${firstFile.end}`,
104
+ 'title=New duplicated code',
105
+ ].join(',');
106
+ const message =
107
+ `${lines} lines duplicate ${second}. Extract the shared code, or ` +
108
+ 'accept the clone with `npm run check:duplication:update`.';
109
+ return `::error ${properties}::${escapeMessage(message)}\n`;
110
+ })
111
+ .join('');
112
+ }
113
+
114
+ function main() {
115
+ const cli = resolveJscpdCli();
116
+ if (!cli) {
117
+ console.error('jscpd is not installed; run npm ci in js/.');
118
+ return 2;
119
+ }
120
+
121
+ const output = mkdtempSync(path.join(tmpdir(), 'jscpd-report-'));
122
+ try {
123
+ const jscpd = spawnSync(
124
+ process.execPath,
125
+ [
126
+ cli,
127
+ '.',
128
+ '--baseline',
129
+ BASELINE,
130
+ '--fail-on-new-clones',
131
+ '--reporters',
132
+ 'json',
133
+ '--output',
134
+ output,
135
+ '--no-colors',
136
+ ],
137
+ { encoding: 'utf8' }
138
+ );
139
+ const reportPath = path.join(output, 'jscpd-report.json');
140
+ if (jscpd.error || !existsSync(reportPath)) {
141
+ // Fail closed: a run that produced no report proved nothing.
142
+ process.stdout.write(jscpd.stdout ?? '');
143
+ process.stderr.write(jscpd.stderr ?? '');
144
+ console.error(
145
+ `jscpd wrote no report (exit ${jscpd.status}): ${jscpd.error?.message ?? reportPath}`
146
+ );
147
+ return jscpd.status || 2;
148
+ }
149
+
150
+ const report = JSON.parse(readFileSync(reportPath, 'utf8'));
151
+ const newCount = report.duplicates.filter((d) => d.isNew).length;
152
+ process.stdout.write(
153
+ formatNewClones(report, {
154
+ githubActions: process.env.GITHUB_ACTIONS === 'true',
155
+ workspace: process.env.GITHUB_WORKSPACE || process.cwd(),
156
+ })
157
+ );
158
+ const total = report.duplicates.length;
159
+ console.log(
160
+ `jscpd: ${total} clone${total === 1 ? '' : 's'}, ${newCount} new ` +
161
+ `(the rest are accepted in ${BASELINE}).`
162
+ );
163
+ if (jscpd.status !== 0 && newCount === 0) {
164
+ // jscpd failed for a reason other than new clones; show what it said.
165
+ process.stdout.write(jscpd.stdout);
166
+ process.stderr.write(jscpd.stderr);
167
+ }
168
+ return jscpd.status ?? 2;
169
+ } finally {
170
+ rmSync(output, { recursive: true, force: true });
171
+ }
172
+ }
173
+
174
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
175
+ process.exitCode = main();
176
+ }
@@ -13,6 +13,8 @@
13
13
  import { writeFileSync } from 'fs';
14
14
  import { randomBytes } from 'crypto';
15
15
 
16
+ import { printUntrusted } from '../../scripts/github-actions-log.mjs';
17
+
16
18
  const PACKAGE_NAME = 'browser-commander';
17
19
 
18
20
  import {
@@ -69,7 +71,7 @@ ${description}
69
71
 
70
72
  console.log(`Created changeset: ${changesetFile}`);
71
73
  console.log('Content:');
72
- console.log(content);
74
+ printUntrusted(content);
73
75
 
74
76
  // Format with Prettier
75
77
  console.log('\nFormatting with Prettier...');
@@ -70,6 +70,9 @@ if (!releaseId || !version || !repository) {
70
70
  process.exit(1);
71
71
  }
72
72
 
73
+ /** Start of the npm badge appended below, which marks a formatted body. */
74
+ const NPM_BADGE_PREFIX = '[![npm version](';
75
+
73
76
  try {
74
77
  // Get current release body
75
78
  const result = await $`gh api repos/${repository}/releases/${releaseId}`.run({
@@ -79,8 +82,10 @@ try {
79
82
 
80
83
  const currentBody = releaseData.body || '';
81
84
 
82
- // Skip if already formatted (has shields.io badge image)
83
- if (currentBody.includes('img.shields.io')) {
85
+ // Skip if already formatted (has the npm badge this script appends). The
86
+ // badge's alt text marks it: matching the image host would also skip a
87
+ // body whose changelog text mentions that host.
88
+ if (currentBody.includes(NPM_BADGE_PREFIX)) {
84
89
  console.log('ℹ️ Release notes already formatted');
85
90
  process.exit(0);
86
91
  }
@@ -186,7 +191,7 @@ try {
186
191
 
187
192
  // Build formatted release notes
188
193
  const versionWithoutV = version.replace(/^v/, '');
189
- const npmBadge = `[![npm version](https://img.shields.io/badge/npm-${versionWithoutV}-blue.svg)](https://www.npmjs.com/package/${PACKAGE_NAME}/v/${versionWithoutV})`;
194
+ const npmBadge = `${NPM_BADGE_PREFIX}https://img.shields.io/badge/npm-${versionWithoutV}-blue.svg)](https://www.npmjs.com/package/${PACKAGE_NAME}/v/${versionWithoutV})`;
190
195
 
191
196
  let formattedBody = `${cleanDescription}`;
192
197
 
@@ -23,6 +23,8 @@ import {
23
23
  } from 'fs';
24
24
  import { join } from 'path';
25
25
 
26
+ import { printUntrusted } from '../../scripts/github-actions-log.mjs';
27
+
26
28
  const PACKAGE_NAME = 'browser-commander';
27
29
  const CHANGESET_DIR = '.changeset';
28
30
 
@@ -254,7 +256,8 @@ function main() {
254
256
  }
255
257
 
256
258
  console.log('\nChangeset merge completed successfully');
257
- console.log(`\nMerged changeset content:\n${mergedContent}`);
259
+ console.log('\nMerged changeset content:');
260
+ printUntrusted(mergedContent);
258
261
  }
259
262
 
260
263
  main();
@@ -7,13 +7,17 @@
7
7
  * - Only checks changeset files ADDED by the current PR (not pre-existing ones)
8
8
  * - Uses git diff to compare PR head against base branch
9
9
  * - Validates that the PR adds exactly one changeset with proper format
10
- * - Falls back to checking all changesets for local development
10
+ * - Fails when a pull request's diff cannot be computed (issue #128): checking
11
+ * every changeset on disk instead would let a stale one satisfy the check
12
+ * - Outside a pull request (local runs), checks the changesets on disk
11
13
  */
12
14
 
13
15
  import { execSync } from 'child_process';
14
16
  import { readFileSync, readdirSync, existsSync } from 'fs';
15
17
  import { join } from 'path';
16
18
 
19
+ import { printUntrusted } from '../../scripts/github-actions-log.mjs';
20
+
17
21
  const PACKAGE_NAME = 'browser-commander';
18
22
  // When running from js/ directory, the changeset dir is local
19
23
  const CHANGESET_DIR = '.changeset';
@@ -113,13 +117,11 @@ function tryBaseBranchComparison(prBase) {
113
117
  }
114
118
 
115
119
  /**
116
- * Fallback: get all changesets in directory
120
+ * Outside a pull request: get all changesets in directory
117
121
  * @returns {string[]} Array of all changeset file names
118
122
  */
119
123
  function getAllChangesets() {
120
- console.log(
121
- 'Warning: Could not determine PR diff, checking all changesets in directory'
122
- );
124
+ console.log('No pull request to compare; checking all changesets on disk');
123
125
  if (!existsSync(CHANGESET_DIR)) {
124
126
  return [];
125
127
  }
@@ -136,6 +138,8 @@ function getAddedChangesetFiles() {
136
138
  const baseSha = process.env.GITHUB_BASE_SHA || process.env.BASE_SHA;
137
139
  const headSha = process.env.GITHUB_HEAD_SHA || process.env.HEAD_SHA;
138
140
 
141
+ const prBase = process.env.GITHUB_BASE_REF;
142
+
139
143
  // Try explicit SHAs first
140
144
  if (baseSha && headSha) {
141
145
  const result = tryExplicitShaComparison(baseSha, headSha);
@@ -145,7 +149,6 @@ function getAddedChangesetFiles() {
145
149
  }
146
150
 
147
151
  // Try base branch comparison
148
- const prBase = process.env.GITHUB_BASE_REF;
149
152
  if (prBase) {
150
153
  const result = tryBaseBranchComparison(prBase);
151
154
  if (result !== null) {
@@ -153,7 +156,14 @@ function getAddedChangesetFiles() {
153
156
  }
154
157
  }
155
158
 
156
- // Fallback to checking all changesets
159
+ // In a pull request, every changeset on disk includes ones already on the
160
+ // base branch, so falling back to them would pass a PR that adds none.
161
+ if (prBase || baseSha || headSha) {
162
+ throw new Error(
163
+ 'Could not determine which changesets this pull request adds, because git could not diff it against its base'
164
+ );
165
+ }
166
+
157
167
  return getAllChangesets();
158
168
  }
159
169
 
@@ -252,7 +262,9 @@ try {
252
262
  console.error(`::error::${validation.error}`);
253
263
  console.error(`\nFile content of ${changesetFile}:`);
254
264
  try {
255
- console.error(readFileSync(changesetFile, 'utf-8'));
265
+ printUntrusted(readFileSync(changesetFile, 'utf-8'), {
266
+ stream: process.stderr,
267
+ });
256
268
  } catch {
257
269
  console.error('(could not read file)');
258
270
  }
@@ -261,9 +273,10 @@ try {
261
273
 
262
274
  console.log('Changeset validation passed');
263
275
  console.log(` Type: ${validation.type}`);
264
- console.log(` Description: ${validation.description}`);
276
+ console.log(' Description:');
277
+ printUntrusted(validation.description);
265
278
  } catch (error) {
266
- console.error('Error during changeset validation:', error.message);
279
+ console.error(`::error::${error.message}`);
267
280
  if (process.env.DEBUG) {
268
281
  console.error('Stack trace:', error.stack);
269
282
  }
@@ -24,6 +24,7 @@ import {
24
24
  loadCommandStream,
25
25
  loadLinoArguments,
26
26
  } from '../../scripts/use-module.mjs';
27
+ import { printUntrusted } from '../../scripts/github-actions-log.mjs';
27
28
 
28
29
  const { $ } = await loadCommandStream();
29
30
  const { makeConfig } = await loadLinoArguments();
@@ -53,11 +54,9 @@ const config = makeConfig({
53
54
  const { mode, bumpType, description } = config;
54
55
 
55
56
  // Debug: Log parsed configuration
56
- console.log('Parsed configuration:', {
57
- mode,
58
- bumpType,
59
- description: description || '(none)',
60
- });
57
+ console.log('Parsed configuration:', { mode, bumpType });
58
+ console.log('Description:');
59
+ printUntrusted(description || '(none)');
61
60
 
62
61
  // Detect if positional arguments were used (common mistake)
63
62
  const args = process.argv.slice(2);
@@ -145,7 +144,7 @@ async function main() {
145
144
  try {
146
145
  // Configure git
147
146
  await $`git config user.name "github-actions[bot]"`;
148
- await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
147
+ await $`git config user.email "41898282+github-actions[bot]@users.noreply.github.com"`;
149
148
 
150
149
  // Check if remote main has advanced (handles re-runs after partial success)
151
150
  console.log('Checking for remote changes...');
@@ -231,10 +230,9 @@ async function main() {
231
230
  // Stage all changes (package.json, package-lock.json, CHANGELOG.md, deleted changesets)
232
231
  await $`git add -A`;
233
232
 
234
- // Commit with version number as message
235
- const commitMessage = newVersion;
236
- const escapedMessage = commitMessage.replace(/"/g, '\\"');
237
- await $`git commit -m "${escapedMessage}"`;
233
+ // Commit with version number as message. command-stream quotes the
234
+ // interpolated value itself; escaping it here would add backslashes.
235
+ await $`git commit -m ${newVersion}`;
238
236
 
239
237
  // Push directly to main.
240
238
  //
@@ -28,6 +28,7 @@ import { createRequire } from 'node:module';
28
28
  import path from 'node:path';
29
29
 
30
30
  import { createWebDriverPage } from '../core/webdriver-page.js';
31
+ import { isVerboseEnabled } from '../core/logger.js';
31
32
  import {
32
33
  runCommand as defaultRunCommand,
33
34
  startProcess as defaultStartProcess,
@@ -484,6 +485,68 @@ async function teardown({
484
485
  * @returns {Promise<{driver: Object, page: Object, close: function(): Promise<void>, serverUrl: string, driverProcess: Object, driverPath: string, driverSource: string, browser: string, userDataDir: string, temporaryProfile: boolean, args: Array<string>, bidi: boolean}>}
485
486
  */
486
487
  export async function launchWebDriver(options = {}, dependencies = {}) {
488
+ if (!isSafariChannel(options.browser ?? 'chrome')) {
489
+ return await launchWebDriverOnce(options, dependencies);
490
+ }
491
+ const delay = dependencies.safariRetryDelay ?? SAFARI_RETRY_DELAY;
492
+ for (let attempt = 1; ; attempt += 1) {
493
+ try {
494
+ return await launchWebDriverOnce(options, dependencies);
495
+ } catch (error) {
496
+ if (
497
+ attempt >= SAFARI_LAUNCH_ATTEMPTS ||
498
+ !TRANSIENT_DRIVER_START_ERROR.test(error?.message ?? '')
499
+ ) {
500
+ throw error;
501
+ }
502
+ if (isVerboseEnabled()) {
503
+ console.warn(
504
+ `[webdriver] Safari launch attempt ${attempt} failed, retrying in ${delay}ms: ${error.message}`
505
+ );
506
+ }
507
+ await new Promise((resolve) => setTimeout(resolve, delay));
508
+ }
509
+ }
510
+ }
511
+
512
+ /**
513
+ * safaridriver serves one Safari automation session at a time. Launched right
514
+ * after a previous session closed, CI saw a new driver exit with code 1 before
515
+ * it was ready, and a driver that was ready refuse the new-session request
516
+ * (issue #128). Both are start-up failures of a driver this process owns, so a
517
+ * Safari launch starts a fresh driver for them, a bounded number of times.
518
+ */
519
+ const SAFARI_LAUNCH_ATTEMPTS = 3;
520
+ const SAFARI_RETRY_DELAY = 1000;
521
+ const TRANSIENT_DRIVER_START_ERROR =
522
+ /ECONNREFUSED|ECONNRESET|socket hang up|exited with code \d+ before it was ready/;
523
+
524
+ /**
525
+ * Name the driver server behind a connection error from selenium, which only
526
+ * says which port refused: whether the server had exited, and its last output.
527
+ */
528
+ function withDriverContext(error, { serverUrl, exitCode, output }) {
529
+ if (
530
+ !serverUrl ||
531
+ !/ECONNREFUSED|ECONNRESET|socket hang up/.test(error?.message ?? '')
532
+ ) {
533
+ return error;
534
+ }
535
+ const state =
536
+ exitCode === null || exitCode === undefined
537
+ ? 'was still running'
538
+ : `had exited with code ${exitCode}`;
539
+ const tail = output.trim();
540
+ return Object.assign(
541
+ new Error(
542
+ `${error.message} (WebDriver server at ${serverUrl} ${state}${tail ? `: ${tail}` : ''})`,
543
+ { cause: error }
544
+ ),
545
+ { code: error.code }
546
+ );
547
+ }
548
+
549
+ async function launchWebDriverOnce(options, dependencies) {
487
550
  const {
488
551
  browser = 'chrome',
489
552
  executablePath,
@@ -533,15 +596,16 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
533
596
 
534
597
  let driverProcess;
535
598
  let driver;
599
+ let serverUrl;
600
+ let output = '';
536
601
  try {
537
602
  const port = await reservePort();
538
- const serverUrl = `http://${LOOPBACK_HOST}:${port}`;
603
+ serverUrl = `http://${LOOPBACK_HOST}:${port}`;
539
604
  driverProcess = startProcess(
540
605
  resolved.driverPath,
541
606
  buildDriverServerArgs(browser, port),
542
607
  { env: childEnv, forwardOutput: verbose }
543
608
  );
544
- let output = '';
545
609
  for (const stream of [driverProcess.stdout, driverProcess.stderr]) {
546
610
  stream?.on('data', (chunk) => {
547
611
  output = `${output}${chunk}`.slice(-4000);
@@ -602,8 +666,10 @@ export async function launchWebDriver(options = {}, dependencies = {}) {
602
666
  bidi,
603
667
  };
604
668
  } catch (error) {
669
+ const exitCode = driverProcess?.exitCode;
605
670
  await teardown({ driver, driverProcess, temporaryProfile, userDataDir });
606
- throw safari ? safariLaunchError(error, browser) : error;
671
+ const failure = withDriverContext(error, { serverUrl, exitCode, output });
672
+ throw safari ? safariLaunchError(failure, browser) : failure;
607
673
  }
608
674
  }
609
675
 
@@ -117,6 +117,31 @@ function isPlaywrightTextSelector(selector) {
117
117
  return selector.includes(':has-text(') || selector.includes(':text-is(');
118
118
  }
119
119
 
120
+ /**
121
+ * Split `base:<pseudo>("text")` into its base selector and text.
122
+ *
123
+ * String operations rather than a regular expression: the former
124
+ * /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically on long selectors
125
+ * that do not match (CodeQL js/polynomial-redos). The base is everything before
126
+ * the first `:<pseudo>("`, as the lazy `(.+?)` made it; base and text must be
127
+ * non-empty.
128
+ * @param {string} selector - Selector to split
129
+ * @param {string} pseudo - Pseudo-class name, e.g. 'has-text'
130
+ * @returns {{baseSelector: string, text: string}|null} - Parts, or null
131
+ */
132
+ function splitTextPseudo(selector, pseudo) {
133
+ const open = `:${pseudo}("`;
134
+ const start = selector.indexOf(open, 1);
135
+ if (start === -1 || !selector.endsWith('")')) {
136
+ return null;
137
+ }
138
+ const text = selector.slice(start + open.length, -2);
139
+ if (!text) {
140
+ return null;
141
+ }
142
+ return { baseSelector: selector.slice(0, start), text };
143
+ }
144
+
120
145
  /**
121
146
  * Parse a Playwright text selector to extract base selector and text
122
147
  * @param {string} selector - Playwright text selector like 'a:has-text("text")'
@@ -124,22 +149,14 @@ function isPlaywrightTextSelector(selector) {
124
149
  */
125
150
  function parsePlaywrightTextSelector(selector) {
126
151
  // Match patterns like 'a:has-text("text")' or 'button:text-is("exact text")'
127
- const hasTextMatch = selector.match(/^(.+?):has-text\("(.+?)"\)$/);
128
- if (hasTextMatch) {
129
- return {
130
- baseSelector: hasTextMatch[1],
131
- text: hasTextMatch[2],
132
- exact: false,
133
- };
152
+ const hasText = splitTextPseudo(selector, 'has-text');
153
+ if (hasText) {
154
+ return { ...hasText, exact: false };
134
155
  }
135
156
 
136
- const textIsMatch = selector.match(/^(.+?):text-is\("(.+?)"\)$/);
137
- if (textIsMatch) {
138
- return {
139
- baseSelector: textIsMatch[1],
140
- text: textIsMatch[2],
141
- exact: true,
142
- };
157
+ const textIs = splitTextPseudo(selector, 'text-is');
158
+ if (textIs) {
159
+ return { ...textIs, exact: true };
143
160
  }
144
161
 
145
162
  return null;
@@ -109,8 +109,11 @@ export async function startProbeServer(probeSource) {
109
109
  });
110
110
  },
111
111
  async close() {
112
+ // Stop listening before dropping connections, so none can slip in
113
+ // between and keep close() waiting (issue #128).
114
+ const closed = new Promise((resolve) => server.close(resolve));
112
115
  server.closeAllConnections?.();
113
- await new Promise((resolve) => server.close(resolve));
116
+ await closed;
114
117
  },
115
118
  };
116
119
  }