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
@@ -176,7 +176,18 @@ function expandBrowserTestEntries(scenarios, engines) {
176
176
  }
177
177
 
178
178
  function sanitizeArtifactName(value) {
179
- return value.replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
179
+ const name = value.replace(/[^a-zA-Z0-9._-]+/g, '-');
180
+ // Trimmed by index: /^-+|-+$/g backtracks quadratically on a long run of
181
+ // inner dashes (CodeQL js/polynomial-redos).
182
+ let start = 0;
183
+ let end = name.length;
184
+ while (start < end && name[start] === '-') {
185
+ start++;
186
+ }
187
+ while (end > start && name[end - 1] === '-') {
188
+ end--;
189
+ }
190
+ return name.slice(start, end);
180
191
  }
181
192
 
182
193
  function closeBrowserLike(browser) {
@@ -5,6 +5,7 @@ import { createServer } from 'node:http';
5
5
  import { describe, it } from 'node:test';
6
6
  import { launchBrowser } from '../../src/browser/launcher.js';
7
7
  import { makeBrowserCommander } from '../../src/factory.js';
8
+ import { closeServer } from '../helpers/fixture-server.js';
8
9
  import {
9
10
  CHROME_LAUNCH_OPTIONS,
10
11
  PARITY_CHROME,
@@ -50,7 +51,7 @@ describe('Common native engine API', { skip: !process.env.RUN_E2E }, () => {
50
51
  );
51
52
  } finally {
52
53
  await session?.close();
53
- await new Promise((resolve) => server.close(resolve));
54
+ await closeServer(server);
54
55
  }
55
56
  }
56
57
  );
@@ -8,6 +8,7 @@ import {
8
8
  makeBrowserCommander,
9
9
  } from '../../src/index.js';
10
10
  import { createDispatcher } from '../../src/cli/dispatcher.js';
11
+ import { closeServer } from '../helpers/fixture-server.js';
11
12
 
12
13
  it(
13
14
  'Safari drives a local page in an isolated automation session',
@@ -97,7 +98,7 @@ it(
97
98
  await commander?.destroy();
98
99
  phase('close remaining session and server');
99
100
  await session?.close();
100
- await new Promise((resolve) => server.close(resolve));
101
+ await closeServer(server);
101
102
  phase('cleanup complete');
102
103
  }
103
104
  }
@@ -20,6 +20,7 @@ import path from 'node:path';
20
20
  import { launchWebDriver } from '../../src/browser/webdriver.js';
21
21
  import { makeBrowserCommander } from '../../src/factory.js';
22
22
  import { CHROME_LAUNCH_OPTIONS } from '../helpers/e2e-browser.js';
23
+ import { closeServer } from '../helpers/fixture-server.js';
23
24
 
24
25
  const PAGE = `<!doctype html>
25
26
  <title>WebDriver e2e</title>
@@ -42,7 +43,7 @@ async function startPageServer() {
42
43
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
43
44
  return {
44
45
  url: `http://127.0.0.1:${server.address().port}/`,
45
- close: () => new Promise((resolve) => server.close(resolve)),
46
+ close: () => closeServer(server),
46
47
  };
47
48
  }
48
49
 
@@ -31,6 +31,23 @@ export function sendNotFound(res) {
31
31
  res.end('not found');
32
32
  }
33
33
 
