diff --git a/scripts/__tests__/lint-no-hardcoded-runners.test.mjs b/scripts/__tests__/lint-no-hardcoded-runners.test.mjs index 709ac63ed..d13c2ad9f 100644 --- a/scripts/__tests__/lint-no-hardcoded-runners.test.mjs +++ b/scripts/__tests__/lint-no-hardcoded-runners.test.mjs @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { spawnSync } from 'node:child_process' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -11,14 +11,12 @@ const SCRIPT = resolve( ) function runScript(script, ...targets) { - try { - execFileSync(process.execPath, [script, ...targets], { encoding: 'utf8' }) - return { exitCode: 0, output: '' } - } catch (err) { - return { - exitCode: err.status, - output: String(err.stdout) + String(err.stderr), - } + const result = spawnSync(process.execPath, [script, ...targets], { + encoding: 'utf8', + }) + return { + exitCode: result.status, + output: String(result.stdout) + String(result.stderr), } } @@ -26,6 +24,69 @@ function run(target) { return runScript(SCRIPT, target) } +function runWithReaddirError(code, { afterParent = false } = {}) { + const probe = resolve( + fileURLToPath(import.meta.url), + '../../walk-error-probe.mjs', + ) + const dir = mkdtempSync(join(tmpdir(), 'lint-hardcoded-runners-walk-')) + const replacement = afterParent + ? `let readdirCalls = 0 +async function readdir() { + readdirCalls += 1 + if (readdirCalls === 1) { + return [{ name: 'vanished', isDirectory: () => true }] + } + const err = new Error('readdir failed') + err.code = '${code}' + throw err +}` + : `async function readdir() { const err = new Error('readdir failed'); err.code = '${code}'; throw err }` + const src = readFileSync(SCRIPT, 'utf8').replace( + "import { readdir } from 'node:fs/promises'", + replacement, + ) + try { + writeFileSync(probe, src) + return runScript(probe, dir) + } finally { + rmSync(probe, { force: true }) + rmSync(dir, { recursive: true, force: true }) + } +} + +function runWithReadFileError(code) { + const probe = resolve( + fileURLToPath(import.meta.url), + '../../read-error-probe.mjs', + ) + const dir = mkdtempSync(join(tmpdir(), 'lint-hardcoded-runners-read-')) + const src = readFileSync(SCRIPT, 'utf8') + .replace( + "import { readFileSync, statSync } from 'node:fs'", + `import { readFileSync as realReadFileSync, statSync } from 'node:fs' +function readFileSync(path, ...args) { + if (String(path).endsWith('vanished.ts')) { + const err = new Error('read failed') + err.code = '${code}' + throw err + } + return realReadFileSync(path, ...args) +}`, + ) + .replace( + "import { readdir } from 'node:fs/promises'", + "async function readdir() { return [{ name: 'vanished.ts', isDirectory: () => false }] }", + ) + try { + writeFileSync(probe, src) + return runScript(probe, dir) + } finally { + rmSync(probe, { force: true }) + rmSync(dir, { recursive: true, force: true }) + } +} + describe('lint-no-hardcoded-runners', () => { const fx = (name) => resolve(fileURLToPath(import.meta.url), `../fixtures/${name}`) @@ -34,6 +95,19 @@ describe('lint-no-hardcoded-runners', () => { expect(run(fx('clean.ts')).exitCode).toBe(0) }) + it('captures stderr from a successful script', () => { + const dir = mkdtempSync(join(tmpdir(), 'lint-hardcoded-runners-stderr-')) + const probe = join(dir, 'stderr-probe.mjs') + try { + writeFileSync(probe, "process.stderr.write('successful warning\\n')\n") + const r = runScript(probe) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('successful warning') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + it('fails on a hardcoded `npx ...` string literal', () => { const r = run(fx('offender.ts')) expect(r.exitCode).toBe(1) @@ -51,6 +125,30 @@ describe('lint-no-hardcoded-runners', () => { expect(r.output).not.toMatch(/at ModuleJob/) }) + it('skips a directory that vanished after its parent was enumerated', () => { + const r = runWithReaddirError('ENOENT', { afterParent: true }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('OK') + }) + + it('rethrows unexpected errors encountered while walking', () => { + const r = runWithReaddirError('EACCES') + expect(r.exitCode).not.toBe(0) + expect(r.output).toContain('EACCES') + }) + + it('skips a file that vanished after its directory was enumerated', () => { + const r = runWithReadFileError('ENOENT') + expect(r.exitCode).toBe(0) + expect(r.output).toContain('OK') + }) + + it('rethrows unexpected errors reading an enumerated file', () => { + const r = runWithReadFileError('EACCES') + expect(r.exitCode).not.toBe(0) + expect(r.output).toContain('EACCES') + }) + // Matches the sibling linter: a target outside the repo rendered as a // `../../../../..` chain out of the root instead of naming the file. it('renders an absolute path for an offender outside the repo root', () => { diff --git a/scripts/__tests__/vitest-config.test.mjs b/scripts/__tests__/vitest-config.test.mjs new file mode 100644 index 000000000..1ad8a56da --- /dev/null +++ b/scripts/__tests__/vitest-config.test.mjs @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import scriptsVitestConfig from '../vitest.config.mjs' + +describe('scripts vitest config', () => { + it('keeps script test files serialized', () => { + expect(scriptsVitestConfig.test.fileParallelism).toBe(false) + }) +}) diff --git a/scripts/lint-no-hardcoded-runners.mjs b/scripts/lint-no-hardcoded-runners.mjs index 26c6a2548..bdf058650 100644 --- a/scripts/lint-no-hardcoded-runners.mjs +++ b/scripts/lint-no-hardcoded-runners.mjs @@ -159,7 +159,17 @@ for (const target of TARGETS) { const shown = rel.startsWith('..') ? file : rel if (ALLOWLISTED_PATHS.has(rel)) continue if (/\.(test|spec)\.(ts|tsx|mts|cts)$/.test(file)) continue - const lines = readFileSync(file, 'utf8').split('\n') + let source + try { + source = readFileSync(file, 'utf8') + } catch (err) { + // A file can vanish after its directory was enumerated, just as a + // directory can vanish before the recursive readdir above. There is + // nothing left to lint; unexpected read failures must still surface. + if (err?.code === 'ENOENT') continue + throw err + } + const lines = source.split('\n') lines.forEach((line, idx) => { const matches = NPX_TOKEN.test(line) if (!matches) return