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,194 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import assert from 'node:assert/strict';
5
+ import { describe, it } from 'node:test';
6
+
7
+ import {
8
+ parseArguments,
9
+ readManifestField,
10
+ readTomlField,
11
+ replaceTomlField,
12
+ } from '../../../../scripts/read-manifest.mjs';
13
+
14
+ const repositoryRoot = join(
15
+ dirname(fileURLToPath(import.meta.url)),
16
+ '..',
17
+ '..',
18
+ '..',
19
+ '..'
20
+ );
21
+
22
+ /**
23
+ * The extraction the release jobs used before this script existed. Kept here so
24
+ * the regression it caused stays visible next to the fix.
25
+ * @param {string} content manifest source
26
+ * @returns {string[]} every line-anchored `version = "..."` match
27
+ */
28
+ function legacyGrepVersions(content) {
29
+ return [...content.matchAll(/^version = "([^"]*)"/gm)].map(
30
+ (match) => match[1]
31
+ );
32
+ }
33
+
34
+ function readFixture(relativePath) {
35
+ return readFileSync(join(repositoryRoot, relativePath), 'utf8');
36
+ }
37
+
38
+ describe('read-manifest', () => {
39
+ it('reproduces the multi-match that broke the Python release', () => {
40
+ // Both `[project].version` and `[tool.scriv].version` are line-anchored, so
41
+ // the old grep wrote two lines into $GITHUB_OUTPUT and the step failed with
42
+ // "Invalid format 'literal: pyproject.toml: project.version'".
43
+ const matches = legacyGrepVersions(readFixture('python/pyproject.toml'));
44
+ assert.ok(
45
+ matches.length > 1,
46
+ 'expected pyproject.toml to still carry more than one line-anchored version key'
47
+ );
48
+ assert.ok(matches.includes('literal: pyproject.toml: project.version'));
49
+ });
50
+
51
+ it('reads the project version despite the scriv literal', () => {
52
+ assert.equal(
53
+ readManifestField(join(repositoryRoot, 'python/pyproject.toml')),
54
+ readFixture('python/pyproject.toml').match(
55
+ /^\[project\][\s\S]*?^version = "([^"]+)"/m
56
+ )[1]
57
+ );
58
+ });
59
+
60
+ it('reads the crate version and name past [[bin]] and [lib]', () => {
61
+ const cargo = join(repositoryRoot, 'rust/Cargo.toml');
62
+ assert.match(readManifestField(cargo), /^\d+\.\d+\.\d+/);
63
+ assert.equal(
64
+ readManifestField(cargo, { field: 'name' }),
65
+ 'browser-commander'
66
+ );
67
+ });
68
+
69
+ it('reads the npm package version', () => {
70
+ assert.match(
71
+ readManifestField(join(repositoryRoot, 'js/package.json')),
72
+ /^\d+\.\d+\.\d+/
73
+ );
74
+ });
75
+
76
+ it('ignores a version declared in another table', () => {
77
+ const toml = [
78
+ '[tool.scriv]',
79
+ 'version = "literal: pyproject.toml: project.version"',
80
+ '',
81
+ '[project]',
82
+ 'version = "1.2.3"',
83
+ ].join('\n');
84
+ assert.equal(readTomlField(toml, 'project', 'version'), '1.2.3');
85
+ });
86
+
87
+ it('ignores a commented-out key and keeps a # inside a value', () => {
88
+ const toml = [
89
+ '[package]',
90
+ '# version = "9.9.9"',
91
+ 'version = "1.0.0" # released',
92
+ 'description = "colors like #fff"',
93
+ ].join('\n');
94
+ assert.equal(readTomlField(toml, 'package', 'version'), '1.0.0');
95
+ assert.equal(
96
+ readTomlField(toml, 'package', 'description'),
97
+ 'colors like #fff'
98
+ );
99
+ });
100
+
101
+ it('reads single-quoted TOML values', () => {
102
+ assert.equal(
103
+ readTomlField("[project]\nversion = '2.0.0'", 'project', 'version'),
104
+ '2.0.0'
105
+ );
106
+ });
107
+
108
+ it('fails loudly instead of emitting an empty version', () => {
109
+ assert.throws(
110
+ () =>
111
+ readManifestField('pyproject.toml', {
112
+ readFile: () => '[tool.scriv]\nversion = "literal: x"',
113
+ }),
114
+ /no non-empty "version" in \[project\]/
115
+ );
116
+ });
117
+
118
+ it('fails loudly on an empty value rather than releasing it', () => {
119
+ assert.throws(
120
+ () =>
121
+ readManifestField('Cargo.toml', {
122
+ readFile: () => '[package]\nversion = ""',
123
+ }),
124
+ /no non-empty "version"/
125
+ );
126
+ });
127
+
128
+ // The field name reaches this module through argv, so building a pattern
129
+ // from it let `.` match any character and let `(` throw SyntaxError instead
130
+ // of reporting a missing field. CodeQL flagged the same lines as
131
+ // js/regex-injection on PR #82.
132
+ it('compares the field name literally rather than as a pattern', () => {
133
+ const toml = ['[package]', 'axb = "wrong"', 'name = "right"'].join('\n');
134
+
135
+ assert.equal(readTomlField(toml, 'package', 'a.b'), undefined);
136
+ assert.throws(
137
+ () => replaceTomlField(toml, 'package', 'a.b', '9.9.9'),
138
+ /No \[package\] a\.b to update/
139
+ );
140
+ });
141
+
142
+ it('reports a missing field whose name is not a valid pattern', () => {
143
+ const toml = ['[package]', 'name = "right"'].join('\n');
144
+
145
+ assert.equal(readTomlField(toml, 'package', 'a('), undefined);
146
+ });
147
+
148
+ it('keeps spacing and a trailing comment when it rewrites a version', () => {
149
+ const toml = ['[package]', ' version = "1.2.3" # keep "this"'].join(
150
+ '\n'
151
+ );
152
+
153
+ assert.equal(
154
+ replaceTomlField(toml, 'package', 'version', '9.9.9'),
155
+ ['[package]', ' version = "9.9.9" # keep "this"'].join('\n')
156
+ );
157
+ });
158
+
159
+ it('refuses to bump a version it cannot rewrite', () => {
160
+ // Silently returning the file unchanged would publish the old version.
161
+ assert.throws(
162
+ () =>
163
+ replaceTomlField(
164
+ '[package]\nversion = 3',
165
+ 'package',
166
+ 'version',
167
+ '9.9.9'
168
+ ),
169
+ /is not a quoted string/
170
+ );
171
+ });
172
+
173
+ it('parses the CLI arguments the workflows pass', () => {
174
+ assert.deepEqual(
175
+ parseArguments([
176
+ 'rust/Cargo.toml',
177
+ '--field',
178
+ 'name',
179
+ '--output=crate_name',
180
+ ]),
181
+ {
182
+ manifest: 'rust/Cargo.toml',
183
+ field: 'name',
184
+ table: undefined,
185
+ output: 'crate_name',
186
+ }
187
+ );
188
+ });
189
+
190
+ it('rejects an invocation without exactly one manifest', () => {
191
+ assert.throws(() => parseArguments([]), /Usage:/);
192
+ assert.throws(() => parseArguments(['a.toml', 'b.toml']), /Usage:/);
193
+ });
194
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Guard for RC-D: the JS package and the Rust crate sharing one tag
3
+ * namespace.
4
+ *
5
+ * Both released as `v<version>`, and their version numbers overlap -- 0.4.0,
6
+ * 0.9.1 and 0.10.0 exist on both npm and crates.io. `git tag` refuses to
7
+ * recreate an existing tag, so whichever language got there first owns the
8
+ * name and the other silently releases without one. `v0.10.11` is a Rust
9
+ * crate version, but in this repository it points at the commit that
10
+ * released JS 0.17.0.
11
+ *
12
+ * Python already avoided this with a `python-v` prefix. The prefixes now
13
+ * live in one place so that the three cannot drift back into each other.
14
+ *
15
+ * Analysis: dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-D
16
+ */
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { describe, it } from 'node:test';
23
+
24
+ import { TAG_PREFIXES, releaseTag } from '../../../../scripts/release-tags.mjs';
25
+
26
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
27
+
28
+ describe('release tag namespaces', () => {
29
+ it('gives every language its own prefix', () => {
30
+ const prefixes = Object.values(TAG_PREFIXES);
31
+ assert.equal(
32
+ new Set(prefixes).size,
33
+ prefixes.length,
34
+ 'two languages sharing a prefix means one of them cannot tag its release'
35
+ );
36
+ });
37
+
38
+ it('cannot produce the same tag for two languages at the same version', () => {
39
+ // The versions that actually collided.
40
+ for (const version of ['0.4.0', '0.9.1', '0.10.0', '0.10.11', '0.17.0']) {
41
+ const tags = Object.keys(TAG_PREFIXES).map((language) =>
42
+ releaseTag(language, version)
43
+ );
44
+ assert.equal(
45
+ new Set(tags).size,
46
+ tags.length,
47
+ `version ${version} produces a duplicate tag: ${tags.join(', ')}`
48
+ );
49
+ }
50
+ });
51
+
52
+ it('keeps `v*` matching the JS package only', () => {
53
+ // Release tooling and humans both list JS releases with `git tag -l 'v*'`.
54
+ for (const [language, prefix] of Object.entries(TAG_PREFIXES)) {
55
+ if (language === 'js') {
56
+ continue;
57
+ }
58
+ assert.ok(
59
+ !prefix.startsWith('v'),
60
+ `${language} uses ${prefix}, which a 'v*' glob would also match`
61
+ );
62
+ }
63
+ });
64
+
65
+ it('rejects an unknown language rather than guessing a prefix', () => {
66
+ assert.throws(() => releaseTag('perl', '1.0.0'), /perl/);
67
+ });
68
+
69
+ it('matches the prefixes the workflows actually pass', () => {
70
+ const expected = {
71
+ 'python.yml': TAG_PREFIXES.python,
72
+ 'rust.yml': TAG_PREFIXES.rust,
73
+ };
74
+
75
+ for (const [file, prefix] of Object.entries(expected)) {
76
+ const workflow = readFileSync(
77
+ join(repoRoot, '.github/workflows', file),
78
+ 'utf-8'
79
+ );
80
+ const passed = [...workflow.matchAll(/--tag-prefix "([^"]*)"/g)].map(
81
+ (match) => match[1]
82
+ );
83
+ assert.ok(
84
+ passed.length > 0,
85
+ `${file} passes no --tag-prefix, so it falls back to the default`
86
+ );
87
+ for (const value of passed) {
88
+ assert.equal(
89
+ value,
90
+ prefix,
91
+ `${file} passes --tag-prefix "${value}" but scripts/release-tags.mjs says "${prefix}"`
92
+ );
93
+ }
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The documentation gate (CI/CD best practice #12).
3
+ *
4
+ * The repository already checks the two mechanical properties of its
5
+ * documentation: `scripts/check-file-line-limits.sh` scans every tracked `.md`
6
+ * file against the 1500-line limit, and `.github/workflows/links.yml` runs
7
+ * lychee over `./**\/*.md`, which resolves relative paths as well as URLs.
8
+ * Neither notices a document that stopped existing, and neither notices one of
9
+ * the three language READMEs quietly losing the section the other two still
10
+ * have - the exact drift `docs/feature-parity.md` exists to prevent.
11
+ *
12
+ * The template covers only the first half of that (a required-files list in
13
+ * `validate-docs`); the shared-section list is what a tri-language repository
14
+ * needs on top.
15
+ *
16
+ * These tests pin the enforcement mechanics against a fixture built from the
17
+ * script's own `--list` output, and pin the repository itself against the real
18
+ * requirement table.
19
+ */
20
+
21
+ import assert from 'node:assert/strict';
22
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import path from 'node:path';
25
+ import { describe, it } from 'node:test';
26
+
27
+ import { BASH_AVAILABLE, repoPath, runBashScript } from '../../helpers/repo.js';
28
+
29
+ const SCRIPT = repoPath('scripts/check-required-docs.sh');
30
+
31
+ function run(args = [], options = {}) {
32
+ return runBashScript(SCRIPT, args, options);
33
+ }
34
+
35
+ /** The requirement table, read back from the script that enforces it. */
36
+ function requirements() {
37
+ const { status, output } = run(['--list']);
38
+ assert.equal(status, 0, output);
39
+
40
+ const table = new Map();
41
+ for (const line of output.split('\n')) {
42
+ if (!line.trim()) {
43
+ continue;
44
+ }
45
+ const [file, section = ''] = line.split('\t');
46
+ if (!table.has(file)) {
47
+ table.set(file, []);
48
+ }
49
+ if (section) {
50
+ table.get(file).push(section);
51
+ }
52
+ }
53
+ return table;
54
+ }
55
+
56
+ /**
57
+ * A throwaway tree that satisfies the table, so a test can take exactly one
58
+ * thing away from it and watch the script notice.
59
+ */
60
+ function fixture(table) {
61
+ const root = mkdtempSync(path.join(tmpdir(), 'required-docs-'));
62
+ for (const [file, sections] of table) {
63
+ const target = path.join(root, file);
64
+ mkdirSync(path.dirname(target), { recursive: true });
65
+ writeFileSync(
66
+ target,
67
+ `# Title\n\n${sections.map((section) => `## ${section}\n\nBody.\n`).join('\n')}`
68
+ );
69
+ }
70
+ return root;
71
+ }
72
+
73
+ describe('check-required-docs.sh', { skip: !BASH_AVAILABLE }, () => {
74
+ it('passes on this repository', () => {
75
+ const { status, output } = run();
76
+ assert.equal(status, 0, output);
77
+ });
78
+
79
+ it('requires the three language READMEs to share a section list', () => {
80
+ const table = requirements();
81
+ const js = table.get('js/README.md');
82
+ assert.ok(js?.length, 'js/README.md carries no required sections');
83
+ assert.deepEqual(table.get('python/README.md'), js);
84
+ assert.deepEqual(table.get('rust/README.md'), js);
85
+ });
86
+
87
+ it('fails on a required document that is missing', () => {
88
+ const table = requirements();
89
+ const root = fixture(table);
90
+ try {
91
+ rmSync(path.join(root, 'python/README.md'));
92
+ const { status, output } = run([root]);
93
+ assert.notEqual(status, 0);
94
+ assert.match(output, /python\/README\.md/);
95
+ assert.match(output, /missing/i);
96
+ } finally {
97
+ rmSync(root, { recursive: true, force: true });
98
+ }
99
+ });
100
+
101
+ it('fails on a required section that is missing, and names it', () => {
102
+ const table = requirements();
103
+ const root = fixture(table);
104
+ const dropped = table.get('rust/README.md')[0];
105
+ try {
106
+ writeFileSync(
107
+ path.join(root, 'rust/README.md'),
108
+ `# Title\n\n${table
109
+ .get('rust/README.md')
110
+ .filter((section) => section !== dropped)
111
+ .map((section) => `## ${section}\n\nBody.\n`)
112
+ .join('\n')}`
113
+ );
114
+ const { status, output } = run([root]);
115
+ assert.notEqual(status, 0);
116
+ assert.match(output, new RegExp(`rust/README\\.md`));
117
+ assert.ok(
118
+ output.includes(dropped),
119
+ `expected the report to name the missing section ${dropped}`
120
+ );
121
+ } finally {
122
+ rmSync(root, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ it('reads a heading only as a heading, not as prose that mentions it', () => {
127
+ const table = requirements();
128
+ const root = fixture(table);
129
+ const [section] = table.get('js/README.md');
130
+ try {
131
+ writeFileSync(
132
+ path.join(root, 'js/README.md'),
133
+ `# Title\n\nSee the ${section} section below.\n\n${table
134
+ .get('js/README.md')
135
+ .slice(1)
136
+ .map((heading) => `## ${heading}\n\nBody.\n`)
137
+ .join('\n')}`
138
+ );
139
+ const { status } = run([root]);
140
+ assert.notEqual(status, 0);
141
+ } finally {
142
+ rmSync(root, { recursive: true, force: true });
143
+ }
144
+ });
145
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The wrapper that gives a long step its own deadline.
3
+ *
4
+ * A job killed by `timeout-minutes` is reported as cancelled, which is not a
5
+ * failure; a step that owns its budget exits non-zero instead, so the overrun
6
+ * turns a check red and names the deadline it blew. These tests pin the three
7
+ * properties the workflows depend on: the command's own exit status is passed
8
+ * through untouched, an overrun exits 124 with an error annotation, and the
9
+ * whole process tree is killed rather than just the direct child.
10
+ *
11
+ * See docs/CI-TIMEOUT-BUDGETS.md.
12
+ */
13
+
14
+ import assert from 'node:assert/strict';
15
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import path from 'node:path';
18
+ import { describe, it } from 'node:test';
19
+
20
+ import { BASH_AVAILABLE, repoPath, runBashScript } from '../../helpers/repo.js';
21
+
22
+ const SCRIPT = repoPath('scripts/run-with-budget-warning.sh');
23
+
24
+ function runWrapper(args, env = {}) {
25
+ return runBashScript(SCRIPT, args, {
26
+ env: { BUDGET_GRACE_SECONDS: '1', ...env },
27
+ });
28
+ }
29
+
30
+ describe('run-with-budget-warning.sh', { skip: !BASH_AVAILABLE }, () => {
31
+ it('passes a successful command through', () => {
32
+ const { status, output } = runWrapper(['5', 'quick step', 'true']);
33
+
34
+ assert.equal(status, 0);
35
+ assert.match(
36
+ output,
37
+ /quick step finished in \d+s of its 5s budget \(exit 0\)/
38
+ );
39
+ });
40
+
41
+ it("passes a failing command's own exit status through", () => {
42
+ const { status } = runWrapper([
43
+ '5',
44
+ 'failing step',
45
+ 'bash',
46
+ '-c',
47
+ 'exit 7',
48
+ ]);
49
+
50
+ assert.equal(status, 7);
51
+ });
52
+
53
+ it('exits 124 and annotates the overrun', () => {
54
+ const { status, output } = runWrapper(['1', 'slow step', 'sleep', '30']);
55
+
56
+ assert.equal(status, 124);
57
+ assert.match(
58
+ output,
59
+ /::error title=slow step exceeded its execution budget::/
60
+ );
61
+ });
62
+
63
+ it('kills the whole process group, not just the direct child', async () => {
64
+ // Test runners spawn workers; killing only the direct child leaves orphans
65
+ // holding the runner, which is why timeout(1) is not sufficient here. The
66
+ // worker is watched through the file it writes rather than through
67
+ // `kill -0`, which also succeeds for a killed-but-unreaped zombie.
68
+ const directory = mkdtempSync(path.join(tmpdir(), 'budget-wrapper-'));
69
+ const tickFile = path.join(directory, 'ticks');
70
+
71
+ try {
72
+ const { status } = runWrapper([
73
+ '1',
74
+ 'step with a worker',
75
+ 'bash',
76
+ '-c',
77
+ `while true; do echo tick >> ${JSON.stringify(tickFile)}; sleep 0.2; done & wait`,
78
+ ]);
79
+
80
+ assert.equal(status, 124);
81
+
82
+ const ticksAtTermination = readFileSync(tickFile, 'utf8').length;
83
+
84
+ assert.ok(ticksAtTermination > 0, 'the worker never started');
85
+
86
+ await new Promise((resolve) => setTimeout(resolve, 1500));
87
+
88
+ assert.equal(
89
+ readFileSync(tickFile, 'utf8').length,
90
+ ticksAtTermination,
91
+ 'the worker outlived the budget termination'
92
+ );
93
+ } finally {
94
+ rmSync(directory, { force: true, recursive: true });
95
+ }
96
+ });
97
+
98
+ it('warns before the budget expires', () => {
99
+ const { status, output } = runWrapper(['10', 'warned step', 'sleep', '2'], {
100
+ BUDGET_WARN_PERCENT: '10',
101
+ });
102
+
103
+ assert.equal(status, 0);
104
+ assert.match(
105
+ output,
106
+ /::warning title=warned step is approaching its execution budget::/
107
+ );
108
+ });
109
+
110
+ it('rejects a budget that is not a positive number of seconds', () => {
111
+ assert.equal(runWrapper(['soon', 'bad budget', 'true']).status, 2);
112
+ assert.equal(runWrapper(['0', 'bad budget', 'true']).status, 2);
113
+ assert.equal(runWrapper(['5']).status, 2);
114
+ });
115
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Guard for the half of RC-C that survives even after errexit is fixed.
3
+ *
4
+ * `rust/scripts/version-and-commit.mjs` carried its own copy of the changelog
5
+ * collector, and that copy never deleted the fragments it consumed. The
6
+ * standalone `rust/scripts/collect-changelog.mjs` did delete them, but only
7
+ * the `manual-release` job ever calls it — `auto-release` used the inline
8
+ * copy. So every automatic release re-published the same twelve entries.
9
+ *
10
+ * Both now share `rust/scripts/changelog.mjs`. These tests pin the behaviour
11
+ * the inline copy was missing.
12
+ *
13
+ * See dev/log/issues/83/pulls/84/analysis/root-causes.md, RC-C.
14
+ */
15
+
16
+ import assert from 'node:assert/strict';
17
+ import {
18
+ mkdtempSync,
19
+ mkdirSync,
20
+ writeFileSync,
21
+ readFileSync,
22
+ readdirSync,
23
+ existsSync,
24
+ } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join } from 'node:path';
27
+ import { describe, it } from 'node:test';
28
+
29
+ import {
30
+ collectFragments,
31
+ removeFragments,
32
+ updateChangelog,
33
+ INSERT_MARKER,
34
+ } from '../../../../rust/scripts/changelog.mjs';
35
+
36
+ /**
37
+ * Build a throwaway crate directory with changelog fragments.
38
+ * @param {{fragments?: Record<string,string>, changelog?: string|null}} [options]
39
+ */
40
+ function fixture(options = {}) {
41
+ const { fragments = {}, changelog = null } = options;
42
+ const dir = mkdtempSync(join(tmpdir(), 'rust-changelog-'));
43
+ const changelogDir = join(dir, 'changelog.d');
44
+ mkdirSync(changelogDir);
45
+ writeFileSync(join(changelogDir, 'README.md'), 'not a fragment\n');
46
+ for (const [name, content] of Object.entries(fragments)) {
47
+ writeFileSync(join(changelogDir, name), content);
48
+ }
49
+ if (changelog !== null) {
50
+ writeFileSync(join(dir, 'CHANGELOG.md'), changelog);
51
+ }
52
+ return { dir, changelogDir };
53
+ }
54
+
55
+ const HEADER = `# Changelog
56
+
57
+ All notable changes to this project will be documented in this file.
58
+
59
+ ${INSERT_MARKER}
60
+
61
+ ## [0.1.0] - 2024-12-30
62
+
63
+ ### Added
64
+
65
+ - Initial release
66
+ `;
67
+
68
+ describe('rust changelog collector', () => {
69
+ it('deletes the fragments it consumed', () => {
70
+ const { dir, changelogDir } = fixture({
71
+ fragments: {
72
+ '38.added.md': '### Added\n\n- A thing\n',
73
+ '49.added.md': '### Added\n\n- Another thing\n',
74
+ },
75
+ changelog: HEADER,
76
+ });
77
+
78
+ updateChangelog(dir, '0.10.11', collectFragments(dir));
79
+ removeFragments(dir);
80
+
81
+ const left = readdirSync(changelogDir);
82
+ assert.deepEqual(
83
+ left,
84
+ ['README.md'],
85
+ 'every consumed fragment must be gone; leaving them makes the next ' +
86
+ 'release re-publish the same notes'
87
+ );
88
+ });
89
+
90
+ it('never deletes README.md', () => {
91
+ const { dir, changelogDir } = fixture({
92
+ fragments: { '1.added.md': '- x\n' },
93
+ changelog: HEADER,
94
+ });
95
+ removeFragments(dir);
96
+ assert.ok(existsSync(join(changelogDir, 'README.md')));
97
+ });
98
+
99
+ it('strips frontmatter carrying the bump type', () => {
100
+ const { dir } = fixture({
101
+ fragments: {
102
+ '81.fixed.md': '---\nbump: patch\n---\n\n### Fixed\n\n- A fix\n',
103
+ },
104
+ changelog: HEADER,
105
+ });
106
+ const fragments = collectFragments(dir);
107
+ assert.ok(!fragments.includes('bump: patch'));
108
+ assert.ok(fragments.includes('- A fix'));
109
+ });
110
+
111
+ it('inserts the new entry directly after the marker, above older releases', () => {
112
+ const { dir } = fixture({
113
+ fragments: { '38.added.md': '### Added\n\n- New thing\n' },
114
+ changelog: HEADER,
115
+ });
116
+
117
+ updateChangelog(dir, '0.10.11', collectFragments(dir));
118
+
119
+ const content = readFileSync(join(dir, 'CHANGELOG.md'), 'utf-8');
120
+ assert.ok(
121
+ content.indexOf('## [0.10.11]') < content.indexOf('## [0.1.0]'),
122
+ 'newest release must come first'
123
+ );
124
+ assert.ok(
125
+ content.indexOf(INSERT_MARKER) < content.indexOf('## [0.10.11]'),
126
+ 'the marker must stay above the entries so the next release can find it'
127
+ );
128
+ });
129
+
130
+ it('reports no fragments rather than writing an empty release section', () => {
131
+ const { dir } = fixture({ changelog: HEADER });
132
+ assert.equal(collectFragments(dir), '');
133
+ });
134
+
135
+ it('creates CHANGELOG.md when it does not exist yet', () => {
136
+ const { dir } = fixture({
137
+ fragments: { '1.added.md': '### Added\n\n- First\n' },
138
+ });
139
+
140
+ updateChangelog(dir, '0.1.0', collectFragments(dir));
141
+
142
+ const content = readFileSync(join(dir, 'CHANGELOG.md'), 'utf-8');
143
+ assert.ok(content.includes(INSERT_MARKER));
144
+ assert.ok(content.includes('## [0.1.0]'));
145
+ assert.ok(content.includes('- First'));
146
+ });
147
+ });