34
+ /**
35
+ * Close an HTTP server without waiting for the browser's idle connections.
36
+ *
37
+ * Browsers open speculative connections they may never send a request on.
38
+ * Node 24's http.Server#close() waits for those until the client drops them,
39
+ * which stalled the Safari smoke cleanup for ~30s (issue #128), so stop
40
+ * listening first and then drop every connection that is left.
41
+ *
42
+ * @param {import('node:http').Server} server - Listening server
43
+ * @returns {Promise<void>} Resolves once the server is closed
44
+ */
45
+ export function closeServer(server) {
46
+ const closed = new Promise((resolve) => server.close(() => resolve()));
47
+ server.closeAllConnections();
48
+ return closed;
49
+ }
50
+
34
51
  /**
35
52
  * Start a fixture server on an ephemeral port.
36
53
  *
@@ -50,7 +67,7 @@ export async function startFixtureHost(handle, onClose) {
50
67
  baseUrl: `http://127.0.0.1:${port}`,
51
68
  close: async () => {
52
69
  await onClose?.();
53
- await new Promise((resolve) => server.close(resolve));
70
+ await closeServer(server);
54
71
  },
55
72
  };
56
73
  }
@@ -488,6 +488,106 @@ describe('Safari driver lifecycle', () => {
488
488
  (error) => error.code === 'SAFARI_SETUP_REQUIRED'
489
489
  );
490
490
  assert.ok(started[0].child.killed);
491
+ assert.equal(started.length, 1, 'setup errors are not retried');
492
+ });
493
+ });
494
+
495
+ /**
496
+ * Safari dependencies whose first `failures` driver starts fail the way CI
497
+ * saw right after the previous session closed: `'exit'` is a safaridriver that
498
+ * exits with code 1 before /status answers, `'refused'` is one that answered
499
+ * /status and then refused the new-session request.
500
+ */
501
+ function flakySafariDependencies(failures) {
502
+ const setup = safariDependencies();
503
+ const { dependencies, started, mock } = setup;
504
+ let port = 49000;
505
+ dependencies.reservePort = async () => (port += 1);
506
+ dependencies.safariRetryDelay = 0;
507
+ const startProcess = dependencies.startProcess;
508
+ dependencies.startProcess = (...args) => {
509
+ const child = startProcess(...args);
510
+ if (failures[started.length - 1] === 'exit') {
511
+ child.exitCode = 1;
512
+ child.stdout = {
513
+ on: (_event, listener) => listener('safaridriver: busy\n'),
514
+ };
515
+ }
516
+ return child;
517
+ };
518
+ dependencies.fetch = async () => ({
519
+ ok: true,
520
+ json: async () => ({ value: { ready: true } }),
521
+ });
522
+ const build = mock.selenium.webdriver.Builder.prototype.build;
523
+ mock.selenium.webdriver.Builder.prototype.build = function () {
524
+ if (failures[started.length - 1] === 'refused') {
525
+ throw new Error(
526
+ `ECONNREFUSED connect ECONNREFUSED 127.0.0.1:${port} (attempt ${started.length})`
527
+ );
528
+ }
529
+ return build.call(this);
530
+ };
531
+ return setup;
532
+ }
533
+
534
+ describe('Safari launch right after a previous session', () => {
535
+ for (const failure of ['exit', 'refused']) {
536
+ it(`starts a new driver when the first one fails with '${failure}'`, async () => {
537
+ const { dependencies, started } = flakySafariDependencies([failure]);
538
+ const result = await launchWebDriver({ browser: 'safari' }, dependencies);
539
+ assert.equal(started.length, 2);
540
+ assert.equal(result.driverProcess, started[1].child);
541
+ assert.ok(started[0].child.killed || started[0].child.exitCode === 1);
542
+ assert.equal(result.serverUrl, 'http://127.0.0.1:49002');
543
+ await result.close();
544
+ });
545
+ }
546
+
547
+ it('gives up after three attempts and reports what the driver printed', async () => {
548
+ const { dependencies, started } = flakySafariDependencies([
549
+ 'exit',
550
+ 'refused',
551
+ 'exit',
552
+ ]);
553
+ await assert.rejects(
554
+ () => launchWebDriver({ browser: 'safari' }, dependencies),
555
+ /exited with code 1 before it was ready: safaridriver: busy/
556
+ );
557
+ assert.equal(started.length, 3);
558
+ });
559
+
560
+ it('adds the driver exit status to a refused session request', async () => {
561
+ const { dependencies } = flakySafariDependencies([
562
+ 'refused',
563
+ 'refused',
564
+ 'refused',
565
+ ]);
566
+ await assert.rejects(
567
+ () => launchWebDriver({ browser: 'safari' }, dependencies),
568
+ (error) =>
569
+ /ECONNREFUSED .*\(attempt 3\)/.test(error.message) &&
570
+ /WebDriver server at http:\/\/127\.0\.0\.1:49003/.test(error.message)
571
+ );
572
+ });
573
+
574
+ it('does not retry other browsers', async () => {
575
+ const { dependencies, started, mock } = launchDependencies({
576
+ checkAccess: accessOnly('/opt/drivers/chromedriver'),
577
+ safariRetryDelay: 0,
578
+ });
579
+ mock.selenium.webdriver.Builder.prototype.build = () => {
580
+ throw new Error('ECONNREFUSED connect ECONNREFUSED 127.0.0.1:41001');
581
+ };
582
+ await assert.rejects(
583
+ () =>
584
+ launchWebDriver(
585
+ { driverPath: '/opt/drivers/chromedriver' },
586
+ dependencies
587
+ ),
588
+ /ECONNREFUSED/
589
+ );
590
+ assert.equal(started.length, 1);
491
591
  });
492
592
  });
