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
@@ -18,32 +18,82 @@ function withWorkflow(content, callback) {
18
18
  }
19
19
  }
20
20
 
21
+ /**
22
+ * Build a workflow that already satisfies every unrelated policy rule.
23
+ *
24
+ * Each test then varies exactly one thing, so a failure count of N names the N
25
+ * rules under test instead of the scaffolding around them.
26
+ *
27
+ * @param {{on?: string, preamble?: string, job?: string, steps: string}} parts
28
+ * @returns {string}
29
+ */
30
+ function workflow({ on = 'push', preamble = '', job = 'test', steps }) {
31
+ return [
32
+ 'name: Test',
33
+ `on: ${on}`,
34
+ preamble,
35
+ 'env:',
36
+ ' GIT_CONFIG_KEY_0: init.defaultBranch',
37
+ 'jobs:',
38
+ ` ${job}:`,
39
+ ' runs-on: ubuntu-latest',
40
+ ' timeout-minutes: 5',
41
+ ' concurrency:',
42
+ ` group: \${{ github.workflow }}-\${{ github.ref }}-${job}`,
43
+ ' cancel-in-progress: true',
44
+ ' steps:',
45
+ ' - uses: actions/checkout@v6',
46
+ steps,
47
+ // Every workflow has to end in the gate that reads the other jobs'
48
+ // results, so the scaffolding carries one; the gate's own tests are in
49
+ // ci-timeout-budgets.test.js.
50
+ ' pipeline-status:',
51
+ ' runs-on: ubuntu-latest',
52
+ ' timeout-minutes: 5',
53
+ ' concurrency:',
54
+ ' group: ${{ github.workflow }}-${{ github.ref }}-pipeline-status',
55
+ ' cancel-in-progress: true',
56
+ ` needs: [${job}]`,
57
+ ' steps:',
58
+ ' - run: bash scripts/check-pipeline-status.sh',
59
+ '',
60
+ ]
61
+ .filter((section) => section !== '')
62
+ .join('\n');
63
+ }
64
+
65
+ /**
66
+ * Count policy failures without letting the reported ::error:: lines leak into
67
+ * the test output, where they read like real CI annotations.
68
+ *
69
+ * @param {string} content
70
+ * @returns {number}
71
+ */
72
+ function countFailures(content) {
73
+ const originalError = console.error;
74
+ console.error = () => {};
75
+
76
+ try {
77
+ return withWorkflow(content, checkWorkflow);
78
+ } finally {
79
+ console.error = originalError;
80
+ }
81
+ }
82
+
21
83
  describe('CI workflow policy', () => {
22
84
  it('accepts bounded job-scoped checks', () => {
23
- const workflow = `name: Test
24
- on: push
25
- permissions:
26
- contents: read
27
- env:
28
- GIT_CONFIG_KEY_0: init.defaultBranch
29
- jobs:
30
- test:
31
- runs-on: ubuntu-latest
32
- timeout-minutes: 5
33
- concurrency:
34
- group: \${{ github.workflow }}-\${{ github.ref }}-test
35
- cancel-in-progress: true
36
- steps:
37
- - uses: actions/checkout@v6
38
- `;
85
+ const content = workflow({
86
+ preamble: 'permissions:\n contents: read',
87
+ steps: ' - run: npm ci --ignore-scripts',
88
+ });
39
89
 
40
- withWorkflow(workflow, (filePath) => {
90
+ withWorkflow(content, (filePath) => {
41
91
  assert.equal(checkWorkflow(filePath), 0);
42
92
  });
43
93
  });
44
94
 
45
95
  it('rejects workflow-wide cancellation and unbounded jobs', () => {
46
- const workflow = `name: Test
96
+ const content = `name: Test
47
97
  on: push
48
98
  concurrency:
49
99
  group: test
@@ -59,168 +109,257 @@ jobs:
59
109
  with:
60
110
  file: coverage.xml
61
111
  `;
62
- const originalError = console.error;
63
- console.error = () => {};
64
112
 
65
- try {
66
- withWorkflow(workflow, (filePath) => {
67
- assert.equal(checkWorkflow(filePath), 7);
68
- });
69
- } finally {
70
- console.error = originalError;
71
- }
113
+ // The eighth is the missing pipeline-status gate: with no job reading the
114
+ // other jobs' results, a check killed by timeout-minutes is reported as
115
+ // cancelled and fails nothing.
116
+ assert.equal(countFailures(content), 8);
72
117
  });
73
118
 
74
119
  it('rejects untrusted expressions interpolated into run bodies', () => {
75
- const workflow = `name: Test
76
- on:
77
- workflow_dispatch:
78
- inputs:
79
- bump_type:
80
- type: choice
81
- options:
82
- - patch
83
- description:
84
- type: string
85
- env:
86
- GIT_CONFIG_KEY_0: init.defaultBranch
87
- jobs:
88
- release:
89
- runs-on: ubuntu-latest
90
- timeout-minutes: 5
91
- concurrency:
92
- group: \${{ github.workflow }}-\${{ github.ref }}-release
93
- cancel-in-progress: true
94
- steps:
95
- - uses: actions/checkout@v6
96
- - run: echo "\${{ github.base_ref }}"
97
- - run: bump "\${{ github.event.inputs.bump_type }}" "\${{ github.event.inputs.description }}"
98
- - uses: peter-evans/create-pull-request@v7
99
- with:
100
- title: 'release \${{ github.event.inputs.description }}'
101
- `;
102
- const originalError = console.error;
103
- console.error = () => {};
120
+ const content = workflow({
121
+ on: '\n workflow_dispatch:\n inputs:\n bump_type:\n type: choice\n options:\n - patch\n description:\n type: string',
122
+ job: 'release',
123
+ steps: [
124
+ ' - run: echo "${{ github.base_ref }}"',
125
+ ' - run: bump "${{ github.event.inputs.bump_type }}" "${{ github.event.inputs.description }}"',
126
+ ' - uses: peter-evans/create-pull-request@v7',
127
+ ' with:',
128
+ " title: 'release ${{ github.event.inputs.description }}'",
129
+ ].join('\n'),
130
+ });
104
131
 
105
- try {
106
- withWorkflow(workflow, (filePath) => {
107
- // One github.base_ref interpolation, one free-form input inside a run
108
- // body (bump_type is a choice input, and the create-pull-request title
109
- // is an action input rather than a shell body), one outdated action.
110
- assert.equal(checkWorkflow(filePath), 3);
111
- });
112
- } finally {
113
- console.error = originalError;
114
- }
132
+ // One github.base_ref interpolation, one free-form input inside a run body
133
+ // (bump_type is a choice input, and the create-pull-request title is an
134
+ // action input rather than a shell body), one outdated action.
135
+ assert.equal(countFailures(content), 3);
115
136
  });
116
137
 
117
138
  it('rejects a condition that starts with the YAML tag indicator', () => {
118
- const workflow = `name: Test
119
- on: push
120
- env:
121
- GIT_CONFIG_KEY_0: init.defaultBranch
122
- jobs:
123
- test:
124
- runs-on: ubuntu-latest
125
- timeout-minutes: 5
126
- concurrency:
127
- group: \${{ github.workflow }}-\${{ github.ref }}-test
128
- cancel-in-progress: true
129
- if: !cancelled() && github.event_name == 'push'
130
- steps:
131
- - uses: actions/checkout@v6
132
- `;
133
- const originalError = console.error;
134
- console.error = () => {};
139
+ const content = workflow({
140
+ steps:
141
+ " - run: echo ok\n if: !cancelled() && github.event_name == 'push'",
142
+ });
135
143
 
136
- try {
137
- withWorkflow(workflow, (filePath) => {
138
- assert.equal(checkWorkflow(filePath), 1);
139
- });
140
- } finally {
141
- console.error = originalError;
142
- }
144
+ assert.equal(countFailures(content), 1);
143
145
  });
144
146
 
145
147
  it('rejects outdated setup-python and codecov versions', () => {
146
- const workflow = `name: Test
147
- on: push
148
- env:
149
- GIT_CONFIG_KEY_0: init.defaultBranch
150
- jobs:
151
- test:
152
- runs-on: ubuntu-latest
153
- timeout-minutes: 5
154
- concurrency:
155
- group: \${{ github.workflow }}-\${{ github.ref }}-test
156
- cancel-in-progress: true
157
- steps:
158
- - uses: actions/checkout@v6
159
- - uses: actions/setup-python@v5
160
- - uses: codecov/codecov-action@v6
161
- `;
162
- const originalError = console.error;
163
- console.error = () => {};
148
+ const content = workflow({
149
+ steps: [
150
+ ' - uses: actions/setup-python@v5',
151
+ ' - uses: codecov/codecov-action@v6',
152
+ ].join('\n'),
153
+ });
164
154
 
165
- try {
166
- withWorkflow(workflow, (filePath) => {
167
- assert.equal(checkWorkflow(filePath), 2);
168
- });
169
- } finally {
170
- console.error = originalError;
171
- }
155
+ assert.equal(countFailures(content), 2);
172
156
  });
173
157
 
174
158
  it('accepts a base_ref bound to any environment variable name', () => {
175
- const workflow = `name: Test
176
- on: pull_request
177
- env:
178
- GIT_CONFIG_KEY_0: init.defaultBranch
179
- jobs:
180
- test:
181
- runs-on: ubuntu-latest
182
- timeout-minutes: 5
183
- concurrency:
184
- group: \${{ github.workflow }}-\${{ github.ref }}-test
185
- cancel-in-progress: true
186
- steps:
187
- - uses: actions/checkout@v6
188
- - name: Simulate fresh merge
189
- env:
190
- BASE_REF: \${{ github.base_ref }}
191
- run: bash scripts/simulate-fresh-merge.sh
192
- `;
159
+ const content = workflow({
160
+ on: 'pull_request',
161
+ steps: [
162
+ ' - name: Simulate fresh merge',
163
+ ' env:',
164
+ ' BASE_REF: ${{ github.base_ref }}',
165
+ ' run: bash scripts/simulate-fresh-merge.sh',
166
+ ].join('\n'),
167
+ });
193
168
 
194
- withWorkflow(workflow, (filePath) => {
169
+ withWorkflow(content, (filePath) => {
195
170
  assert.equal(checkWorkflow(filePath), 0);
196
171
  });
197
172
  });
198
173
 
199
174
  it('still rejects a base_ref spliced into a run body', () => {
200
- const workflow = `name: Test
201
- on: pull_request
202
- env:
203
- GIT_CONFIG_KEY_0: init.defaultBranch
204
- jobs:
205
- test:
175
+ const content = workflow({
176
+ on: 'pull_request',
177
+ steps: ' - run: git fetch origin ${{ github.base_ref }}',
178
+ });
179
+
180
+ assert.ok(countFailures(content) > 0);
181
+ });
182
+
183
+ it('rejects a version scraped out of pyproject.toml with grep', () => {
184
+ // The exact command that shipped in python.yml until this pull request.
185
+ // pyproject.toml carries `version` under [project] and under [tool.scriv],
186
+ // so this grep wrote two lines into $GITHUB_OUTPUT and failed the step.
187
+ const content = workflow({
188
+ steps: [
189
+ ' - run: |',
190
+ ` CURRENT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' pyproject.toml)`,
191
+ ' echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT',
192
+ ].join('\n'),
193
+ });
194
+
195
+ assert.equal(countFailures(content), 1);
196
+ });
197
+
198
+ it('rejects a crate name scraped out of Cargo.toml with head -1', () => {
199
+ // `head -1` is ordering-dependent, not table-aware: Cargo.toml repeats
200
+ // `name` under [[bin]] and [lib].
201
+ const content = workflow({
202
+ steps: ` - run: grep '^name' Cargo.toml | head -1 | cut -d'"' -f2`,
203
+ });
204
+
205
+ assert.equal(countFailures(content), 1);
206
+ });
207
+
208
+ it('rejects a workflow with no pipeline-status gate', () => {
209
+ // A job killed by timeout-minutes is reported as cancelled, and a run whose
210
+ // only casualty is a cancelled job is filed under cancelled too, so without
211
+ // this gate the overrun fails nothing.
212
+ const content = workflow({
213
+ steps: ' - run: npm ci --ignore-scripts',
214
+ }).replace(/ {2}pipeline-status:[\s\S]*$/, '');
215
+
216
+ assert.equal(countFailures(content), 1);
217
+ });
218
+
219
+ it('rejects a gate that has stopped watching a job', () => {
220
+ const content = `${workflow({
221
+ steps: ' - run: npm ci --ignore-scripts',
222
+ })}
223
+ drifted:
206
224
  runs-on: ubuntu-latest
207
225
  timeout-minutes: 5
208
226
  concurrency:
209
- group: \${{ github.workflow }}-\${{ github.ref }}-test
227
+ group: \${{ github.workflow }}-\${{ github.ref }}-drifted
210
228
  cancel-in-progress: true
211
229
  steps:
212
- - uses: actions/checkout@v6
213
- - run: git fetch origin \${{ github.base_ref }}
230
+ - run: echo drifted
214
231
  `;
215
- const originalError = console.error;
216
- console.error = () => {};
217
-
218
- try {
219
- withWorkflow(workflow, (filePath) => {
220
- assert.ok(checkWorkflow(filePath) > 0);
221
- });
222
- } finally {
223
- console.error = originalError;
224
- }
232
+
233
+ assert.equal(countFailures(content), 1);
234
+ });
235
+
236
+ it('accepts the table-aware manifest readers', () => {
237
+ const content = workflow({
238
+ steps: [
239
+ ' - run: python scripts/read_manifest.py pyproject.toml --output current_version',
240
+ ' - run: node ../scripts/read-manifest.mjs Cargo.toml --field name',
241
+ ].join('\n'),
242
+ });
243
+
244
+ withWorkflow(content, (filePath) => {
245
+ assert.equal(checkWorkflow(filePath), 0);
246
+ });
247
+ });
248
+
249
+ it('leaves a commented-out manifest grep alone', () => {
250
+ const content = workflow({
251
+ steps: [
252
+ ' - run: |',
253
+ ` # Replaced grep -Po '(?<=^version = ")[^"]*' pyproject.toml`,
254
+ ' python scripts/read_manifest.py pyproject.toml --output version',
255
+ ].join('\n'),
256
+ });
257
+
258
+ withWorkflow(content, (filePath) => {
259
+ assert.equal(checkWorkflow(filePath), 0);
260
+ });
261
+ });
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);
225
364
  });
226
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
+ });