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,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,134 @@
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
+ // The raw `git push origin main` this used to look for is now
124
+ // pushWithRebaseRetry, which rebases when another release wrote to main
125
+ // first. See dev/log/issues/85/pulls/86/analysis/root-causes.md (RC-1).
126
+ const push = script.indexOf('pushWithRebaseRetry({');
127
+
128
+ assert.ok(check > 0 && commit > 0 && push > 0);
129
+ assert.ok(
130
+ check < commit && check < push,
131
+ 'a check that runs after the push cannot stop a bad release commit'
132
+ );
133
+ });
134
+ });
@@ -260,3 +260,106 @@ jobs:
260
260
  });
261
261
  });
262
262
  });
263
+
264
+ /**
265
+ * Build a two-job workflow whose `test` job varies only in how it relates to
266
+ * `lint`, so the fast-fail rule is the only thing a failure count can mean.
267
+ *
268
+ * @param {{needs: string, condition?: string}} parts
269
+ * @returns {string}
270
+ */
271
+ function lintAndTest({ needs, condition = '' }) {
272
+ return [
273
+ 'name: Test',
274
+ 'on: push',
275
+ 'permissions:',
276
+ ' contents: read',
277
+ 'env:',
278
+ ' GIT_CONFIG_KEY_0: init.defaultBranch',
279
+ 'jobs:',
280
+ ' lint:',
281
+ ' runs-on: ubuntu-latest',
282
+ ' timeout-minutes: 5',
283
+ ' concurrency:',
284
+ ' group: ${{ github.workflow }}-${{ github.ref }}-lint',
285
+ ' cancel-in-progress: true',
286
+ ' steps:',
287
+ ' - uses: actions/checkout@v6',
288
+ ' test:',
289
+ ' runs-on: ubuntu-latest',
290
+ ' timeout-minutes: 5',
291
+ ' concurrency:',
292
+ ' group: ${{ github.workflow }}-${{ github.ref }}-test',
293
+ ' cancel-in-progress: true',
294
+ ` needs: [${needs}]`,
295
+ condition,
296
+ ' steps:',
297
+ ' - uses: actions/checkout@v6',
298
+ ' pipeline-status:',
299
+ ' runs-on: ubuntu-latest',
300
+ ' timeout-minutes: 5',
301
+ ' concurrency:',
302
+ ' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
303
+ ' cancel-in-progress: true',
304
+ ' needs: [lint, test]',
305
+ ' steps:',
306
+ ' - run: bash scripts/check-pipeline-status.sh',
307
+ '',
308
+ ]
309
+ .filter((section) => section !== '')
310
+ .join('\n');
311
+ }
312
+
313
+ describe('fast-fail job ordering', () => {
314
+ it('rejects a test matrix that does not wait for lint', () => {
315
+ assert.equal(countFailures(lintAndTest({ needs: 'detect-changes' })), 1);
316
+ });
317
+
318
+ it('rejects a lint gate neutralised by !cancelled()', () => {
319
+ // `!cancelled()` overrides the implicit "all needs succeeded" rule, so
320
+ // `needs: [lint]` alone lets the matrix run after lint has already failed.
321
+ // The dependency is then decorative, which is worse than not having it:
322
+ // the graph claims a gate the run does not have.
323
+ assert.equal(
324
+ countFailures(
325
+ lintAndTest({
326
+ needs: 'lint',
327
+ condition: " if: >-\n !cancelled() && github.ref != 'x'",
328
+ })
329
+ ),
330
+ 1
331
+ );
332
+ });
333
+
334
+ it('accepts a lint gate that restates the failure guard', () => {
335
+ assert.equal(
336
+ countFailures(
337
+ lintAndTest({
338
+ needs: 'lint',
339
+ condition:
340
+ " if: >-\n !cancelled() && !contains(needs.*.result, 'failure')",
341
+ })
342
+ ),
343
+ 0
344
+ );
345
+ });
346
+
347
+ it('accepts a lint gate stated as an explicit result comparison', () => {
348
+ assert.equal(
349
+ countFailures(
350
+ lintAndTest({
351
+ needs: 'lint',
352
+ condition:
353
+ " if: >-\n !cancelled() && needs.lint.result == 'success'",
354
+ })
355
+ ),
356
+ 0
357
+ );
358
+ });
359
+
360
+ it('accepts a plain dependency with no status function at all', () => {
361
+ // Without `always()` or `!cancelled()`, GitHub's own default already
362
+ // refuses to start the job when lint fails; nothing has to be restated.
363
+ assert.equal(countFailures(lintAndTest({ needs: 'lint' })), 0);
364
+ });
365
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Guard for the second half of the errexit change.
3
+ *
4
+ * Turning `errexit` on in loadCommandStream() makes a failed command reject —
5
+ * but `command-stream`'s rejection carries only
6
+ *
7
+ * message : "Command failed with exit code 101"
8
+ * code : 101
9
+ * stdout : ""
10
+ * stderr : "error: crate version 0.10.11 is already uploaded\n"
11
+ *
12
+ * The diagnostic text a caller needs to branch on is in `stderr`, *not* in
13
+ * `message`. Three release scripts were written as
14
+ * `error.message.includes('already exists')`, which silently stops matching the
15
+ * moment the promise actually rejects. Left unfixed, enabling errexit would
16
+ * convert a harmless re-run over an already-published version into a red
17
+ * release job.
18
+ *
19
+ * `commandErrorText()` is the shared accessor those call sites use instead.
20
+ */
21
+
22
+ import assert from 'node:assert/strict';
23
+ import { describe, it } from 'node:test';
24
+
25
+ import { commandErrorText } from '../../../../scripts/use-module.mjs';
26
+
27
+ describe('commandErrorText', () => {
28
+ it('finds text that command-stream puts in stderr rather than message', () => {
29
+ const error = Object.assign(
30
+ new Error('Command failed with exit code 101'),
31
+ {
32
+ code: 101,
33
+ stdout: '',
34
+ stderr: 'error: crate version 0.10.11 is already uploaded\n',
35
+ }
36
+ );
37
+
38
+ assert.equal(
39
+ error.message.includes('already uploaded'),
40
+ false,
41
+ 'precondition: the message alone does not carry the reason'
42
+ );
43
+ assert.ok(commandErrorText(error).includes('already uploaded'));
44
+ });
45
+
46
+ it('includes stdout, which is where gh reports an existing release', () => {
47
+ const error = Object.assign(new Error('Command failed with exit code 1'), {
48
+ code: 1,
49
+ stdout: '{"message":"Reference already exists"}',
50
+ stderr: '',
51
+ });
52
+
53
+ assert.ok(commandErrorText(error).includes('already exists'));
54
+ });
55
+
56
+ it('keeps the message so an unrecognised failure is still describable', () => {
57
+ const error = Object.assign(new Error('Command failed with exit code 7'), {
58
+ code: 7,
59
+ stdout: '',
60
+ stderr: '',
61
+ });
62
+
63
+ assert.match(commandErrorText(error), /exit code 7/);
64
+ });
65
+
66
+ it('tolerates Buffer stdio, which is what capture returns without an encoding', () => {
67
+ const error = Object.assign(new Error('Command failed with exit code 1'), {
68
+ code: 1,
69
+ stdout: Buffer.from('already exists'),
70
+ stderr: Buffer.from(''),
71
+ });
72
+
73
+ assert.ok(commandErrorText(error).includes('already exists'));
74
+ });
75
+
76
+ it('does not throw on a non-command error, or on null', () => {
77
+ assert.equal(commandErrorText(new Error('plain')), 'plain');
78
+ assert.equal(commandErrorText(null), '');
79
+ assert.equal(commandErrorText(undefined), '');
80
+ // A thrown string is not an Error but must still be describable.
81
+ assert.equal(commandErrorText('boom'), 'boom');
82
+ });
83
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Guard for the failure mode that made three release defects invisible.
3
+ *
4
+ * `command-stream` resolves its `$` promise on a non-zero exit code instead of
5
+ * rejecting, so the idiom every release script in this repository is written
6
+ * around —
7
+ *
8
+ * try { await $`some-command`; } catch { process.exit(1); }
9
+ *
10
+ * — is dead code under the library's defaults. On 2026-09-05 that turned a
11
+ * crashed `changeset version` into a green JS release (run 33974450016) and a
12
+ * Rust release that never committed its version bump into a green Rust release
13
+ * (run 33974450069). See dev/log/issues/83/pulls/84/analysis/root-causes.md,
14
+ * RC-B and RC-C.
15
+ *
16
+ * `loadCommandStream()` is the single place every consumer obtains `$` from, so
17
+ * it is the single place the default can be corrected. These tests pin that:
18
+ * a non-zero exit must reject, and the rejection must carry the exit code so a
19
+ * caller can still branch on it.
20
+ *
21
+ * The tests need network access, because use-m is fetched from a CDN. When it
22
+ * is unreachable they skip, matching use-module-integration.test.js.
23
+ */
24
+
25
+ import assert from 'node:assert/strict';
26
+ import { describe, it } from 'node:test';
27
+
28
+ import {
29
+ loadCommandStream,
30
+ USE_M_URL,
31
+ } from '../../../../scripts/use-module.mjs';
32
+
33
+ async function hasNetwork() {
34
+ try {
35
+ return (await fetch(USE_M_URL, { method: 'HEAD' })).ok;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Load command-stream, or a reason to skip.
43
+ * @param {import('node:test').TestContext} t
44
+ * @returns {Promise<Record<string, any> | null>}
45
+ */
46
+ async function load(t) {
47
+ if (process.platform === 'win32') {
48
+ // Same upstream limitation use-module-integration.test.js documents:
49
+ // use-m imports the resolved file by absolute path, which the Windows ESM
50
+ // loader rejects. The Linux and macOS runs cover this.
51
+ t.skip('use-m cannot import absolute paths on Windows');
52
+ return null;
53
+ }
54
+ if (!(await hasNetwork())) {
55
+ t.skip(`${USE_M_URL} is unreachable, so use-m cannot be evaluated`);
56
+ return null;
57
+ }
58
+ try {
59
+ return await loadCommandStream();
60
+ } catch (error) {
61
+ if (
62
+ /fetch|network|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|registry/i.test(
63
+ error.message
64
+ )
65
+ ) {
66
+ t.skip(error.message);
67
+ return null;
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ describe('loadCommandStream makes a failed command fail', () => {
74
+ it('turns errexit on, so the release scripts catch what they try to catch', async (t) => {
75
+ const cs = await load(t);
76
+ if (!cs) {
77
+ return;
78
+ }
79
+
80
+ assert.equal(
81
+ cs.shell.settings().errexit,
82
+ true,
83
+ 'errexit must be enabled by loadCommandStream; with the library default ' +
84
+ 'of false, every try/catch around $ in the release scripts is unreachable'
85
+ );
86
+ });
87
+
88
+ it('rejects on a non-zero exit and reports the exit code', async (t) => {
89
+ const cs = await load(t);
90
+ if (!cs) {
91
+ return;
92
+ }
93
+ const { $ } = cs;
94
+
95
+ await assert.rejects(
96
+ () => $`exit 7`,
97
+ (error) => {
98
+ assert.equal(error.code, 7, 'the exit code must survive on the error');
99
+ return true;
100
+ },
101
+ 'a command exiting 7 must reject, not resolve'
102
+ );
103
+ });
104
+
105
+ it('still resolves with stdout on success', async (t) => {
106
+ const cs = await load(t);
107
+ if (!cs) {
108
+ return;
109
+ }
110
+ const { $ } = cs;
111
+
112
+ // errexit must not turn every command into a throw: the success path is
113
+ // what the release scripts read version numbers and git status out of.
114
+ const result = await $`echo errexit-ok`;
115
+ assert.match(String(result.stdout), /errexit-ok/);
116
+ });
117
+
118
+ it('reports a non-zero exit for the exact command that broke the Rust release', async (t) => {
119
+ const cs = await load(t);
120
+ if (!cs) {
121
+ return;
122
+ }
123
+ const { $ } = cs;
124
+
125
+ // `git diff --cached --quiet` exits 1 when there ARE staged changes.
126
+ // rust/scripts/version-and-commit.mjs relied on that becoming a rejection
127
+ // and, because it did not, reported "No changes to commit" after staging
128
+ // two modified files. `false` stands in for it here so the test needs no
129
+ // git fixture; the contract under test is identical.
130
+ await assert.rejects(() => $`false`, 'a command exiting 1 must reject');
131
+ });
132
+ });
@@ -70,6 +70,11 @@ const MIRRORED_COMMANDS = [
70
70
  workflow: 'quality.yml',
71
71
  command: 'bash scripts/check-shared-fingerprint-assets.sh',
72
72
  },
73
+ {
74
+ id: 'required-docs',
75
+ workflow: 'quality.yml',
76
+ command: 'bash scripts/check-required-docs.sh',
77
+ },
73
78
  {
74
79
  id: 'repo-scripts-lint',
75
80
  workflow: 'quality.yml',