Skip to content

Commit 64053b1

Browse files
committed
chore(wheelhouse): cascade template@7b3d3e3
1 parent 6deafa4 commit 64053b1

1 file changed

Lines changed: 91 additions & 106 deletions

File tree

scripts/test.mts

Lines changed: 91 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,36 @@
1+
/* eslint-disable no-shadow -- nested cached-length for-loops intentionally reuse `i`/`length` names for the fleet-wide cached-loop idiom; renaming would diverge from the codebase pattern. */
12
/**
2-
* @file Canonical minimal test runner for socket-* repos. Scope modes:
3-
* (default) Run tests covering files modified in the working tree vs HEAD.
4-
* --staged Run tests covering files in the git index (pre-commit hook). --all
5-
* Run the full test suite. Flags: --quiet Suppress progress output.
6-
* Scope-to-tests mapping (adapt per repo layout):
3+
* @file Canonical minimal test runner for socket-* repos. Delegates the
4+
* scope-to-tests mapping to vitest itself rather than rolling a basename-
5+
* based mapper that would inevitably drift from the actual module graph.
76
*
8-
* - Changed test files run themselves.
9-
* - Changed source files under `packages/<pkg>/src/` run the sibling
10-
* `packages/<pkg>/test/` folder. Non-workspace repos can adapt the
11-
* resolveTestPatterns() function to their layout (e.g. single src/ + test/
12-
* at root, or tests colocated with source).
13-
* - Config / infrastructure changes escalate to the full suite. This is the
14-
* minimal zero-dependency reference implementation. Larger repos
15-
* (socket-registry, socket-sdk-js, socket-packageurl-js, etc.) use a richer
16-
* version; this one keeps the same CLI contract so pre-commit hooks and CI
17-
* work identically across repos.
7+
* Scope modes:
8+
*
9+
* - `(default)` — local-dev scope. Runs `vitest --changed`, vitest's
10+
* compare-vs-HEAD-with-uncommitted mode. Walks the actual import graph
11+
* so a change to a util shared by many tests runs every affected test
12+
* file, not the union of two guesses.
13+
* - `--staged` — pre-commit hook scope. Hands `git diff --cached` filenames
14+
* to `vitest related <files…> --run`. Same module-graph walk, but rooted
15+
* at the staged delta. The `--run` flag is mandatory: `vitest related`
16+
* defaults to watch mode just like the bare `vitest` invocation, which
17+
* would hang the pre-commit hook.
18+
* - `--all` — run the full suite (`vitest run`). Used in CI and on explicit
19+
* opt-in.
20+
*
21+
* Flags: `--quiet` / `--silent` suppress progress output.
22+
*
23+
* Config / infrastructure changes (`vitest.config*`, `tsconfig*`,
24+
* `.oxlintrc.json`, `.oxfmtrc.json`, `pnpm-lock.yaml`, `package.json`,
25+
* anything under `.config/` or `scripts/`) still escalate to `all` —
26+
* module-graph traversal doesn't capture config-derived discovery + alias
27+
* changes. See https://vitest.dev/guide/cli.html#vitest-related.
1828
*/
1929

30+
// prefer-async-spawn: sync-required — top-level CLI runner; entire
31+
// flow is sync (test runner invocation + exit-code aggregation).
2032
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
2133
import type { SpawnSyncOptions } from 'node:child_process'
22-
import { existsSync } from 'node:fs'
2334
import process from 'node:process'
2435
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
2536

@@ -33,6 +44,10 @@ const mode: 'staged' | 'all' | 'modified' = args.includes('--all')
3344
: 'modified'
3445
const quiet = args.includes('--quiet') || args.includes('--silent')
3546
const stdio: SpawnSyncOptions['stdio'] = quiet ? 'pipe' : 'inherit'
47+
// On Windows, `pnpm` is a .cmd shim that Node refuses to exec directly via
48+
// spawnSync (CVE-2024-27980 hardening). Wrap through the shell on Windows
49+
// only; POSIX keeps direct invocation.
50+
const useShell = process.platform === 'win32'
3651

