browser-commander 0.16.0 → 0.17.0
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.
- package/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +22 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-version-modification.test.js +70 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +217 -28
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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
|
|
96
|
+
const content = `name: Test
|
|
47
97
|
on: push
|
|
48
98
|
concurrency:
|
|
49
99
|
group: test
|
|
@@ -59,15 +109,154 @@ jobs:
|
|
|
59
109
|
with:
|
|
60
110
|
file: coverage.xml
|
|
61
111
|
`;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
112
|
+
|
|
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);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects untrusted expressions interpolated into run bodies', () => {
|
|
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
|
+
});
|
|
131
|
+
|
|
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);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('rejects a condition that starts with the YAML tag indicator', () => {
|
|
139
|
+
const content = workflow({
|
|
140
|
+
steps:
|
|
141
|
+
" - run: echo ok\n if: !cancelled() && github.event_name == 'push'",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.equal(countFailures(content), 1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects outdated setup-python and codecov versions', () => {
|
|
148
|
+
const content = workflow({
|
|
149
|
+
steps: [
|
|
150
|
+
' - uses: actions/setup-python@v5',
|
|
151
|
+
' - uses: codecov/codecov-action@v6',
|
|
152
|
+
].join('\n'),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
assert.equal(countFailures(content), 2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('accepts a base_ref bound to any environment variable name', () => {
|
|
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
|
+
});
|
|
168
|
+
|
|
169
|
+
withWorkflow(content, (filePath) => {
|
|
170
|
+
assert.equal(checkWorkflow(filePath), 0);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('still rejects a base_ref spliced into a run body', () => {
|
|
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:
|
|
224
|
+
runs-on: ubuntu-latest
|
|
225
|
+
timeout-minutes: 5
|
|
226
|
+
concurrency:
|
|
227
|
+
group: \${{ github.workflow }}-\${{ github.ref }}-drifted
|
|
228
|
+
cancel-in-progress: true
|
|
229
|
+
steps:
|
|
230
|
+
- run: echo drifted
|
|
231
|
+
`;
|
|
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
|
+
});
|
|
72
261
|
});
|
|
73
262
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { describe, it } from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
debugWith,
|
|
6
|
+
formatDebugLines,
|
|
7
|
+
isDebugEnabled,
|
|
8
|
+
readEnvVar,
|
|
9
|
+
} from '../../../../scripts/debug-print.mjs';
|
|
10
|
+
|
|
11
|
+
describe('debug-print', () => {
|
|
12
|
+
it('stays off by default so release logs are not noisy', () => {
|
|
13
|
+
assert.equal(isDebugEnabled({}), false);
|
|
14
|
+
assert.equal(isDebugEnabled({ CI_SCRIPTS_DEBUG: '0' }), false);
|
|
15
|
+
assert.equal(isDebugEnabled({ CI_SCRIPTS_DEBUG: '' }), false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('turns on through the local toggle or either Actions debug switch', () => {
|
|
19
|
+
assert.equal(isDebugEnabled({ CI_SCRIPTS_DEBUG: '1' }), true);
|
|
20
|
+
assert.equal(isDebugEnabled({ CI_SCRIPTS_DEBUG: 'true' }), true);
|
|
21
|
+
assert.equal(isDebugEnabled({ RUNNER_DEBUG: '1' }), true);
|
|
22
|
+
assert.equal(isDebugEnabled({ ACTIONS_STEP_DEBUG: 'true' }), true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('prefixes every line so Actions renders it in the debug stream', () => {
|
|
26
|
+
assert.deepEqual(formatDebugLines(['a\nb']), ['::debug::a', '::debug::b']);
|
|
27
|
+
assert.deepEqual(formatDebugLines(['keys', { a: 1 }]), [
|
|
28
|
+
'::debug::keys {',
|
|
29
|
+
'::debug:: "a": 1',
|
|
30
|
+
'::debug::}',
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('prints nothing when debug output is off', () => {
|
|
35
|
+
const printed = [];
|
|
36
|
+
assert.deepEqual(
|
|
37
|
+
debugWith({ env: {}, log: (l) => printed.push(l) }, 'x'),
|
|
38
|
+
[]
|
|
39
|
+
);
|
|
40
|
+
assert.deepEqual(printed, []);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('prints through the injected logger when debug output is on', () => {
|
|
44
|
+
const printed = [];
|
|
45
|
+
debugWith(
|
|
46
|
+
{ env: { CI_SCRIPTS_DEBUG: '1' }, log: (line) => printed.push(line) },
|
|
47
|
+
'loaded',
|
|
48
|
+
{ keys: ['default'] }
|
|
49
|
+
);
|
|
50
|
+
assert.equal(printed[0], '::debug::loaded {');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('treats an unreadable environment as unset instead of throwing', () => {
|
|
54
|
+
// Deno raises NotCapable on the property read when --allow-env is absent;
|
|
55
|
+
// tracing must never be the reason a script fails.
|
|
56
|
+
const hostile = new Proxy(
|
|
57
|
+
{},
|
|
58
|
+
{
|
|
59
|
+
get() {
|
|
60
|
+
throw new Error('NotCapable');
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
assert.equal(readEnvVar('CI_SCRIPTS_DEBUG', hostile), undefined);
|
|
65
|
+
assert.equal(isDebugEnabled(hostile), false);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guard against a duplication gate that reports success without reading code.
|
|
3
|
+
*
|
|
4
|
+
* `.jscpd.json` used to set `"format": "console"`, but jscpd's `format` selects
|
|
5
|
+
* the *file formats* to scan, not the reporter. No file has the extension
|
|
6
|
+
* `console`, so `npm run check:duplication` analysed 0 files, found 0 clones and
|
|
7
|
+
* exited 0 for every commit - a green check that proved nothing. The static
|
|
8
|
+
* assertions below pin the keys that caused it; the end-to-end run proves the
|
|
9
|
+
* shipped config still reaches a real file.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import {
|
|
15
|
+
existsSync,
|
|
16
|
+
mkdtempSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { describe, it } from 'node:test';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
|
|
26
|
+
const JS_ROOT = path.resolve(
|
|
27
|
+
fileURLToPath(new URL('.', import.meta.url)),
|
|
28
|
+
'../../..'
|
|
29
|
+
);
|
|
30
|
+
const CONFIG_PATH = path.join(JS_ROOT, '.jscpd.json');
|
|
31
|
+
const JSCPD_PACKAGE = path.join(JS_ROOT, 'node_modules', 'jscpd');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve jscpd's CLI entry point, not its `node_modules/.bin` shim.
|
|
35
|
+
*
|
|
36
|
+
* On Windows npm writes three shims - `jscpd`, `jscpd.cmd` and `jscpd.ps1` -
|
|
37
|
+
* and the extensionless one is a Bourne script that `CreateProcess` cannot
|
|
38
|
+
* execute, so `execFileSync(.bin/jscpd)` fails with ENOENT there while working
|
|
39
|
+
* on Linux and macOS. Reading `bin.jscpd` out of the package manifest gives the
|
|
40
|
+
* same file npm links to, on every platform, and survives an upstream rename.
|
|
41
|
+
*/
|
|
42
|
+
function resolveJscpdCli() {
|
|
43
|
+
const manifest = path.join(JSCPD_PACKAGE, 'package.json');
|
|
44
|
+
if (!existsSync(manifest)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const { bin } = JSON.parse(readFileSync(manifest, 'utf-8'));
|
|
48
|
+
const entry = typeof bin === 'string' ? bin : bin?.jscpd;
|
|
49
|
+
if (!entry) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const cli = path.join(JSCPD_PACKAGE, entry);
|
|
53
|
+
return existsSync(cli) ? cli : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
57
|
+
|
|
58
|
+
describe('.jscpd.json', () => {
|
|
59
|
+
it('does not restrict the scan to a format named after a reporter', () => {
|
|
60
|
+
// `format` is a comma-separated list of jscpd language ids ("javascript",
|
|
61
|
+
// "markup", ...). Naming a reporter there silently matches nothing.
|
|
62
|
+
assert.equal(
|
|
63
|
+
config.format,
|
|
64
|
+
undefined,
|
|
65
|
+
'`format` filters which file types are scanned; use `reporters` instead'
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('selects reporters through the `reporters` array', () => {
|
|
70
|
+
assert.ok(Array.isArray(config.reporters), '`reporters` must be an array');
|
|
71
|
+
assert.ok(
|
|
72
|
+
config.reporters.includes('console'),
|
|
73
|
+
'console output is what makes a failing run readable in CI logs'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('asks to ignore comments through `mode`, not an unknown key', () => {
|
|
78
|
+
// jscpd warns "unknown field 'skipComments'" and carries on; `mode: weak`
|
|
79
|
+
// is the supported spelling of the same intent.
|
|
80
|
+
assert.equal(config.skipComments, undefined);
|
|
81
|
+
assert.equal(config.mode, 'weak');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('the shipped jscpd config scans real files', () => {
|
|
86
|
+
it('reports at least one analysed source for a one-file project', (t) => {
|
|
87
|
+
const jscpdCli = resolveJscpdCli();
|
|
88
|
+
if (!jscpdCli) {
|
|
89
|
+
return t.skip(
|
|
90
|
+
`jscpd is not installed under ${JSCPD_PACKAGE}; run npm ci in js/`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const fixture = mkdtempSync(path.join(tmpdir(), 'jscpd-config-'));
|
|
95
|
+
try {
|
|
96
|
+
// jscpd reads `.jscpd.json` from the directory it is invoked in, so the
|
|
97
|
+
// fixture gets a copy of the real config rather than a --config path.
|
|
98
|
+
writeFileSync(
|
|
99
|
+
path.join(fixture, '.jscpd.json'),
|
|
100
|
+
readFileSync(CONFIG_PATH)
|
|
101
|
+
);
|
|
102
|
+
writeFileSync(
|
|
103
|
+
path.join(fixture, 'sample.js'),
|
|
104
|
+
[
|
|
105
|
+
'export function sample(value) {',
|
|
106
|
+
' return value + 1;',
|
|
107
|
+
'}',
|
|
108
|
+
'',
|
|
109
|
+
].join('\n')
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const output = path.join(fixture, 'report');
|
|
113
|
+
execFileSync(
|
|
114
|
+
process.execPath,
|
|
115
|
+
[
|
|
116
|
+
jscpdCli,
|
|
117
|
+
'.',
|
|
118
|
+
'--reporters',
|
|
119
|
+
'json',
|
|
120
|
+
'--output',
|
|
121
|
+
output,
|
|
122
|
+
'--no-colors',
|
|
123
|
+
],
|
|
124
|
+
{
|
|
125
|
+
cwd: fixture,
|
|
126
|
+
stdio: 'pipe',
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const report = JSON.parse(
|
|
131
|
+
readFileSync(path.join(output, 'jscpd-report.json'), 'utf-8')
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
report.statistics.total.sources > 0,
|
|
135
|
+
`jscpd analysed ${report.statistics.total.sources} files; the config excludes everything`
|
|
136
|
+
);
|
|
137
|
+
} finally {
|
|
138
|
+
rmSync(fixture, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local quality gate has to be the same gate CI uses.
|
|
3
|
+
*
|
|
4
|
+
* Before this test the repository claimed pre-commit hooks through husky:
|
|
5
|
+
* `js/package.json` ran `"prepare": "husky || true"`, husky refused with
|
|
6
|
+
* `.git can't be found` because npm runs `prepare` inside `js/`, and `|| true`
|
|
7
|
+
* threw the refusal away. `git config --get core.hooksPath` stayed empty, so
|
|
8
|
+
* `js/.husky/pre-commit` never ran for anyone - the same shape of failure as the
|
|
9
|
+
* `|| true` that once hid a Prettier error in the release job.
|
|
10
|
+
*
|
|
11
|
+
* The replacement is a repository-root `.pre-commit-config.yaml` whose local
|
|
12
|
+
* hooks run the exact commands the workflows run. The assertions below pin that
|
|
13
|
+
* correspondence: change a lint command in a workflow without changing the hook
|
|
14
|
+
* (or the other way round) and this test fails.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { describe, it } from 'node:test';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
normalizeNewlines,
|
|
23
|
+
readRepoText,
|
|
24
|
+
repoPath,
|
|
25
|
+
} from '../../helpers/repo.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every local hook, mapped to the workflow step it has to mirror.
|
|
29
|
+
*
|
|
30
|
+
* `command` is matched twice: it must appear inside the hook's `entry`, and the
|
|
31
|
+
* workflow must contain a `run:` step that is exactly that command.
|
|
32
|
+
*/
|
|
33
|
+
const MIRRORED_COMMANDS = [
|
|
34
|
+
{ id: 'js-eslint', workflow: 'js.yml', command: 'npm run lint' },
|
|
35
|
+
{ id: 'js-prettier', workflow: 'js.yml', command: 'npm run format:check' },
|
|
36
|
+
{
|
|
37
|
+
id: 'js-duplication',
|
|
38
|
+
workflow: 'js.yml',
|
|
39
|
+
command: 'npm run check:duplication',
|
|
40
|
+
},
|
|
41
|
+
{ id: 'python-ruff', workflow: 'python.yml', command: 'ruff check .' },
|
|
42
|
+
{
|
|
43
|
+
id: 'python-ruff-format',
|
|
44
|
+
workflow: 'python.yml',
|
|
45
|
+
command: 'ruff format --check .',
|
|
46
|
+
},
|
|
47
|
+
{ id: 'python-mypy', workflow: 'python.yml', command: 'mypy src' },
|
|
48
|
+
{
|
|
49
|
+
id: 'python-mypy-scripts',
|
|
50
|
+
workflow: 'python.yml',
|
|
51
|
+
command: 'mypy --python-version 3.13 scripts tests/unit/scripts',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'rust-fmt',
|
|
55
|
+
workflow: 'rust.yml',
|
|
56
|
+
command: 'cargo fmt --all -- --check',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'rust-clippy',
|
|
60
|
+
workflow: 'rust.yml',
|
|
61
|
+
command: 'cargo clippy --all-targets --all-features',
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'file-line-limits',
|
|
65
|
+
workflow: 'quality.yml',
|
|
66
|
+
command: 'bash scripts/check-file-line-limits.sh',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'shared-fingerprint-assets',
|
|
70
|
+
workflow: 'quality.yml',
|
|
71
|
+
command: 'bash scripts/check-shared-fingerprint-assets.sh',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'repo-scripts-lint',
|
|
75
|
+
workflow: 'quality.yml',
|
|
76
|
+
command:
|
|
77
|
+
'node js/node_modules/eslint/bin/eslint.js scripts experiments rust/scripts',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'ci-workflow-policy',
|
|
81
|
+
workflow: 'ci-policy.yml',
|
|
82
|
+
command: 'node scripts/check-ci-workflows.mjs',
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Collect the `repo: local` hooks as `id -> lines of that hook`.
|
|
88
|
+
*
|
|
89
|
+
* A regex reader rather than a YAML parser, for the same reason
|
|
90
|
+
* `scripts/check-ci-workflows.mjs` uses one: this repository ships no YAML
|
|
91
|
+
* dependency, and a policy check that needs one is a policy check that stops
|
|
92
|
+
* being run.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} text - contents of .pre-commit-config.yaml
|
|
95
|
+
* @returns {Map<string, string[]>}
|
|
96
|
+
*/
|
|
97
|
+
function readLocalHooks(text) {
|
|
98
|
+
const localSection = text.slice(text.indexOf('- repo: local'));
|
|
99
|
+
const hooks = new Map();
|
|
100
|
+
let current = null;
|
|
101
|
+
|
|
102
|
+
for (const line of localSection.split(/\r?\n/)) {
|
|
103
|
+
const id = line.match(/^\s*-\s+id:\s+(\S+)/);
|
|
104
|
+
if (id) {
|
|
105
|
+
current = [];
|
|
106
|
+
hooks.set(id[1], current);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (current) {
|
|
110
|
+
current.push(line);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return hooks;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param {string[]} lines - the lines of one hook
|
|
118
|
+
* @returns {string} the hook's `entry` value
|
|
119
|
+
*/
|
|
120
|
+
function entryOf(lines) {
|
|
121
|
+
const entry = lines.find((line) => /^\s*entry:\s/.test(line));
|
|
122
|
+
return entry ? entry.replace(/^\s*entry:\s+/, '').trim() : '';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const localHooks = readLocalHooks(readRepoText('.pre-commit-config.yaml'));
|
|
126
|
+
|
|
127
|
+
const workflow = (name) => readRepoText('.github', 'workflows', name);
|
|
128
|
+
|
|
129
|
+
describe('local hooks run the commands CI runs', () => {
|
|
130
|
+
for (const { id, workflow: file, command } of MIRRORED_COMMANDS) {
|
|
131
|
+
it(`${id} runs \`${command}\`, the same as ${file}`, () => {
|
|
132
|
+
const lines = localHooks.get(id);
|
|
133
|
+
assert.ok(lines, `no \`${id}\` hook in .pre-commit-config.yaml`);
|
|
134
|
+
assert.ok(
|
|
135
|
+
entryOf(lines).includes(command),
|
|
136
|
+
`${id} must run \`${command}\`; its entry is \`${entryOf(lines)}\``
|
|
137
|
+
);
|
|
138
|
+
assert.ok(
|
|
139
|
+
workflow(file).includes(`run: ${command}\n`),
|
|
140
|
+
`${file} no longer has a step running \`${command}\`; the hook and the workflow have drifted apart`
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
it('reads a workflow checked out with Windows line endings', () => {
|
|
146
|
+
// The twelve assertions above match `run: <command>\n`. On windows-latest
|
|
147
|
+
// git checks the repository out with CRLF endings, so every one of them
|
|
148
|
+
// failed there while passing on Linux - run 33963736349, twelve failures,
|
|
149
|
+
// none of them a real drift. Reading through readRepoText() is what makes
|
|
150
|
+
// the assertion about this repository rather than about the runner.
|
|
151
|
+
const crlfWorkflow = normalizeNewlines(
|
|
152
|
+
workflow('js.yml').replaceAll('\n', '\r\n')
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
assert.ok(crlfWorkflow.includes('run: npm run lint\n'));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('leaves no local hook unpinned', () => {
|
|
159
|
+
const pinned = new Set(MIRRORED_COMMANDS.map((m) => m.id));
|
|
160
|
+
const unpinned = [...localHooks.keys()].filter((id) => !pinned.has(id));
|
|
161
|
+
assert.deepEqual(
|
|
162
|
+
unpinned,
|
|
163
|
+
[],
|
|
164
|
+
'every local hook must be listed in MIRRORED_COMMANDS so it cannot drift from CI'
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('never lets pre-commit append filenames to a `bash -c` entry', () => {
|
|
169
|
+
// `bash -c 'cmd' a.js b.js` passes the files as $0/$1, which bash ignores:
|
|
170
|
+
// the hook would keep passing while checking something else entirely.
|
|
171
|
+
for (const [id, lines] of localHooks) {
|
|
172
|
+
assert.ok(
|
|
173
|
+
lines.some((line) => /^\s*pass_filenames:\s+false\s*$/.test(line)),
|
|
174
|
+
`${id} must set pass_filenames: false`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('gives clippy the flag that makes a warning fatal in CI', () => {
|
|
180
|
+
// rust.yml sets RUSTFLAGS at workflow level, which is the only reason
|
|
181
|
+
// `cargo clippy` without `-D warnings` is a real gate there.
|
|
182
|
+
assert.ok(entryOf(localHooks.get('rust-clippy')).includes('-Dwarnings'));
|
|
183
|
+
assert.ok(workflow('rust.yml').includes('RUSTFLAGS: -Dwarnings'));
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('the previous hook wiring is gone', () => {
|
|
188
|
+
const manifest = JSON.parse(
|
|
189
|
+
readFileSync(repoPath('js', 'package.json'), 'utf-8')
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
it('installs no git hook through a masked command', () => {
|
|
193
|
+
for (const [name, command] of Object.entries(manifest.scripts)) {
|
|
194
|
+
assert.ok(
|
|
195
|
+
!/\|\|\s*true\s*$/.test(command),
|
|
196
|
+
`npm script \`${name}\` ends in \`|| true\`, which hides its own failure`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('keeps no husky wiring that cannot reach .git from js/', () => {
|
|
202
|
+
// husky refuses any path containing `..` and requires `.git` in the working
|
|
203
|
+
// directory, so it cannot manage a repository-root hook from this package.
|
|
204
|
+
assert.equal(manifest.devDependencies.husky, undefined);
|
|
205
|
+
assert.equal(manifest.devDependencies['lint-staged'], undefined);
|
|
206
|
+
assert.equal(manifest['lint-staged'], undefined);
|
|
207
|
+
assert.ok(!existsSync(repoPath('js', '.husky')));
|
|
208
|
+
});
|
|
209
|
+
});
|