browser-commander 0.16.1 → 0.17.1

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 (71) hide show
  1. package/.changeset/config.json +2 -1
  2. package/.jscpd-baseline.json +263 -0
  3. package/.jscpd.json +1 -3
  4. package/.prettierignore +1 -0
  5. package/CHANGELOG.md +19 -0
  6. package/eslint.config.js +23 -1
  7. package/package.json +13 -24
  8. package/scripts/changeset-version.mjs +2 -6
  9. package/scripts/check-release-format.mjs +80 -0
  10. package/scripts/create-github-release.mjs +33 -13
  11. package/scripts/create-manual-changeset.mjs +7 -8
  12. package/scripts/format-github-release.mjs +6 -7
  13. package/scripts/format-release-notes.mjs +7 -8
  14. package/scripts/instant-version-bump.mjs +7 -8
  15. package/scripts/publish-to-npm.mjs +7 -8
  16. package/scripts/setup-npm.mjs +2 -6
  17. package/scripts/version-and-commit.mjs +18 -7
  18. package/src/browser/browser-cookie-credentials.js +3 -1
  19. package/src/browser/browser-cookies.js +6 -2
  20. package/src/browser/browser-profiles.js +1 -1
  21. package/src/browser/launch-options.js +31 -8
  22. package/src/browser/launcher.js +16 -0
  23. package/src/exports.js +34 -0
  24. package/src/fingerprint/apply.js +116 -0
  25. package/src/fingerprint/automation-parity.js +224 -0
  26. package/src/fingerprint/cdp-overrides.js +176 -0
  27. package/src/fingerprint/derive.js +104 -0
  28. package/src/fingerprint/init-payload.js +181 -0
  29. package/src/fingerprint/init-script.js +104 -0
  30. package/src/fingerprint/limitations.js +107 -0
  31. package/src/fingerprint/limitations.json +96 -0
  32. package/src/fingerprint/presets.js +244 -0
  33. package/src/fingerprint/profile.js +428 -0
  34. package/src/tests/index.js +2 -1
  35. package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
  36. package/tests/helpers/mocks.js +4 -2
  37. package/tests/helpers/repo.js +74 -0
  38. package/tests/unit/browser/launch-options.test.js +9 -1
  39. package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
  40. package/tests/unit/fingerprint/apply.test.js +271 -0
  41. package/tests/unit/fingerprint/automation-parity.test.js +212 -0
  42. package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
  43. package/tests/unit/fingerprint/derive.test.js +105 -0
  44. package/tests/unit/fingerprint/init-script.test.js +357 -0
  45. package/tests/unit/fingerprint/limitations.test.js +184 -0
  46. package/tests/unit/fingerprint/presets.test.js +135 -0
  47. package/tests/unit/fingerprint/profile.test.js +282 -0
  48. package/tests/unit/scripts/cargo-lock-sync.test.js +110 -0
  49. package/tests/unit/scripts/changelog-collection-order.test.js +144 -0
  50. package/tests/unit/scripts/changeset-formatter.test.js +108 -0
  51. package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
  52. package/tests/unit/scripts/check-release-format.test.js +131 -0
  53. package/tests/unit/scripts/check-web-archive.test.js +191 -0
  54. package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
  55. package/tests/unit/scripts/ci-workflow-policy.test.js +292 -153
  56. package/tests/unit/scripts/command-error-text.test.js +83 -0
  57. package/tests/unit/scripts/command-stream-errexit.test.js +132 -0
  58. package/tests/unit/scripts/debug-print.test.js +67 -0
  59. package/tests/unit/scripts/jscpd-config.test.js +141 -0
  60. package/tests/unit/scripts/pre-commit-config.test.js +214 -0
  61. package/tests/unit/scripts/read-manifest.test.js +194 -0
  62. package/tests/unit/scripts/release-tags.test.js +96 -0
  63. package/tests/unit/scripts/required-docs.test.js +145 -0
  64. package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
  65. package/tests/unit/scripts/rust-changelog.test.js +147 -0
  66. package/tests/unit/scripts/use-module-adoption.test.js +74 -0
  67. package/tests/unit/scripts/use-module-integration.test.js +64 -0
  68. package/tests/unit/scripts/use-module.test.js +182 -0
  69. package/.husky/pre-commit +0 -1
  70. package/.prettierrc +0 -10
  71. package/deno.json +0 -7
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Guard for the lockfile half of RC-C.
3
+ *
4
+ * `rust/scripts/version-and-commit.mjs` writes the new version into
5
+ * Cargo.toml. Cargo.lock carries the workspace member's own version too, so a
6
+ * bump that touches only the manifest leaves the two disagreeing, and every
7
+ * job that builds the release commit with `cargo build --locked` fails with
8
+ * "the lock file needs to be updated but --locked was passed".
9
+ *
10
+ * The drift was invisible while RC-C was live: the release commit was never
11
+ * pushed, so main kept a Cargo.toml and a Cargo.lock that still agreed.
12
+ * Fixing RC-C is what makes this reachable.
13
+ *
14
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-C
15
+ */
16
+
17
+ import assert from 'node:assert/strict';
18
+ import { readFileSync } from 'node:fs';
19
+ import { dirname, join } from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { describe, it } from 'node:test';
22
+
23
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
24
+
25
+ /**
26
+ * Read the version of a named package out of a Cargo.lock.
27
+ *
28
+ * The line separator is `\r?\n` because a Windows runner checks the file out
29
+ * with CRLF: `git config core.autocrlf` is `true` on GitHub's windows-latest
30
+ * image, so a pattern anchored on a bare `\n` matches nothing there and the
31
+ * lookup silently returns `undefined` -- a guard that passes on two operating
32
+ * systems and fails on the third for a reason that has nothing to do with what
33
+ * it guards.
34
+ *
35
+ * @param {string} lock
36
+ * @param {string} name
37
+ * @returns {string | undefined}
38
+ */
39
+ function lockedVersion(lock, name) {
40
+ const pattern = new RegExp(
41
+ `\\[\\[package\\]\\]\\r?\\nname = "${name}"\\r?\\nversion = "([^"]+)"`
42
+ );
43
+ return pattern.exec(lock)?.[1];
44
+ }
45
+
46
+ describe('Cargo.lock stays in step with the release bump', () => {
47
+ const cargoToml = readFileSync(join(repoRoot, 'rust/Cargo.toml'), 'utf8');
48
+ const cargoLock = readFileSync(join(repoRoot, 'rust/Cargo.lock'), 'utf8');
49
+ const script = readFileSync(
50
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
51
+ 'utf8'
52
+ );
53
+
54
+ it('records the manifest version in the lockfile today', () => {
55
+ const manifestVersion = /^\[package\][\s\S]*?^version = "([^"]+)"/m.exec(
56
+ cargoToml
57
+ )?.[1];
58
+ assert.ok(manifestVersion, 'Cargo.toml has no [package] version');
59
+ assert.equal(
60
+ lockedVersion(cargoLock, 'browser-commander'),
61
+ manifestVersion,
62
+ 'a build with --locked would refuse this tree'
63
+ );
64
+ });
65
+
66
+ it('reads a lockfile that was checked out with CRLF line endings', () => {
67
+ const crlf = cargoLock.replace(/\r?\n/g, '\r\n');
68
+ assert.equal(
69
+ lockedVersion(crlf, 'browser-commander'),
70
+ lockedVersion(cargoLock, 'browser-commander'),
71
+ 'the same lockfile must read the same on a Windows checkout'
72
+ );
73
+ });
74
+
75
+ it('refreshes the lockfile as part of the bump', () => {
76
+ assert.match(
77
+ script,
78
+ /cargo update --workspace/,
79
+ 'bumping Cargo.toml without refreshing Cargo.lock breaks --locked builds'
80
+ );
81
+ });
82
+
83
+ it('refreshes the lockfile after the manifest, not before', () => {
84
+ const bump = script.indexOf('updateCargoToml(newVersion)');
85
+ const refresh = script.indexOf('cargo update --workspace');
86
+ assert.ok(bump > 0 && refresh > 0);
87
+ assert.ok(
88
+ bump < refresh,
89
+ 'refreshing before the bump would re-lock the old version'
90
+ );
91
+ });
92
+
93
+ it('stages the lockfile in the release commit', () => {
94
+ const add = /git add -A ([^`]+)`/.exec(script)?.[1] ?? '';
95
+ assert.ok(
96
+ add.split(/\s+/).includes('Cargo.lock'),
97
+ `Cargo.lock is refreshed but never committed (staged: ${add.trim()})`
98
+ );
99
+ });
100
+
101
+ it('is what the release job later builds with --locked', () => {
102
+ // If the workflow stopped passing --locked the drift would go unnoticed
103
+ // again, so the guard is only meaningful while this flag is there.
104
+ const workflow = readFileSync(
105
+ join(repoRoot, '.github/workflows/rust.yml'),
106
+ 'utf8'
107
+ );
108
+ assert.match(workflow, /cargo build --locked/);
109
+ });
110
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Guard for RC-I: collecting the changelog before the version bump.
3
+ *
4
+ * Both manual-release jobs used to collect fragments in a workflow step that
5
+ * ran ahead of the bump, and both named the section wrongly as a result:
6
+ *
7
+ * rust.yml `node scripts/collect-changelog.mjs` reads the version out of
8
+ * Cargo.toml, which at that point still held the version that
9
+ * was already released, so the notes were filed under it and
10
+ * the version being released got none.
11
+ * python.yml `scriv collect --version "$BUMP_TYPE"`. `--version` names the
12
+ * new changelog section rather than selecting a bump, so a
13
+ * release wrote a section titled "patch".
14
+ *
15
+ * Either way the release notes went missing: create-github-release looks for
16
+ * the version it is releasing and falls back to a bare "Release <version>".
17
+ *
18
+ * Collection now happens inside the version-and-commit scripts, after the
19
+ * bump has computed the version. These assertions are on the workflows,
20
+ * because that is where the ordering lived.
21
+ *
22
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md
23
+ * Reproduction: experiments/scriv-collect-version/run.sh
24
+ */
25
+
26
+ import assert from 'node:assert/strict';
27
+ import { readFileSync } from 'node:fs';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { describe, it } from 'node:test';
31
+
32
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
33
+
34
+ /**
35
+ * Read a workflow with its comment lines removed.
36
+ *
37
+ * The comments explain the very mistakes these assertions look for, so
38
+ * matching against the raw file would find the explanation and report the bug
39
+ * as still present.
40
+ * @param {string} name
41
+ * @returns {string}
42
+ */
43
+ function workflow(name) {
44
+ return readFileSync(join(repoRoot, '.github/workflows', name), 'utf8')
45
+ .split('\n')
46
+ .filter((line) => !/^\s*#/.test(line))
47
+ .join('\n');
48
+ }
49
+
50
+ describe('changelog fragments are collected after the version bump', () => {
51
+ it('never passes a bump type where a version name is expected', () => {
52
+ // `scriv collect --version` is documented as "The version name to use for
53
+ // this entry", so "$BUMP_TYPE" lands in the changelog verbatim.
54
+ assert.doesNotMatch(
55
+ workflow('python.yml'),
56
+ /scriv collect --version "\$BUMP_TYPE"/,
57
+ 'scriv would head the release section with the word "patch"'
58
+ );
59
+ });
60
+
61
+ it('has no workflow step that collects ahead of the bump', () => {
62
+ for (const [name, collector] of [
63
+ ['rust.yml', 'collect-changelog.mjs'],
64
+ ['python.yml', 'scriv collect'],
65
+ ]) {
66
+ const content = workflow(name);
67
+ const collect = content.indexOf(collector);
68
+ if (collect < 0) {
69
+ continue;
70
+ }
71
+ const bump = content.indexOf('name: Version and commit');
72
+ assert.ok(
73
+ bump < 0 || collect > bump,
74
+ `${name} collects the changelog before the bump names the version`
75
+ );
76
+ }
77
+ });
78
+
79
+ it('collects inside the scripts that know the new version', () => {
80
+ const rust = readFileSync(
81
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
82
+ 'utf8'
83
+ );
84
+ assert.match(rust, /updateChangelog\('\.', newVersion, fragments\)/);
85
+
86
+ const python = readFileSync(
87
+ join(repoRoot, 'python/scripts/version_and_commit.py'),
88
+ 'utf8'
89
+ );
90
+ assert.match(python, /collect_changelog\(new_version, project_root\)/);
91
+ });
92
+
93
+ it('orders collection after the bump inside those scripts', () => {
94
+ const rust = readFileSync(
95
+ join(repoRoot, 'rust/scripts/version-and-commit.mjs'),
96
+ 'utf8'
97
+ );
98
+ assert.ok(
99
+ rust.indexOf('const newVersion = await findNextAvailableVersion') <
100
+ rust.indexOf('collectFragments()')
101
+ );
102
+
103
+ const python = readFileSync(
104
+ join(repoRoot, 'python/scripts/version_and_commit.py'),
105
+ 'utf8'
106
+ );
107
+ assert.ok(
108
+ python.indexOf('new_version = bump_version(') <
109
+ python.indexOf('collect_changelog(new_version')
110
+ );
111
+ });
112
+
113
+ it('writes headings at the level the release scripts read', () => {
114
+ // scriv defaults to level 1 while create_github_release.py extracts from
115
+ // `## <version>`, so the two could never meet.
116
+ const pyproject = readFileSync(
117
+ join(repoRoot, 'python/pyproject.toml'),
118
+ 'utf8'
119
+ );
120
+ assert.match(pyproject, /^md_header_level = "2"$/m);
121
+
122
+ const release = readFileSync(
123
+ join(repoRoot, 'python/scripts/create_github_release.py'),
124
+ 'utf8'
125
+ );
126
+ assert.match(release, /rf"\^## \{re\.escape\(version\)\}/);
127
+ });
128
+
129
+ it('says so in the run summary when the notes are missing anyway', () => {
130
+ // The fallback body is a silent downgrade: the release still goes out and
131
+ // the job still passes.
132
+ for (const path of [
133
+ 'js/scripts/create-github-release.mjs',
134
+ 'rust/scripts/create-github-release.mjs',
135
+ 'python/scripts/create_github_release.py',
136
+ ]) {
137
+ assert.match(
138
+ readFileSync(join(repoRoot, path), 'utf8'),
139
+ /::warning::/,
140
+ `${path} drops the release notes without a word`
141
+ );
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Guard for RC-G: `changeset version` aborting on a formatter that is not
3
+ * installed.
4
+ *
5
+ * @changesets/cli v3 formats the changelog it writes. With the default
6
+ * `"format": "auto"`, @changesets/format walks `defaultDetectOrder` --
7
+ * dprint, deno, oxfmt, biome, prettier -- and picks the first formatter whose
8
+ * config file it finds. `js/deno.json` existed -- orphaned configuration no
9
+ * workflow ever used -- so deno won over the prettier this package actually
10
+ * depends on. deno is the one entry in that table with
11
+ * no `packageName`, so it is spawned straight off PATH with no existence
12
+ * check and no npx fallback; GitHub's ubuntu-latest runner has no deno.
13
+ *
14
+ * `changeset version` therefore bumped package.json, wrote CHANGELOG.md, and
15
+ * then died with `spawn deno ENOENT` before deleting the changeset it had
16
+ * just consumed. The release still shipped -- 0.17.0 is on npm -- but
17
+ * `merged-loud-river.md` survived to be released a second time, and the
18
+ * half-written CHANGELOG.md kept trailing whitespace that fails
19
+ * `prettier --check`.
20
+ *
21
+ * Reproduction: experiments/ci-repro/repro-changeset-deno-formatter.mjs
22
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-G
23
+ */
24
+
25
+ import assert from 'node:assert/strict';
26
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+ import { dirname, join } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { describe, it } from 'node:test';
31
+
32
+ const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..');
33
+ const require = createRequire(import.meta.url);
34
+
35
+ const config = JSON.parse(
36
+ readFileSync(join(packageRoot, '.changeset/config.json'), 'utf-8')
37
+ );
38
+
39
+ describe('changesets formatter', () => {
40
+ it('pins a formatter instead of auto-detecting one', () => {
41
+ assert.notEqual(
42
+ config.format,
43
+ undefined,
44
+ 'omitting `format` means "auto", which detects whichever formatter ' +
45
+ 'config happens to sit beside package.json and then fails on a ' +
46
+ 'runner that has no such binary'
47
+ );
48
+ assert.notEqual(config.format, 'auto', 'same as omitting it');
49
+ });
50
+
51
+ it('pins a formatter this package can actually run', () => {
52
+ // `false` disables formatting, which is also a safe answer.
53
+ if (config.format === false) {
54
+ return;
55
+ }
56
+
57
+ const { defaultDetectOrder } = require('@changesets/format');
58
+ assert.ok(
59
+ defaultDetectOrder.includes(config.format),
60
+ `unknown formatter ${JSON.stringify(config.format)}; expected one of ${defaultDetectOrder.join(
61
+ ', '
62
+ )}`
63
+ );
64
+
65
+ // @changesets/format keeps the formatter table private, so mirror the
66
+ // one fact this test needs: which formatters it resolves through
67
+ // node_modules and which it spawns straight off PATH. `deno` is the
68
+ // single entry with no package, which is why it cannot be relied on.
69
+ const packageNames = {
70
+ dprint: 'dprint',
71
+ deno: null,
72
+ oxfmt: 'oxfmt',
73
+ biome: '@biomejs/biome',
74
+ prettier: 'prettier',
75
+ };
76
+ const packageName = packageNames[config.format];
77
+ assert.ok(
78
+ packageName,
79
+ `${config.format} is spawned straight off PATH with no npx fallback, ` +
80
+ 'so it cannot be relied on inside a release job'
81
+ );
82
+ assert.ok(
83
+ existsSync(join(packageRoot, 'node_modules', packageName)),
84
+ `${packageName} is not installed in this package`
85
+ );
86
+ });
87
+
88
+ it('leaves no consumed changeset behind', () => {
89
+ const changesetDir = join(packageRoot, '.changeset');
90
+ const pending = readdirSync(changesetDir).filter(
91
+ (file) => file.endsWith('.md') && file !== 'README.md'
92
+ );
93
+
94
+ const changelog = readFileSync(join(packageRoot, 'CHANGELOG.md'), 'utf-8');
95
+ for (const file of pending) {
96
+ const body = readFileSync(join(changesetDir, file), 'utf-8')
97
+ .replace(/^---\s*\n[\s\S]*?\n---\s*\n/, '')
98
+ .trim();
99
+ const firstLine = body.split('\n')[0].trim();
100
+ assert.ok(
101
+ firstLine.length === 0 || !changelog.includes(firstLine),
102
+ `.changeset/${file} describes a change that CHANGELOG.md already ` +
103
+ 'records as released; `changeset version` consumed it but did not ' +
104
+ 'delete it, so the next release will publish it again'
105
+ );
106
+ }
107
+ });
108
+ });
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The gate that makes a cancelled job visible.
3
+ *
4
+ * GitHub reports a job killed by its `timeout-minutes` as *cancelled*, not
5
+ * *failed*, and a run whose only casualty is a cancelled job is filed under
6
+ * `cancelled` as well - run 24045269874 of this repository is one such run on
7
+ * `main`. scripts/check-pipeline-status.sh is the only thing that looks at
8
+ * that, so these tests pin the four readings it has to get right: a failure is
9
+ * always an error, a cancellation off the default branch is a warning, a
10
+ * cancellation on the default branch is an error unless a newer run has already
11
+ * taken over the branch head.
12
+ *
13
+ * See docs/CI-TIMEOUT-BUDGETS.md.
14
+ */
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import path from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { describe, it } from 'node:test';
22
+
23
+ import {
24
+ BASH_AVAILABLE,
25
+ REPO_ROOT,
26
+ repoPath,
27
+ runBashScript,
28
+ } from '../../helpers/repo.js';
29
+
30
+ const SCRIPT = repoPath('scripts/check-pipeline-status.sh');
31
+
32
+ function runGate(needs, env = {}, options = {}) {
33
+ return runBashScript(SCRIPT, [], {
34
+ cwd: options.cwd,
35
+ env: { NEEDS_JSON: JSON.stringify(needs), ...env },
36
+ });
37
+ }
38
+
39
+ function results(map) {
40
+ return Object.fromEntries(
41
+ Object.entries(map).map(([job, result]) => [job, { result }])
42
+ );
43
+ }
44
+
45
+ describe('check-pipeline-status.sh', { skip: !BASH_AVAILABLE }, () => {
46
+ it('passes when every job succeeded or was legitimately skipped', () => {
47
+ const { status, output } = runGate(
48
+ results({ lint: 'success', release: 'skipped' }),
49
+ { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
50
+ );
51
+
52
+ assert.equal(status, 0);
53
+ assert.match(
54
+ output,
55
+ /All required jobs succeeded or were legitimately skipped/
56
+ );
57
+ });
58
+
59
+ it('fails on a failed job and names it', () => {
60
+ const { status, output } = runGate(
61
+ results({ lint: 'failure', test: 'success' }),
62
+ { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: 'abc' }
63
+ );
64
+
65
+ assert.equal(status, 1);
66
+ assert.match(output, /::error::Pipeline failed\. Failing jobs: lint/);
67
+ });
68
+
69
+ it('only warns about a cancellation off the default branch', () => {
70
+ const { status, output } = runGate(results({ test: 'cancelled' }), {
71
+ IS_MAIN: 'false',
72
+ RUN_SHA: 'abc',
73
+ BRANCH_HEAD_SHA: 'abc',
74
+ });
75
+
76
+ assert.equal(status, 0);
77
+ assert.match(output, /::warning::Cancelled jobs: test/);
78
+ });
79
+
80
+ it('fails on a cancellation on the default branch head', () => {
81
+ const { status, output } = runGate(results({ test: 'cancelled' }), {
82
+ IS_MAIN: 'true',
83
+ RUN_SHA: 'abc',
84
+ BRANCH_HEAD_SHA: 'abc',
85
+ });
86
+
87
+ assert.equal(status, 1);
88
+ assert.match(output, /::error::Pipeline has cancelled jobs on main: test/);
89
+ });
90
+
91
+ it('only warns when a newer commit already superseded the run', () => {
92
+ const { status, output } = runGate(results({ test: 'cancelled' }), {
93
+ IS_MAIN: 'true',
94
+ RUN_SHA: 'abc',
95
+ BRANCH_HEAD_SHA: 'def',
96
+ });
97
+
98
+ assert.equal(status, 0);
99
+ assert.match(output, /This run tests abc; main is at def/);
100
+ assert.match(output, /::warning::Cancelled jobs: test/);
101
+ });
102
+
103
+ it('fails rather than guesses when the branch head cannot be resolved', () => {
104
+ // A missed supersede costs one noisy warning; a missed overrun on main
105
+ // costs a silent failure, so the unresolvable case has to be the loud one.
106
+ const directory = mkdtempSync(path.join(tmpdir(), 'pipeline-status-'));
107
+
108
+ try {
109
+ const { status, output } = runGate(
110
+ results({ test: 'cancelled' }),
111
+ { IS_MAIN: 'true', RUN_SHA: 'abc', BRANCH_HEAD_SHA: '' },
112
+ { cwd: directory }
113
+ );
114
+
115
+ assert.equal(status, 1);
116
+ assert.match(output, /assuming it is current/);
117
+ assert.match(output, /::error::Pipeline has cancelled jobs on main/);
118
+ } finally {
119
+ rmSync(directory, { force: true, recursive: true });
120
+ }
121
+ });
122
+
123
+ it('refuses to run without the job results', () => {
124
+ const result = spawnSync('bash', [SCRIPT], {
125
+ cwd: REPO_ROOT,
126
+ encoding: 'utf8',
127
+ env: { ...process.env, NEEDS_JSON: '' },
128
+ });
129
+
130
+ assert.notEqual(result.status, 0);
131
+ assert.match(result.stderr, /NEEDS_JSON is required/);
132
+ });
133
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Guard for RC-E: the release commit that no workflow run ever checks.
3
+ *
4
+ * `version-and-commit.mjs` pushes the bump with `GITHUB_TOKEN`, and a push
5
+ * authenticated with `GITHUB_TOKEN` does not start a workflow run. When
6
+ * `changeset version` wrote a CHANGELOG.md that Prettier rejects, main's
7
+ * `format:check` went red and stayed red: the runs that could have reported it
8
+ * were exactly the ones the push never triggered.
9
+ *
10
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-E
11
+ */
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { readFileSync } from 'node:fs';
15
+ import { dirname, join } from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { describe, it } from 'node:test';
18
+
19
+ import {
20
+ FORMAT_CHECK_COMMAND,
21
+ assertGeneratedFilesAreFormatted,
22
+ formatFailureAnnotation,
23
+ formatFailureDetails,
24
+ } from '../../../scripts/check-release-format.mjs';
25
+
26
+ const jsRoot = join(dirname(fileURLToPath(import.meta.url)), '../../..');
27
+
28
+ /** @returns {{log: (m: string) => void, error: (m: string) => void, lines: string[], errors: string[]}} */
29
+ function recorder() {
30
+ const lines = [];
31
+ const errors = [];
32
+ return {
33
+ lines,
34
+ errors,
35
+ log: (message) => lines.push(String(message)),
36
+ error: (message) => errors.push(String(message)),
37
+ };
38
+ }
39
+
40
+ describe('release commit formatting gate', () => {
41
+ it('lets a formatted release commit through', async () => {
42
+ const console_ = recorder();
43
+ let ran = 0;
44
+
45
+ await assertGeneratedFilesAreFormatted(async () => {
46
+ ran += 1;
47
+ }, console_);
48
+
49
+ assert.equal(ran, 1);
50
+ assert.equal(console_.errors.length, 0);
51
+ assert.ok(console_.lines.some((line) => line.includes('is formatted')));
52
+ });
53
+
54
+ it('stops the release when the generated files fail the gate', async () => {
55
+ const console_ = recorder();
56
+ const failure = Object.assign(
57
+ new Error('Command failed with exit code 1'),
58
+ { stdout: '[warn] CHANGELOG.md\n', stderr: '' }
59
+ );
60
+
61
+ await assert.rejects(
62
+ assertGeneratedFilesAreFormatted(async () => {
63
+ throw failure;
64
+ }, console_),
65
+ // Rethrown unchanged: the caller's catch reports it and exits non-zero,
66
+ // so the push never happens.
67
+ (error) => error === failure
68
+ );
69
+
70
+ assert.ok(console_.errors[0].startsWith('::error title='));
71
+ assert.ok(console_.errors.includes('[warn] CHANGELOG.md'));
72
+ });
73
+
74
+ it('keeps the annotation on one line', () => {
75
+ // A workflow command ends at the first newline; anything after it is
76
+ // printed as plain log text and never becomes an annotation.
77
+ assert.ok(!formatFailureAnnotation().includes('\n'));
78
+ assert.ok(formatFailureAnnotation().startsWith('::error title='));
79
+ });
80
+
81
+ it('reads the reason out of the captured streams, not the message', () => {
82
+ // With errexit on, `error.message` is only "Command failed with exit code
83
+ // N". A call site that reported `message` would say nothing useful.
84
+ const details = formatFailureDetails({
85
+ message: 'Command failed with exit code 1',
86
+ stdout: ' [warn] CHANGELOG.md ',
87
+ stderr: 'prettier: not found',
88
+ });
89
+
90
+ assert.equal(details, '[warn] CHANGELOG.md\nprettier: not found');
91
+ assert.equal(formatFailureDetails(new Error('boom')), '');
92
+ assert.equal(formatFailureDetails(undefined), '');
93
+ });
94
+
95
+ it('runs the same gate the lint job runs', () => {
96
+ // A gate that drifts from the one CI uses would pass here and fail there.
97
+ const workflow = readFileSync(
98
+ join(jsRoot, '../.github/workflows/js.yml'),
99
+ 'utf8'
100
+ );
101
+ assert.ok(
102
+ workflow.includes(`run: ${FORMAT_CHECK_COMMAND}`),
103
+ `${FORMAT_CHECK_COMMAND} is no longer the workflow's formatting gate`
104
+ );
105
+
106
+ const script = readFileSync(
107
+ join(jsRoot, 'scripts/version-and-commit.mjs'),
108
+ 'utf8'
109
+ );
110
+ assert.ok(
111
+ script.includes(FORMAT_CHECK_COMMAND),
112
+ 'version-and-commit.mjs must run the same command it documents'
113
+ );
114
+ });
115
+
116
+ it('checks before it commits, not after it pushes', () => {
117
+ const script = readFileSync(
118
+ join(jsRoot, 'scripts/version-and-commit.mjs'),
119
+ 'utf8'
120
+ );
121
+ const check = script.indexOf('assertGeneratedFilesAreFormatted(() =>');
122
+ const commit = script.indexOf('git commit');
123
+ const push = script.indexOf('git push origin main');
124
+
125
+ assert.ok(check > 0 && commit > 0 && push > 0);
126
+ assert.ok(
127
+ check < commit && check < push,
128
+ 'a check that runs after the push cannot stop a bad release commit'
129
+ );
130
+ });
131
+ });