3752
// Paths that, when changed, force the full suite to run.
3853
const ESCALATION_PATTERNS = [
@@ -47,20 +62,16 @@ const ESCALATION_PATTERNS = [
4762
/^lockstep\.schema\.json$/,
4863
]
4964

50-
export function getModifiedFiles(): string[] {
51-
return gitFiles(['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'])
52-
}
53-
54-
export function getStagedFiles(): string[] {
55-
return gitFiles(['diff', '--cached', '--name-only', '--diff-filter=ACMR'])
65+
function log(msg: string): void {
66+
if (!quiet) {
67+
logger.log(msg)
68+
}
5669
}
5770

58-
// spawnSync with array args — no shell interpolation. Matches the
59-
// socket/prefer-spawn-over-execsync rule: a shell-string execSync makes
60-
// every interpolated value an injection vector; the array form can't
61-
// shell-expand its args.
62-
export function gitFiles(gitArgs: string[]): string[] {
63-
const r = spawnSync('git', gitArgs, {
71+
function gitFiles(args: string[]): string[] {
72+
// spawnSync with array args — no shell interpolation. Matches the
73+
// socket/prefer-spawn-over-execsync rule contract.
74+
const r = spawnSync('git', args, {
6475
stdio: ['ignore', 'pipe', 'pipe'],
6576
stdioString: true,
6677
})
@@ -73,95 +84,63 @@ export function gitFiles(gitArgs: string[]): string[] {
7384
.filter(s => s.length > 0)
7485
}
7586

76-
export function log(msg: string): void {
77-
if (!quiet) {
78-
logger.log(msg)
79-
}
87+
function getStagedFiles(): string[] {
88+
return gitFiles(['diff', '--cached', '--name-only', '--diff-filter=ACMR'])
8089
}
8190

82-
/**
83-
* Map changed files to vitest test patterns.
84-
*
85-
* Default implementation handles two common layouts:
86-
*
87-
* - Pnpm workspace: packages/<pkg>/src/... → packages/<pkg>/test
88-
* - Single repo: src/... → test Adapt to your repo's layout if different.
89-
*/
90-
export function resolveTestPatterns(files: string[]): string[] {
91-
const patterns = new Set<string>()
92-
for (let i = 0, { length } = files; i < length; i += 1) {
93-
const f = files[i]
94-
// Test file itself.
95-
if (/\.test\.(m?[jt]s)$/.test(f)) {
96-
patterns.add(f)
97-
continue
98-
}
99-
// Workspace source file. Only emit the pattern if the test dir exists;
100-
// packages without a test/ directory are skipped rather than making
101-
// vitest error on an unknown pattern.
102-
const wsMatch = f.match(/^(packages\/[^/]+)\/src\//)
103-
if (wsMatch && existsSync(`${wsMatch[1]}/test`)) {
104-
patterns.add(`${wsMatch[1]}/test`)
105-
continue
106-
}
107-
// Single-repo source file.
108-
if (f.startsWith('src/') && existsSync('test')) {
109-
patterns.add('test')
110-
}
111-
}
112-
return [...patterns]
91+
function getModifiedFiles(): string[] {
92+
return gitFiles(['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'])
11393
}
11494

115-
export function runAll(): number {
116-
log('Test scope: all')
117-
const r = spawnSync('pnpm', ['exec', 'vitest', 'run'], { stdio })
118-
if (r.status === 0) {
119-
log('All tests passed')
120-
return 0
95+
function shouldEscalate(files: string[]): boolean {
96+
for (let i = 0, { length } = files; i < length; i += 1) {
97+
const f = files[i]!
98+
for (let i = 0, { length } = ESCALATION_PATTERNS; i < length; i += 1) {
99+
const pattern = ESCALATION_PATTERNS[i]!
100+
if (pattern.test(f)) {
101+
return true
102+
}
103+
}
121104
}
122-
log('Tests failed')
123-
return 1
105+
return false
124106
}
125107

126-
export function runPatterns(patterns: string[]): number {
127-
if (patterns.length === 0) {
128-
log('No tests to run; skipping.')
129-
return 0
130-
}
131-
log(`Test scope: ${mode} (${patterns.length} pattern(s))`)
132-
// --passWithNoTests: if a pattern produces zero matches (e.g. a freshly
133-
// added package with an empty test dir, or a source change that doesn't
134-
// touch any testable code), vitest treats it as success rather than a
135-
// "no test files found" error. Scoped-by-default runs shouldn't fail
136-
// just because the change didn't happen to touch a testable file.
108+
function runVitest(vitestArgs: string[], label: string): number {
109+
log(`Test scope: ${label}`)
137110
const r = spawnSync(
138111
'pnpm',
139-
['exec', 'vitest', 'run', '--passWithNoTests', ...patterns],
140-
{ stdio },
112+
['exec', 'vitest', ...vitestArgs, '--config', '.config/vitest.config.mts'],
113+
// Windows shell-shim rationale: see useShell at file top.
114+
{ shell: useShell, stdio },
141115
)
142-
if (r.status === 0) {
143-
log('All tests passed')
144-
return 0
116+
if (r.status !== 0) {
117+
log('Tests failed')
118+
return 1
145119
}
146-
log('Tests failed')
147-
return 1
120+
log('All tests passed')
121+
return 0
148122
}
149123

150-
export function shouldEscalate(files: string[]): boolean {
151-
for (let i = 0, { length } = files; i < length; i += 1) {
152-
const f = files[i]
153-
for (
154-
let j = 0, { length: patternsLength } = ESCALATION_PATTERNS;
155-
j < patternsLength;
156-
j += 1
157-
) {
158-
const pattern = ESCALATION_PATTERNS[j]
159-
if (pattern.test(f)) {
160-
return true
161-
}
162-
}
163-
}
164-
return false
124+
function runAll(): number {
125+
return runVitest(['run'], 'all')
126+
}
127+
128+
// --passWithNoTests: a scoped run where the changed files don't resolve
129+
// to any test file should succeed rather than error with "No test files
130+
// found". Keeps pre-commit hooks passing when an edit touches only
131+
// non-testable code.
132+
function runChanged(): number {
133+
return runVitest(['run', '--changed', '--passWithNoTests'], 'changed')
134+
}
135+
136+
function runRelated(files: string[]): number {
137+
// `vitest related <files…>` defaults to watch mode; `--run` forces a
138+
// single non-watch execution. Pass the staged file list as positionals;
139+
// vitest walks the module graph from each.
140+
return runVitest(
141+
['related', ...files, '--run', '--passWithNoTests'],
142+
`staged (${files.length} file(s))`,
143+
)
165144
}
166145

167146
function main(): void {
@@ -183,8 +162,14 @@ function main(): void {
183162
return
184163
}
185164

186-
const patterns = resolveTestPatterns(files)
187-
process.exitCode = runPatterns(patterns)
165+
if (mode === 'staged') {
166+
process.exitCode = runRelated(files)
167+
return
168+
}
169+
170+
// Working-tree changed → vitest's native --changed (it re-detects the
171+
// file list via git itself, including uncommitted edits).
172+
process.exitCode = runChanged()
188173
}
189174

190175
main()

0 commit comments

Comments
 (0)