browser-commander 0.17.0 → 0.17.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.
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Guard for RC-1: a release job that treats every rejected push the same.
3
+ *
4
+ * Two rejections both print "rejected" and need opposite responses. A lost
5
+ * race is fixed by rebasing; a repository-rule violation can never be, and
6
+ * retrying it burns the retry and blames a race that never happened.
7
+ *
8
+ * The samples below are the literal wordings the two situations produce:
9
+ * `non-fast-forward` from the CI logs in dev/log/issues/85/pulls/86/ci-logs/,
10
+ * `fetch first` from the local reproduction in
11
+ * experiments/ci-repro/repro-release-push-race.sh.
12
+ *
13
+ * Analysis: dev/log/issues/85/pulls/86/analysis/root-causes.md, RC-1
14
+ */
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { describe, it } from 'node:test';
18
+
19
+ import {
20
+ NON_FAST_FORWARD_PATTERNS,
21
+ REPOSITORY_RULE_PATTERNS,
22
+ classifyPushFailure,
23
+ isBlockedByRepositoryRule,
24
+ isNonFastForward,
25
+ } from '../../../../scripts/push-failure-classifier.mjs';
26
+
27
+ /** Rejection text a lost race produces, verbatim. */
28
+ const RACE_SAMPLES = [
29
+ {
30
+ label: 'non-fast-forward (remote commits already fetched)',
31
+ stderr: [
32
+ 'To https://github.com/link-foundation/browser-commander',
33
+ ' ! [rejected] HEAD -> main (non-fast-forward)',
34
+ "error: failed to push some refs to 'https://github.com/link-foundation/browser-commander'",
35
+ 'hint: Updates were rejected because a pushed branch tip is behind its remote',
36
+ ].join('\n'),
37
+ },
38
+ {
39
+ label: 'fetch first (remote commits not yet fetched)',
40
+ stderr: [
41
+ ' ! [rejected] HEAD -> main (fetch first)',
42
+ 'error: failed to push some refs',
43
+ 'hint: Updates were rejected because the remote contains work that you do not',
44
+ 'hint: have locally.',
45
+ ].join('\n'),
46
+ },
47
+ ];
48
+
49
+ /** Rejection text a ruleset or protected branch produces, verbatim. */
50
+ const RULE_SAMPLES = [
51
+ {
52
+ label: 'GH013 repository ruleset',
53
+ stderr: [
54
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.',
55
+ 'remote: - Changes must be made through a pull request.',
56
+ ' ! [remote rejected] main -> main (push declined due to repository rule violations)',
57
+ ].join('\n'),
58
+ },
59
+ {
60
+ label: 'GH006 legacy protected branch',
61
+ stderr: [
62
+ 'remote: error: GH006: Protected branch update failed for refs/heads/main.',
63
+ ' ! [remote rejected] main -> main (protected branch hook declined)',
64
+ ].join('\n'),
65
+ },
66
+ ];
67
+
68
+ describe('push failure classifier', () => {
69
+ it('reads a lost race as non-fast-forward', () => {
70
+ for (const sample of RACE_SAMPLES) {
71
+ assert.equal(
72
+ classifyPushFailure(sample),
73
+ 'non-fast-forward',
74
+ `${sample.label} must be retried by rebasing`
75
+ );
76
+ assert.equal(isNonFastForward(sample), true, sample.label);
77
+ assert.equal(isBlockedByRepositoryRule(sample), false, sample.label);
78
+ }
79
+ });
80
+
81
+ it('never reads a rule violation as a lost race', () => {
82
+ // This is the whole point of the module. A ruleset rejection contains the
83
+ // word "rejected" too, so a naive substring check retries a push that can
84
+ // never succeed.
85
+ for (const sample of RULE_SAMPLES) {
86
+ assert.equal(
87
+ classifyPushFailure(sample),
88
+ 'repository-rule',
89
+ `${sample.label} must not be retried`
90
+ );
91
+ assert.equal(isNonFastForward(sample), false, sample.label);
92
+ assert.equal(isBlockedByRepositoryRule(sample), true, sample.label);
93
+ }
94
+ });
95
+
96
+ it('leaves an unrelated failure unclassified so it is not retried', () => {
97
+ const authFailure = {
98
+ stderr:
99
+ 'fatal: could not read Username for https://github.com: No such device or address',
100
+ };
101
+ assert.equal(classifyPushFailure(authFailure), 'unknown');
102
+ assert.equal(isNonFastForward(authFailure), false);
103
+ assert.equal(isBlockedByRepositoryRule(authFailure), false);
104
+ });
105
+
106
+ it('ignores the generic wrapper message command-stream attaches', () => {
107
+ // command-stream rejects with "Command failed with exit code 1"; the
108
+ // classification has to rest on what git wrote, not on the wrapper.
109
+ const wrapped = {
110
+ message: 'Command failed with exit code 1',
111
+ stdout: '',
112
+ stderr: 'Everything up-to-date',
113
+ };
114
+ assert.equal(classifyPushFailure(wrapped), 'unknown');
115
+ });
116
+
117
+ it('reads a bare string as well as a command result', () => {
118
+ assert.equal(
119
+ classifyPushFailure(' ! [rejected] main -> main (non-fast-forward)'),
120
+ 'non-fast-forward'
121
+ );
122
+ assert.equal(
123
+ classifyPushFailure('remote: error: GH013:'),
124
+ 'repository-rule'
125
+ );
126
+ });
127
+
128
+ it('survives a failure with nothing to read', () => {
129
+ for (const empty of [null, undefined, {}, '']) {
130
+ assert.equal(classifyPushFailure(empty), 'unknown');
131
+ }
132
+ });
133
+
134
+ it('matches case-insensitively, because git and GitHub disagree on case', () => {
135
+ assert.equal(
136
+ classifyPushFailure({
137
+ stderr: 'REMOTE: ERROR: GH013: REPOSITORY RULE VIOLATIONS',
138
+ }),
139
+ 'repository-rule'
140
+ );
141
+ assert.equal(
142
+ classifyPushFailure({
143
+ stderr: '! [REJECTED] main -> main (NON-FAST-FORWARD)',
144
+ }),
145
+ 'non-fast-forward'
146
+ );
147
+ });
148
+
149
+ it('keeps the pattern lists frozen so a caller cannot widen them', () => {
150
+ assert.throws(() => REPOSITORY_RULE_PATTERNS.push('anything'));
151
+ assert.throws(() => NON_FAST_FORWARD_PATTERNS.push('anything'));
152
+ });
153
+ });
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Guard for RC-1 and RC-3.
3
+ *
4
+ * RC-1: the release jobs are serialised by a concurrency group but each one
5
+ * checks out `github.sha`, so every writer after the first holds a tree one
6
+ * commit behind main and its push is rejected. Nothing rebased and retried.
7
+ *
8
+ * RC-3: a retry rebases, which rewrites the release commit. A tag created
9
+ * before the push would keep pointing at the pre-rebase commit and be
10
+ * reachable from no branch -- reproduced as case 2 of
11
+ * experiments/ci-repro/repro-release-push-race.sh. So the release scripts must
12
+ * tag only after the push has succeeded.
13
+ *
14
+ * Analysis: dev/log/issues/85/pulls/86/analysis/root-causes.md
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
+ import {
24
+ DEFAULT_ATTEMPTS,
25
+ pushWithRebaseRetry,
26
+ resolveCurrentBranch,
27
+ } from '../../../../scripts/push-with-rebase-retry.mjs';
28
+
29
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../..');
30
+
31
+ /** A rejection shaped like the one `command-stream` produces for git. */
32
+ function rejection(stderr) {
33
+ return Object.assign(new Error('Command failed with exit code 1'), {
34
+ code: 1,
35
+ stdout: '',
36
+ stderr,
37
+ });
38
+ }
39
+
40
+ const RACE = ' ! [rejected] HEAD -> main (non-fast-forward)\n';
41
+ const RULE = [
42
+ 'remote: error: GH013: Repository rule violations found for refs/heads/main.',
43
+ 'remote: - Changes must be made through a pull request.',
44
+ ].join('\n');
45
+
46
+ /**
47
+ * Build a fake git that fails the first `failures` pushes with `stderr`, then
48
+ * succeeds, recording every argv it was handed.
49
+ */
50
+ function fakeGit({ failures = 0, stderr = RACE, stdout = '' } = {}) {
51
+ const calls = [];
52
+ let pushes = 0;
53
+ const git = async (args) => {
54
+ calls.push(args.join(' '));
55
+ if (args[0] === 'push') {
56
+ pushes++;
57
+ if (pushes <= failures) {
58
+ throw rejection(stderr);
59
+ }
60
+ }
61
+ return { stdout, stderr: '', code: 0 };
62
+ };
63
+ git.calls = calls;
64
+ return git;
65
+ }
66
+
67
+ /** Collect log lines instead of printing them. */
68
+ function fakeLogger() {
69
+ const lines = [];
70
+ return { lines, log: (line) => lines.push(line) };
71
+ }
72
+
73
+ const silent = () => {};
74
+
75
+ describe('pushWithRebaseRetry', () => {
76
+ it('pushes once when nothing else has written to the branch', async () => {
77
+ const git = fakeGit();
78
+ const result = await pushWithRebaseRetry({
79
+ git,
80
+ branch: 'main',
81
+ logger: fakeLogger(),
82
+ debugFn: silent,
83
+ });
84
+
85
+ assert.deepEqual(result, { pushed: true, via: 'direct', attempts: 1 });
86
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
87
+ });
88
+
89
+ it('rebases and retries after losing the race, as in run 33998729944', async () => {
90
+ const git = fakeGit({ failures: 1 });
91
+ const logger = fakeLogger();
92
+
93
+ const result = await pushWithRebaseRetry({
94
+ git,
95
+ branch: 'main',
96
+ logger,
97
+ debugFn: silent,
98
+ });
99
+
100
+ assert.deepEqual(result, { pushed: true, via: 'rebase', attempts: 2 });
101
+ assert.deepEqual(git.calls, [
102
+ 'push origin HEAD:main',
103
+ 'pull --rebase origin main',
104
+ 'push origin HEAD:main',
105
+ ]);
106
+ assert.ok(
107
+ logger.lines.some((line) => line.startsWith('::warning::')),
108
+ 'a recovered race must still be visible in the log'
109
+ );
110
+ });
111
+
112
+ it('does not retry a repository-rule rejection', async () => {
113
+ // Rebasing cannot satisfy a ruleset. Retrying it doubles the time the job
114
+ // takes to die and leaves a log blaming a race that never happened.
115
+ const git = fakeGit({ failures: DEFAULT_ATTEMPTS, stderr: RULE });
116
+ const logger = fakeLogger();
117
+
118
+ await assert.rejects(
119
+ pushWithRebaseRetry({ git, branch: 'main', logger, debugFn: silent })
120
+ );
121
+
122
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
123
+ assert.ok(
124
+ logger.lines.some(
125
+ (line) => line.startsWith('::error::') && line.includes('GH006/GH013')
126
+ ),
127
+ 'the log must name the real cause'
128
+ );
129
+ });
130
+
131
+ it('does not retry a failure a rebase cannot fix', async () => {
132
+ const git = fakeGit({
133
+ failures: DEFAULT_ATTEMPTS,
134
+ stderr: 'fatal: could not read Username for https://github.com',
135
+ });
136
+
137
+ await assert.rejects(
138
+ pushWithRebaseRetry({
139
+ git,
140
+ branch: 'main',
141
+ logger: fakeLogger(),
142
+ debugFn: silent,
143
+ })
144
+ );
145
+ assert.deepEqual(git.calls, ['push origin HEAD:main']);
146
+ });
147
+
148
+ it('gives up after the configured number of attempts', async () => {
149
+ const git = fakeGit({ failures: 99 });
150
+
151
+ await assert.rejects(
152
+ pushWithRebaseRetry({
153
+ git,
154
+ branch: 'main',
155
+ attempts: 3,
156
+ logger: fakeLogger(),
157
+ debugFn: silent,
158
+ })
159
+ );
160
+
161
+ const pushes = git.calls.filter((call) => call.startsWith('push'));
162
+ assert.equal(pushes.length, 3, 'exactly `attempts` pushes, no more');
163
+ });
164
+
165
+ it('pushes the branch it is told to, not always main', async () => {
166
+ const git = fakeGit();
167
+ await pushWithRebaseRetry({
168
+ git,
169
+ remote: 'upstream',
170
+ branch: 'release',
171
+ logger: fakeLogger(),
172
+ debugFn: silent,
173
+ });
174
+ assert.deepEqual(git.calls, ['push upstream HEAD:release']);
175
+ });
176
+
177
+ it('stays silent on the debug channel unless tracing is switched on', async () => {
178
+ // The verbose mode has to default to off: a release log is read by people
179
+ // looking for the one line that matters.
180
+ const traced = [];
181
+ const git = fakeGit({ failures: 1 });
182
+ await pushWithRebaseRetry({
183
+ git,
184
+ branch: 'main',
185
+ logger: fakeLogger(),
186
+ debugFn: (...parts) => traced.push(parts.join(' ')),
187
+ });
188
+ assert.ok(traced.length > 0, 'the helper must offer tracing to inject');
189
+
190
+ const quiet = [];
191
+ const { debugWith } = await import('../../../../scripts/debug-print.mjs');
192
+ await pushWithRebaseRetry({
193
+ git: fakeGit(),
194
+ branch: 'main',
195
+ logger: fakeLogger(),
196
+ debugFn: (...parts) =>
197
+ debugWith({ env: {}, log: (l) => quiet.push(l) }, ...parts),
198
+ });
199
+ assert.deepEqual(quiet, [], 'debug output must be off by default');
200
+ });
201
+ });
202
+
203
+ describe('resolveCurrentBranch', () => {
204
+ it('reads the branch instead of assuming main', async () => {
205
+ // The Rust release used a bare `git push`, which follows the current
206
+ // branch's upstream; a hard-coded HEAD:main would change behaviour for a
207
+ // workflow_dispatch run started from another branch.
208
+ const git = fakeGit({ stdout: 'release-2.x\n' });
209
+ assert.equal(await resolveCurrentBranch(git), 'release-2.x');
210
+ assert.deepEqual(git.calls, ['rev-parse --abbrev-ref HEAD']);
211
+ });
212
+
213
+ it('falls back to main when git says nothing', async () => {
214
+ assert.equal(
215
+ await resolveCurrentBranch(fakeGit({ stdout: ' \n' })),
216
+ 'main'
217
+ );
218
+ });
219
+
220
+ it('does not push HEAD:HEAD from a detached checkout', async () => {
221
+ // `git rev-parse --abbrev-ref HEAD` prints the literal "HEAD" when
222
+ // detached. Using it as the target branch would aim the push at a remote
223
+ // branch named HEAD instead of the release branch.
224
+ assert.equal(
225
+ await resolveCurrentBranch(fakeGit({ stdout: 'HEAD\n' })),
226
+ 'main'
227
+ );
228
+ assert.equal(
229
+ await resolveCurrentBranch(fakeGit({ stdout: 'HEAD\n' }), 'release'),
230
+ 'release'
231
+ );
232
+ });
233
+ });
234
+
235
+ describe('release scripts tag only after the push (RC-3)', () => {
236
+ const releaseScripts = [
237
+ 'rust/scripts/version-and-commit.mjs',
238
+ 'js/scripts/version-and-commit.mjs',
239
+ ];
240
+
241
+ for (const relativePath of releaseScripts) {
242
+ it(`${relativePath} never leaves an orphaned tag`, () => {
243
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
244
+ const tagIndex = source.indexOf('git tag -a');
245
+ if (tagIndex === -1) {
246
+ return; // This script does not tag; nothing to order.
247
+ }
248
+ const pushIndex = source.indexOf('pushWithRebaseRetry({');
249
+ assert.notEqual(
250
+ pushIndex,
251
+ -1,
252
+ `${relativePath} must push through pushWithRebaseRetry`
253
+ );
254
+ assert.ok(
255
+ pushIndex < tagIndex,
256
+ `${relativePath} creates a tag before pushing; a rebase retry would ` +
257
+ 'rewrite the commit and orphan that tag'
258
+ );
259
+ });
260
+ }
261
+
262
+ it('no release script pushes with the blanket --tags', () => {
263
+ // `git push --tags` also pushes every unrelated local tag the runner
264
+ // happens to have fetched.
265
+ for (const relativePath of releaseScripts) {
266
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
267
+ assert.ok(
268
+ !source.includes('git push --tags'),
269
+ `${relativePath} must push its release tag by name`
270
+ );
271
+ }
272
+ });
273
+ });
274
+
275
+ describe('every release push site uses the shared helper', () => {
276
+ // The fix has to be applied everywhere: a single un-migrated push site
277
+ // reproduces the original failure the next time two releases run.
278
+ it('leaves no raw push to a shared branch behind', () => {
279
+ const sites = [
280
+ 'js/scripts/version-and-commit.mjs',
281
+ 'rust/scripts/version-and-commit.mjs',
282
+ ];
283
+ for (const relativePath of sites) {
284
+ const source = readFileSync(join(repoRoot, relativePath), 'utf8');
285
+ for (const raw of [
286
+ 'git push`',
287
+ 'git push origin main`',
288
+ 'git push --tags`',
289
+ 'git push origin HEAD:main`',
290
+ ]) {
291
+ assert.ok(
292
+ !source.includes(raw),
293
+ `${relativePath} still contains a raw \`${raw}\``
294
+ );
295
+ }
296
+ assert.ok(
297
+ source.includes('pushWithRebaseRetry'),
298
+ `${relativePath} must push through the shared helper`
299
+ );
300
+ }
301
+ });
302
+ });
@@ -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
+ });