493
593
 
@@ -192,6 +192,81 @@ describe('selectors', () => {
192
192
  });
193
193
  });
194
194
 
195
+ describe('normalizeSelector with Playwright text selectors', () => {
196
+ /** A page whose evaluate records the parsed selector it was given. */
197
+ function capturingPage() {
198
+ const page = {
199
+ parsed: null,
200
+ evaluate: async (_fn, parsed) => {
201
+ page.parsed = parsed;
202
+ return 'button:nth-of-type(1)';
203
+ },
204
+ };
205
+ return page;
206
+ }
207
+
208
+ it('splits :has-text into base selector and text', async () => {
209
+ const page = capturingPage();
210
+ await normalizeSelector({
211
+ page,
212
+ engine: 'playwright',
213
+ selector: 'nav a:has-text("Sign in")',
214
+ });
215
+ assert.deepStrictEqual(page.parsed, {
216
+ baseSelector: 'nav a',
217
+ text: 'Sign in',
218
+ exact: false,
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
+ });
234
+ });
235
+
236
+ it('returns an unparseable selector unchanged', async () => {
237
+ for (const selector of [
238
+ ':has-text("no base")',
239
+ 'a:has-text("")',
240
+ 'a:has-text("unclosed',
241
+ ]) {
242
+ const page = capturingPage();
243
+ const result = await normalizeSelector({
244
+ page,
245
+ engine: 'playwright',
246
+ selector,
247
+ });
248
+ assert.strictEqual(result, selector);
249
+ assert.strictEqual(page.parsed, null);
250
+ }
251
+ });
252
+
253
+ it('rejects a long unparseable selector in linear time', async () => {
254
+ // The former /^(.+?):has-text\("(.+?)"\)$/ backtracked quadratically
255
+ // on this input: about 18s at this length (CodeQL js/polynomial-redos,
256
+ // issue #128; experiments/issue-128/text-selector-redos.mjs).
257
+ const selector = `a${':has-text("'.repeat(50_000)}`;
258
+ const started = performance.now();
259
+ const result = await normalizeSelector({
260
+ page: capturingPage(),
261
+ engine: 'playwright',
262
+ selector,
263
+ });
264
+ const elapsed = performance.now() - started;
265
+ assert.strictEqual(result, selector);
266
+ assert.ok(elapsed < 1000, `took ${elapsed.toFixed(0)}ms`);
267
+ });
268
+ });
269
+
195
270
  describe('normalizeSelector', () => {
196
271
  it('should throw when selector is not provided', async () => {
197
272
  const page = createMockPlaywrightPage();
@@ -0,0 +1,60 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createServer } from 'node:http';
4
+ import { connect } from 'node:net';
5
+ import { closeServer, startFixtureHost } from '../../helpers/fixture-server.js';
6
+
7
+ /**
8
+ * Open a connection the way a browser preconnects: TCP only, no request.
9
+ * Node 24's http.Server#close() waits for such a socket until the client
10
+ * drops it, which is what stalled the Safari smoke cleanup (issue #128).
11
+ */
12
+ async function preconnect(url) {
13
+ const { hostname, port } = new URL(url);
14
+ const socket = connect(Number(port), hostname);
15
+ socket.on('error', () => {});
16
+ await new Promise((resolve) => socket.once('connect', resolve));
17
+ return socket;
18
+ }
19
+
20
+ /** Fail instead of hanging when a close does not finish in time. */
21
+ function within(promise, ms) {
22
+ let timer;
23
+ return Promise.race([
24
+ promise,
25
+ new Promise((_resolve, reject) => {
26
+ timer = setTimeout(
27
+ () => reject(new Error(`did not close within ${ms}ms`)),
28
+ ms
29
+ );
30
+ }),
31
+ ]).finally(() => clearTimeout(timer));
32
+ }
33
+
34
+ describe('fixture server shutdown', { timeout: 10_000 }, () => {
35
+ it('closeServer does not wait for a preconnected idle socket', async () => {
36
+ const server = createServer((_request, response) => response.end('ok'));
37
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
38
+ const socket = await preconnect(
39
+ `http://127.0.0.1:${server.address().port}/`
40
+ );
41
+ try {
42
+ await within(closeServer(server), 2_000);
43
+ assert.equal(server.listening, false);
44
+ } finally {
45
+ socket.destroy();
46
+ }
47
+ });
48
+
49
+ it('startFixtureHost close does not wait for a preconnected idle socket', async () => {
50
+ const host = await startFixtureHost((_path, _request, response) =>
51
+ response.end('ok')
52
+ );
53
+ const socket = await preconnect(host.baseUrl);
54
+ try {
55
+ await within(host.close(), 2_000);
56
+ } finally {
57
+ socket.destroy();
58
+ }
59
+ });
60
+ });
@@ -198,12 +198,15 @@ describe('Rust Playwright protocol bindings', () => {
198
198
  const installed = JSON.parse(
199
199
  fs.readFileSync(path.join(root, 'package.json'), 'utf8')
200
200
  ).version;
201
- if (installed !== version) {
202
- t.skip(
203
- `playwright-core ${installed} is installed, the spec is ${version}`
204
- );
205
- return;
206
- }
201
+ // A version mismatch is the drift this test exists to catch, so it fails
202
+ // rather than skips (issue #128: the same skip in the Puppeteer bindings
203
+ // test hid a stale manifest on every CI run).
204
+ assert.equal(
205
+ version,
206
+ installed,
207
+ `playwright-core ${installed} is installed, the spec is ${version}; ` +
208
+ 'refresh rust/protocol/playwright/ as its README describes'
209
+ );
207
210
  // The driver validates every message against these schemes; one exists per
208
211
  // command (`<Interface><Command>Params`) and event (`<Interface><Event>Event`).
209
212
  const bundle = fs.readFileSync(
@@ -7,7 +7,8 @@
7
7
  * extracts from puppeteer-core's `lib/types.d.ts`. These tests fail when:
8
8
  *
9
9
  * - the committed wrappers differ from what the generator writes;
10
- * - the committed manifest differs from the installed puppeteer-core;
10
+ * - the committed manifest differs from the installed puppeteer-core,
11
+ * including its version (a mismatch fails rather than skips);
11
12
  * - a public method or getter declared in `lib/types.d.ts` (own or inherited)
12
13
  * has no typed entry point in Rust or Python.
13
14
  */
@@ -131,12 +132,15 @@ describe('Puppeteer bindings (Rust and Python)', () => {
131
132
  t.skip('puppeteer-core is not installed');
132
133
  return;
133
134
  }
134
- if (installed.version !== manifest.version) {
135
- t.skip(
136
- `puppeteer-core ${installed.version} is installed, the manifest is ${manifest.version}`
137
- );
138
- return;
139
- }
135
+ // A version mismatch is exactly the drift this test exists to catch (issue
136
+ // #128: puppeteer-core was upgraded to 25.12.0 while the manifest stayed
137
+ // at 25.10.0, and a skip here hid it), so it fails instead of skipping.
138
+ assert.equal(
139
+ manifest.version,
140
+ installed.version,
141
+ `puppeteer-core ${installed.version} is installed, the manifest is ${manifest.version}; ` +
142
+ 'run node scripts/generate-puppeteer-bindings.mjs --update-api'
143
+ );
140
144
  const fresh = {
141
145
  package: 'puppeteer-core',
142
146
  version: installed.version,
@@ -149,6 +153,23 @@ describe('Puppeteer bindings (Rust and Python)', () => {
149
153
  );
150
154
  });
151
155
 
156
+ it('are read through virtual paths the TypeScript API can find on Windows', () => {
157
+ // TypeScript 7's virtual file system matches file names as exact keys, and
158
+ // the native compiler asks for them with forward slashes. Keys built with
159
+ // path.join on Windows (`D:\a\…\tsconfig.json`) were never found, so the
160
+ // project came back undefined (issue #128, run 37526767522).
161
+ const paths = extractor.virtualProjectPaths(
162
+ 'D:\\a\\browser-commander\\browser-commander'
163
+ );
164
+ assert.deepEqual(paths, {
165
+ cwd: 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual',
166
+ config:
167
+ 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual/tsconfig.json',
168
+ declaration:
169
+ 'D:/a/browser-commander/browser-commander/experiments/puppeteer-api-virtual/types.d.ts',
170
+ });
171
+ });
172
+
152
173
  it('give every declared Puppeteer method a typed entry point', () => {
153
174
  const missing = [];
154
175
  let checked = 0;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * CI commits use the bot's documented no-reply address (issue #128).
3
+ *
4
+ * GitHub documents a user's no-reply address as
5
+ * `{user.id}+{user.login}@users.noreply.github.com`, and the actions/checkout
6
+ * README configures `41898282+github-actions[bot]@users.noreply.github.com`
7
+ * for commits pushed from a workflow. The three release paths here used the
8
+ * legacy `github-actions[bot]@users.noreply.github.com` instead.
9
+ *
10
+ * Checked on 2026-10-06: GitHub still links the legacy address to the bot
11
+ * (the commits API returns `author.login: github-actions[bot]` for release
12
+ * commits 9f8a552, cca988f, fda33e7 and d159aeb), so no release was ever
13
+ * unattributed here. The JS pipeline template switched for that reason
14
+ * (link-foundation/js-ai-driven-development-pipeline-template#144), which
15
+ * this repository could not reproduce. The test pins the documented form so
16
+ * the three packages agree with each other and with the template, without
17
+ * relying on GitHub continuing to resolve the undocumented one.
18
+ */
19
+
20
+ import assert from 'node:assert/strict';
21
+ import { execFileSync } from 'node:child_process';
22
+ import { readFileSync } from 'node:fs';
23
+ import { describe, it } from 'node:test';
24
+
25
+ import { REPO_ROOT, repoPath } from '../../helpers/repo.js';
26
+
27
+ const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com';
28
+ const UNPREFIXED = 'github-actions[bot]@users.noreply.github.com';
29
+
30
+ // Every place that configures the commit author for CI, per language.
31
+ const ROOTS = [
32
+ '.github/workflows',
33
+ 'scripts',
34
+ 'js/scripts',
35
+ 'python/scripts',
36
+ 'rust/scripts',
37
+ ];
38
+
39
+ // Tracked files only: a local __pycache__ holds stale copies of the scripts.
40
+ function trackedFiles(roots) {
41
+ return execFileSync('git', ['ls-files', '-z', '--', ...roots], {
42
+ cwd: REPO_ROOT,
43
+ encoding: 'utf8',
44
+ })
45
+ .split('\0')
46
+ .filter(Boolean);
47
+ }
48
+
49
+ describe('github-actions[bot] commit attribution', () => {
50
+ const files = trackedFiles(ROOTS);
51
+
52
+ it('configures the prefixed bot e-mail somewhere', () => {
53
+ assert.ok(
54
+ files.some((file) =>
55
+ readFileSync(repoPath(file), 'utf8').includes(BOT_EMAIL)
56
+ ),
57
+ `no file configures ${BOT_EMAIL}`
58
+ );
59
+ });
60
+
61
+ it('never configures the unprefixed bot e-mail', () => {
62
+ const offenders = files.filter((file) =>
63
+ readFileSync(repoPath(file), 'utf8')
64
+ .split(BOT_EMAIL)
65
+ .join('')
66
+ .includes(UNPREFIXED)
67
+ );
68
+ assert.deepEqual(offenders, []);
69
+ });
70
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The duplication gate must print the clones it fails on, not the baseline
3
+ * (issue #128).
4
+ *
5
+ * With the console reporter, `jscpd --baseline … --fail-on-new-clones` lists
6
+ * every clone it finds. A passing run printed 272 "Clone found" blocks, about
7
+ * 840 lines, all of them clones the baseline already accepts. The one clone
8
+ * that fails a run was marked only by a `[NEW]` tag somewhere in that list.
9
+ */
10
+
11
+ import assert from 'node:assert/strict';
12
+ import { spawnSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
16
+ import { describe, it } from 'node:test';
17
+
18
+ import {
19
+ formatNewClones,
20
+ resolveJscpdCli,
21
+ } from '../../../scripts/check-duplication.mjs';
22
+ import { repoPath } from '../../helpers/repo.js';
23
+
24
+ const SCRIPT = repoPath('js/scripts/check-duplication.mjs');
25
+
26
+ function clone(first, second, isNew) {
27
+ const side = (name, start, end) => ({ name, start, end });
28
+ return {
29
+ format: 'javascript',
30
+ lines: second[2] - second[1] + 1,
31
+ isNew,
32
+ firstFile: side(...first),
33
+ secondFile: side(...second),
34
+ };
35
+ }
36
+
37
+ const REPORT = {
38
+ duplicates: [
39
+ clone(['README.md:javascript', 61, 92], ['src/README.md', 29, 60], false),
40
+ clone(['src/a.js', 21, 40], ['src/b,c.js', 3, 22], true),
41
+ ],
42
+ };
43
+
44
+ describe('formatNewClones', () => {
45
+ it('lists only the clones missing from the baseline', () => {
46
+ const text = formatNewClones(REPORT, { githubActions: false });
47
+ assert.match(text, /src\/a\.js:21-40 ~ src\/b,c\.js:3-22 \(20 lines\)/);
48
+ assert.doesNotMatch(text, /README/);
49
+ });
50
+
51
+ it('raises one error annotation per new clone, on the repository path', () => {
52
+ const text = formatNewClones(REPORT, {
53
+ githubActions: true,
54
+ cwd: repoPath('js'),
55
+ workspace: repoPath(),
56
+ });
57
+ const lines = text.trim().split('\n');
58
+ assert.equal(lines.length, 1);
59
+ assert.equal(
60
+ lines[0],
61
+ '::error file=js/src/a.js,line=21,endLine=40,title=New duplicated code::' +
62
+ '20 lines duplicate js/src/b,c.js:3-22. Extract the shared code, or ' +
63
+ 'accept the clone with `npm run check:duplication:update`.'
64
+ );
65
+ });
66
+
67
+ it('strips the embedded-language suffix jscpd adds to Markdown code', () => {
68
+ const report = {
69
+ duplicates: [
70
+ clone(
71
+ ['README.md:javascript', 5, 9],
72
+ ['docs/x.md:markdown', 1, 5],
73
+ true
74
+ ),
75
+ ],
76
+ };
77
+ assert.match(
78
+ formatNewClones(report, {
79
+ githubActions: true,
80
+ cwd: repoPath('js'),
81
+ workspace: repoPath(),
82
+ }),
83
+ /^::error file=js\/README\.md,line=5,endLine=9,/
84
+ );
85
+ });
86
+ });
87
+
88
+ describe('check-duplication.mjs end to end', () => {
89
+ function run(cwd) {
90
+ return spawnSync(process.execPath, [SCRIPT], {
91
+ cwd,
92
+ encoding: 'utf8',
93
+ env: { ...process.env, GITHUB_ACTIONS: '', NO_COLOR: '1' },
94
+ });
95
+ }
96
+
97
+ it('is quiet about accepted clones and names a new one', (t) => {
98
+ if (!resolveJscpdCli()) {
99
+ return t.skip('jscpd is not installed; run npm ci in js/');
100
+ }
101
+ const fixture = fs.mkdtempSync(path.join(tmpdir(), 'check-duplication-'));
102
+ try {
103
+ fs.copyFileSync(
104
+ repoPath('js/.jscpd.json'),
105
+ path.join(fixture, '.jscpd.json')
106
+ );
107
+ const body = fs.readFileSync(repoPath('js/src/core/dialog-manager.js'));
108
+ fs.writeFileSync(path.join(fixture, 'one.js'), body);
109
+ fs.writeFileSync(path.join(fixture, 'two.js'), body);
110
+ const update = spawnSync(
111
+ process.execPath,
112
+ [
113
+ resolveJscpdCli(),
114
+ '.',
115
+ '--baseline',
116
+ '.jscpd-baseline.json',
117
+ '--update-baseline',
118
+ '--reporters',
119
+ 'silent',
120
+ ],
121
+ { cwd: fixture, encoding: 'utf8' }
122
+ );
123
+ assert.equal(update.status, 0, update.stderr);
124
+
125
+ const accepted = run(fixture);
126
+ assert.equal(accepted.status, 0, accepted.stdout + accepted.stderr);
127
+ assert.doesNotMatch(accepted.stdout, /Clone found|one\.js/);
128
+ assert.match(accepted.stdout, /\d+ clones?, 0 new/);
129
+
130
+ fs.writeFileSync(path.join(fixture, 'three.js'), body);
131
+ const failed = run(fixture);
132
+ assert.equal(failed.status, 1, failed.stdout + failed.stderr);
133
+ assert.match(failed.stdout, /^New clone: \S+:\d+-\d+ ~ /m);
134
+ assert.match(failed.stdout, /clones, [1-9]\d* new/);
135
+ } finally {
136
+ fs.rmSync(fixture, { recursive: true, force: true });
137
+ }
138
+ });
139
+ });