From b99f4984602fbab06d8623f18bd7800b2ec7a05a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 30 Sep 2026 19:55:25 +0700 Subject: [PATCH 1/9] wip: e2e test infrastructure --- packages/uncheck/src/checks/tsc.ts | 9 +- packages/uncheck/src/tool.ts | 2 +- packages/uncheck/tests/command.test.ts | 2817 ----------------- packages/uncheck/tests/files.test.ts | 207 -- packages/uncheck/tests/fixture.ts | 56 - packages/uncheck/tests/tool.test.ts | 78 - packages/uncheck/tests/tsc.test.ts | 383 --- .../uncheck/tests/uncheck/project.test.ts | 13 + packages/uncheck/tests/utils/project.ts | 447 +++ packages/uncheck/tests/utils/register.ts | 30 + vitest.config.ts | 3 + 11 files changed, 497 insertions(+), 3548 deletions(-) delete mode 100644 packages/uncheck/tests/command.test.ts delete mode 100644 packages/uncheck/tests/files.test.ts delete mode 100644 packages/uncheck/tests/fixture.ts delete mode 100644 packages/uncheck/tests/tool.test.ts delete mode 100644 packages/uncheck/tests/tsc.test.ts create mode 100644 packages/uncheck/tests/uncheck/project.test.ts create mode 100644 packages/uncheck/tests/utils/project.ts create mode 100644 packages/uncheck/tests/utils/register.ts diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index d5f552d..8094671 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -1,5 +1,4 @@ import { posix } from 'node:path' -import process from 'node:process' import { Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' @@ -461,7 +460,6 @@ const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/ const FILES_ASTERISK = '(?:[^./]|(?:\\.(?!min\\.js$))?)*' const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' -const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' /** * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: @@ -499,10 +497,9 @@ function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | unde written = true } - return new RegExp( - `^${source}${usage === 'exclude' ? '(?:$|/)' : '$'}`, - CASE_INSENSITIVE ? 'i' : '', - ) + // tsc ignores case on file systems that do, so `include` ignores it and `exclude` does not: on any + // file system that selects every project tsc would check, and at worst one more. + return usage === 'files' ? new RegExp(`^${source}$`, 'i') : new RegExp(`^${source}(?:$|/)`) } function filesComponent(component: string): string { diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index b715a3e..2b5faf6 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -52,7 +52,7 @@ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName }) /** Windows caps a whole command line, node and the tool path included, at 32,767 characters. */ -const MAX_ARGV_LENGTH = process.platform === 'win32' ? 30_000 : 65_536 +const MAX_ARGV_LENGTH = 30_000 export function argvBatches(args: ReadonlyArray): ReadonlyArray> { const batches: string[][] = [[]] diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts deleted file mode 100644 index 183b64f..0000000 --- a/packages/uncheck/tests/command.test.ts +++ /dev/null @@ -1,2817 +0,0 @@ -import { Buffer } from 'node:buffer' -import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { once } from 'node:events' -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { delimiter, join } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { stripVTControlCharacters } from 'node:util' - -import { NodeServices } from '@effect/platform-node' -import { Console, Effect, Stdio, Stream } from 'effect' -import { Command } from 'effect/unstable/cli' - -import { hooks } from '../src/commands/hooks' -import { prepare } from '../src/commands/prepare' -import { staged } from '../src/commands/staged' -import { uncheck } from '../src/commands/uncheck' -import { CheckFailed } from '../src/errors' -import { middleapi as oxfmtPreset } from '../src/presets/oxfmt' -import { middleapi as oxlintPreset } from '../src/presets/oxlint' -import { fixture } from './fixture' - -const cli = uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])) -const SUBCOMMANDS = new Set(['staged', 'prepare', 'hooks', 'install', 'run']) - -interface RunResult { - readonly result: 'ok' | 'blocked' | CheckFailed - /** Everything logged, one line each, without ANSI styling. */ - readonly stdout: string - readonly stderr: string -} - -/** Runs the CLI in `cwd`; the flag goes after the subcommand names so it lands on the command that runs. */ -async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): Promise { - let verbs = 0 - - while (SUBCOMMANDS.has(args[verbs] ?? '')) { - verbs++ - } - - const argv = [...args.slice(0, verbs), '--cwd', cwd, ...args.slice(verbs)] - const stdout: string[] = [] - const stderr: string[] = [] - - const capture: Console.Console = Object.assign(Object.create(globalThis.console), { - log: (...parts: ReadonlyArray) => { - stdout.push(parts.join(' ')) - }, - error: (...parts: ReadonlyArray) => { - stderr.push(parts.join(' ')) - }, - }) - - const result = await Effect.runPromise( - Command.runWith(cli, { version: '0.0.0' })(argv).pipe( - Effect.map(() => 'ok' as const), - Effect.catchTag('CheckFailed', (error) => Effect.succeed(error)), - Effect.catchTag('StopBlocked', () => Effect.succeed('blocked' as const)), - Effect.provideService(Console.Console, capture), - Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })), - Effect.provide(NodeServices.layer), - ), - ) - - return { result, stdout: text(stdout), stderr: text(stderr) } -} - -/** Joins captured lines the way a terminal would show them, without ANSI styling. */ -function text(lines: string[]): string { - return stripVTControlCharacters(lines.map((line) => `${line}\n`).join('')) -} - -const cliBin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) - -const CLAUDE_STOP = JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }) - -const oxlintrc = { rules: { 'no-var': 'error' } } - -const standaloneTsconfig = { - compilerOptions: { - noEmit: true, - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - }, - include: ['src', 'scripts'], -} - -function compositeTsconfig(references: string[] = []) { - return { - compilerOptions: { - composite: true, - declaration: true, - emitDeclarationOnly: true, - outDir: 'dist', - rootDir: 'src', - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - }, - references: references.map((path) => ({ path })), - include: ['src'], - } -} - -describe('uncheck', { timeout: 120_000 }, () => { - it('reports oxlint and oxfmt failures, fixes them with --fix, then passes', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/legacy.ts': 'var count = 1;\nexport { count };\n', - 'src/ugly.ts': 'export const ugly = {a:1,\n b:2}\n', - }) - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'failed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'passed' }, - ]) - expect(check.stdout).toContain('no-var') - expect(check.stdout).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') - expect(check.stdout).toContain('rerun with `--fix` to apply oxlint and oxfmt fixes') - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBe('ok') - expect(fix.stdout).toContain('▶ oxlint --fix\n') - expect(fix.stdout).toContain('▶ oxfmt\n') - expect(readFileSync(join(dir, 'src/legacy.ts'), 'utf8')).toBe( - 'const count = 1;\nexport { count };\n', - ) - expect(readFileSync(join(dir, 'src/ugly.ts'), 'utf8')).toBe( - 'export const ugly = { a: 1, b: 2 };\n', - ) - - const clean = await run(dir) - - expect(clean.result).toBe('ok') - expect( - clean.stdout.startsWith( - `uncheck in ${dir}\n○ sherif skipped, no package.json found\n▶ oxlint\n`, - ), - ).toBe(true) - expect(clean.stdout).toContain('▶ oxlint\n') - expect(clean.stdout).toContain('▶ oxfmt --check\n') - expect(clean.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(clean.stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - }) - - it('builds referenced projects with tsc -b before checking standalone ones with tsc -p', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'scripts/hello.ts': 'export const greeting = "hi";\n', - 'packages/lib/tsconfig.json': compositeTsconfig(), - 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', - 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), - 'packages/app/src/index.ts': - 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', - 'tools/tsconfig.json': standaloneTsconfig, - 'tools/src/index.ts': - 'import { answer } from "../../packages/lib/dist/index";\n\nexport const tool: number = answer;\n', - }) - - const { result, stdout } = await run(dir) - - expect(result).toBe('ok') - expect(stdout).toContain( - '▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tools/tsconfig.json --noEmit\n▶ tsc -p tsconfig.json --noEmit\n', - ) - expect(stdout).not.toContain('packages/lib/tsconfig.json') - - writeFileSync(join(dir, 'packages/lib/src/index.ts'), 'export const answer: number = "42";\n') - - const broken = await run(dir) - - expect(broken.result).toBeInstanceOf(CheckFailed) - expect((broken.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'passed' }, - { name: 'oxfmt', status: 'passed' }, - { name: 'tsc', status: 'failed' }, - ]) - expect(broken.stdout).toContain('packages/lib/src/index.ts') - expect(broken.stdout).toContain('TS2322') - }) - - it('checks standalone projects side by side but prints their output in plan order', async () => { - const dir = fixture( - { - 'packages/a/tsconfig.json': standaloneTsconfig, - 'packages/a/src/index.ts': 'export const a: number = 1;\n', - 'packages/b/tsconfig.json': standaloneTsconfig, - 'packages/b/src/index.ts': 'export const b: number = "2";\n', - 'packages/c/tsconfig.json': standaloneTsconfig, - 'packages/c/src/index.ts': 'export const c: number = 3;\n', - }, - ['typescript'], - ) - - const { result, stdout } = await run(dir, ['--only=tsc']) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toMatch( - /▶ tsc -p packages\/a\/tsconfig\.json --noEmit\n▶ tsc -p packages\/b\/tsconfig\.json --noEmit\npackages\/b\/src\/index\.ts[^▶]*TS2322[^▶]*▶ tsc -p packages\/c\/tsconfig\.json --noEmit\n✘ tsc failed/, - ) - }) - - it('forwards paths to oxlint and oxfmt and narrows tsc to the projects containing them', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'scripts/hello.ts': 'export const greeting = "hi";\n', - 'scripts/legacy.ts': 'var count = 1;\nexport { count };\n', - 'README.md': '# Hi\n', - 'packages/lib/tsconfig.json': compositeTsconfig(), - 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', - 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), - 'packages/app/src/index.ts': - 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', - }) - - const scoped = await run(dir, ['packages/app']) - - expect(scoped.result).toBe('ok') - expect(scoped.stdout).not.toContain('no-var') - expect(scoped.stdout).toContain( - '▶ oxlint --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', - ) - expect(scoped.stdout).toContain( - '▶ oxfmt --check --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', - ) - // The root project's inputs (src, scripts) do not reach into packages/app, so only app is built. - expect(scoped.stdout).toContain('▶ tsc -b packages/app/tsconfig.json\n✔ tsc') - expect(scoped.stdout).not.toContain('tsc -p tsconfig.json') - - const single = await run(dir, ['--fix', 'scripts/hello.ts']) - - expect(single.result).toBe('ok') - expect(single.stdout).toContain( - '▶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n', - ) - expect(single.stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') - expect(single.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(single.stdout).not.toContain('tsc -b') - - const all = await run(dir, ['.']) - - expect(all.stdout).toMatch(/▶ oxlint --no-error-on-unmatched-pattern \[\d+ files\]\n/) - expect(all.stdout).toMatch(/▶ oxfmt --check --no-error-on-unmatched-pattern \[\d+ files\]\n/) - - const docs = await run(dir, ['README.md']) - - expect(docs.result).toBe('ok') - expect(docs.stdout).toContain('▶ oxlint --no-error-on-unmatched-pattern README.md\n') - expect(docs.stdout).toContain('○ tsc skipped, no tsconfig.json covers the given files\n') - expect(docs.stdout).toContain('✔ all checks passed (oxlint, oxfmt)') - }) - - it('fails on paths that match nothing unless told otherwise', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - }) - - await expect(run(dir, ['src/index.ts', 'missing.ts', 'lib/**'])).rejects.toThrow( - /No files match missing.ts, lib\/\*\*/, - ) - - const lenient = await run(dir, [ - '--no-error-on-unmatched-pattern', - 'src/index.ts', - 'missing.ts', - ]) - - expect(lenient.result).toBe('ok') - expect(lenient.stdout).toContain('▶ oxlint --no-error-on-unmatched-pattern src/index.ts\n') - - const nothing = await run(dir, ['--no-error-on-unmatched-pattern', 'missing.ts']) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe( - `uncheck in ${dir}\n○ nothing to check, no files match missing.ts\n`, - ) - }) - - it('discovers tsconfig files through git so ignored folders are skipped', async () => { - const files = { - '.gitignore': 'node_modules\ndist\nignored\n', - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'ignored/tsconfig.json': standaloneTsconfig, - 'ignored/src/index.ts': 'export const answer: number = "42";\n', - } - - const plain = fixture(files, ['typescript']) - const walked = await run(plain) - - expect(walked.result).toBeInstanceOf(CheckFailed) - expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json --noEmit\n') - - const repo = fixture(files, ['typescript']) - execFileSync('git', ['init', '--quiet'], { cwd: repo }) - const tracked = await run(repo) - - expect(tracked.result).toBe('ok') - expect(tracked.stdout).not.toContain('ignored/tsconfig.json') - expect(tracked.stdout).toContain('○ oxlint skipped, not installed\n') - expect(tracked.stdout).toContain('○ oxfmt skipped, not installed\n') - }) - - it('fails on circular project references', async () => { - const dir = fixture( - { - 'packages/a/tsconfig.json': compositeTsconfig(['../b']), - 'packages/a/src/index.ts': 'export const a = 1;\n', - 'packages/b/tsconfig.json': compositeTsconfig(['../a']), - 'packages/b/src/index.ts': 'export const b = 2;\n', - }, - ['typescript'], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain( - '✘ tsc circular project references between packages/a/tsconfig.json, packages/b/tsconfig.json\n', - ) - }) - - it('fails when a tsconfig.json exists but typescript is not installed', async () => { - const dir = fixture( - { 'tsconfig.json': standaloneTsconfig, 'src/index.ts': 'export const answer = 42;\n' }, - [], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect((result as CheckFailed).outcomes.map((outcome) => outcome.status)).toEqual([ - 'skipped', - 'skipped', - 'skipped', - 'failed', - ]) - expect(stdout).toContain('found 1 tsconfig.json but typescript is not installed') - }) - - it('fails when there is nothing to check', async () => { - const dir = fixture({ 'package.json': '{}\n' }, []) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect((result as CheckFailed).outcomes.map((outcome) => outcome.status)).toEqual([ - 'skipped', - 'skipped', - 'skipped', - 'skipped', - ]) - expect(stdout).toContain( - 'nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', - ) - }) - - it.skipIf(process.platform === 'win32')( - 'fails a check whose tool is killed and still runs the others', - async () => { - const dir = fixture( - { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, - 'node_modules/oxlint/bin.js': "process.kill(process.pid, 'SIGKILL')\n", - }, - ['oxfmt', 'typescript'], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain( - "▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n", - ) - expect(stdout).toContain('✘ oxlint failed') - expect(stdout).toContain('✔ tsc passed') - expect(stdout).toContain('✘ 1 of 3 checks failed: oxlint') - expect(stdout).not.toContain('PlatformError') - }, - ) -}) - -/** A workspace sherif has something to say about, with its install step off so the fix stays offline. */ -function workspace( - extra: Record = {}, - config: Record = {}, -) { - return fixture( - { - 'package.json': { - name: 'root', - private: true, - packageManager: 'pnpm@10.0.0', - workspaces: ['packages/*'], - devDependencies: { zod: '^3.0.0', react: '^18.0.0' }, - sherif: { noInstall: true, ...config }, - }, - 'packages/a/package.json': { - name: 'a', - version: '1.0.0', - dependencies: { react: '^18.0.0' }, - }, - 'packages/b/package.json': { - name: 'b', - version: '1.0.0', - dependencies: { react: '^17.0.0' }, - }, - ...extra, - }, - ['sherif'], - ) -} - -describe('uncheck sherif', { timeout: 120_000 }, () => { - beforeEach(() => { - vi.stubEnv('CI', undefined) - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - it('checks a workspace root with sherif and fixes it, aligning versions on the highest', async () => { - const dir = workspace() - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes[0]).toEqual({ name: 'sherif', status: 'failed' }) - expect(check.stdout).toContain('▶ sherif\n') - expect(check.stdout).toContain('unordered-dependencies') - expect(check.stdout).toContain('multiple-dependency-versions') - expect(check.stdout).toContain( - '✘ 1 of 1 checks failed: sherif\n rerun with `--fix` to apply sherif fixes', - ) - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBe('ok') - expect(fix.stdout).toContain('▶ sherif --fix --select=highest\n') - expect( - Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).devDependencies), - ).toEqual(['react', 'zod']) - expect(JSON.parse(readFileSync(join(dir, 'packages/b/package.json'), 'utf8'))).toMatchObject({ - dependencies: { react: '^18.0.0' }, - }) - - const clean = await run(dir) - - expect(clean.result).toBe('ok') - expect(clean.stdout).toContain('✔ all checks passed (sherif)') - }) - - it('leaves the version choice to the sherif config when it makes one', async () => { - const dir = workspace({}, { select: 'lowest' }) - - const { result, stdout } = await run(dir, ['--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ sherif --fix\n') - expect(JSON.parse(readFileSync(join(dir, 'packages/a/package.json'), 'utf8'))).toMatchObject({ - dependencies: { react: '^17.0.0' }, - }) - }) - - it('only checks in CI, where sherif refuses to fix', async () => { - vi.stubEnv('CI', 'true') - - const dir = workspace() - - const { result, stdout } = await run(dir, ['--fix']) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain('▶ sherif\n') - expect(stdout).toContain('unordered-dependencies') - expect(stdout).not.toContain('Cannot fix issues inside a CI environment') - }) - - it('runs only for a workspace root and only when a package.json is among the given files', async () => { - const dir = workspace({ 'src/index.ts': 'export const answer = 42;\n' }) - - const code = await run(dir, ['src/index.ts']) - - expect(code.result).toBeInstanceOf(CheckFailed) - expect(code.stdout).toContain('○ sherif skipped, no package.json among the given files\n') - expect(code.stdout).toContain('nothing to check: sherif no package.json among the given files') - - const manifest = await run(dir, ['packages/a/package.json']) - - expect(manifest.result).toBeInstanceOf(CheckFailed) - expect(manifest.stdout).toContain('▶ sherif\n') - - const single = fixture({ 'package.json': { name: 'single', private: true } }, ['sherif']) - const alone = await run(single) - - expect(alone.result).toBeInstanceOf(CheckFailed) - expect(alone.stdout).toContain('○ sherif skipped, not a workspace root\n') - - const nested = fixture( - { - 'package.json': { name: 'nested', private: true }, - 'pnpm-workspace.yaml': 'packages:\n - packages/*\n', - }, - ['sherif'], - ) - const pnpm = await run(nested) - - expect(pnpm.stdout).toContain('▶ sherif\n') - }) -}) - -describe('uncheck check flags', { timeout: 120_000 }, () => { - it('skips a check with --skip and requires it with --require', async () => { - const dir = fixture( - { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: string = 1;\n', - }, - ['oxlint', 'typescript'], - ) - - const skipped = await run(dir, ['--skip=tsc']) - - expect(skipped.result).toBe('ok') - expect(skipped.stdout).toContain('○ oxfmt skipped, not installed\n') - expect(skipped.stdout).toContain('○ tsc skipped, disabled with --skip=tsc\n') - expect(skipped.stdout).toContain('✔ all checks passed (oxlint)') - - const required = await run(dir, ['--require=oxfmt', '--skip=tsc']) - - expect(required.result).toBeInstanceOf(CheckFailed) - expect(required.stdout).toContain('✘ oxfmt not installed\n') - expect(required.stdout).toContain('✘ 1 of 2 checks failed: oxfmt') - - await expect(run(dir, ['--require=tsc', '--skip=tsc'])).rejects.toThrow( - /--require=tsc and --skip=tsc/, - ) - - const none = await run(dir, ['--skip=oxlint', '--skip=oxfmt', '--skip=tsc']) - - expect(none.result).toBeInstanceOf(CheckFailed) - expect(none.stdout).toContain( - 'nothing to check: sherif not installed, oxlint disabled with --skip=oxlint, oxfmt disabled with --skip=oxfmt, tsc disabled with --skip=tsc', - ) - }) - - it('runs only the checks named with --only', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/ugly.ts': 'export const ugly = 1\n', - }) - - const lint = await run(dir, ['--only=oxlint']) - - expect(lint.result).toBe('ok') - expect(lint.stdout).toContain('▶ oxlint\n') - expect(lint.stdout).toContain('○ oxfmt skipped, not selected by --only\n') - expect(lint.stdout).toContain('○ tsc skipped, not selected by --only\n') - expect(lint.stdout).toContain('✔ all checks passed (oxlint)') - - const format = await run(dir, ['--only=oxfmt']) - - expect(format.result).toBeInstanceOf(CheckFailed) - expect((format.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'not selected by --only' }, - { name: 'oxlint', status: 'skipped', reason: 'not selected by --only' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'skipped', reason: 'not selected by --only' }, - ]) - - await expect(run(dir, ['--only=oxlint', '--skip=oxlint'])).rejects.toThrow( - /--only=oxlint and --skip=oxlint/, - ) - await expect(run(dir, ['--only=oxlint', '--only=oxfmt', '--require=tsc'])).rejects.toThrow( - /--require=tsc and --only=oxlint --only=oxfmt/, - ) - }) -}) - -describe('uncheck hooks install', { timeout: 120_000 }, () => { - it('writes stop hook configs for the named agents through the detected package manager', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - - const { result, stdout } = await run(dir, ['hooks', 'install', 'claude', 'cursor', 'copilot']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') - expect(stdout).toContain('✔ Cursor .cursor/hooks.json created\n') - expect(stdout).toContain('pnpm exec uncheck hooks run --fix') - - const hook = 'pnpm exec uncheck hooks run --fix' - - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: hook, timeout: 600 }] }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { stop: [{ command: hook, timeout: 600 }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { - agentStop: [{ type: 'command', bash: hook, powershell: hook, timeoutSec: 600 }], - }, - }) - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - }) - - it('merges into existing settings and keeps other hooks', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - '.claude/settings.json': `{ - // keep me - "permissions": { "allow": ["Bash(pnpm test)"] }, - "hooks": { - "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }], - "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }], - }, -} -`, - }, - [], - ) - const hook = 'npx --no uncheck hooks run --fix --require=tsc --skip=sherif' - - await expect(run(dir, ['hooks', 'install', '--require=tsc'])).rejects.toThrow( - 'Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot', - ) - - const { result, stdout } = await run(dir, [ - 'hooks', - 'install', - 'codebuddy', - 'claude', - '--require=tsc', - '--skip=sherif', - ]) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json created\n') - expect(stdout).toContain(hook) - - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - permissions: { allow: ['Bash(pnpm test)'] }, - hooks: { - PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], - Stop: [ - { hooks: [{ type: 'command', command: 'notify-send done' }] }, - { hooks: [{ type: 'command', command: hook, timeout: 600 }] }, - ], - }, - }) - }) - - it('rejects unknown agents', async () => { - const dir = fixture({ 'package.json': '{}\n' }, []) - - await expect(run(dir, ['hooks', 'install', 'emacs'])).rejects.toThrow() - }) - - it('writes the check flags into the hook command and updates an installed hook', async () => { - const dir = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) - const fast = 'yarn run --silent uncheck hooks run --fix --only=oxlint --only=oxfmt' - - const { result, stdout } = await run(dir, [ - 'hooks', - 'install', - 'claude', - 'copilot', - '--only=oxlint', - '--only=oxfmt', - ]) - - expect(result).toBe('ok') - expect(stdout).toContain(fast) - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: fast, timeout: 600 }] }] }, - }) - - const all = 'yarn run --silent uncheck hooks run --fix' - const again = await run(dir, ['hooks', 'install', 'claude', 'copilot']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(again.stdout).toContain('✔ GitHub Copilot .github/hooks/uncheck.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: all, timeout: 600 }] }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { - agentStop: [{ type: 'command', bash: all, powershell: all, timeoutSec: 600 }], - }, - }) - - await expect( - run(dir, ['hooks', 'install', 'claude', '--only=oxlint', '--skip=oxlint']), - ).rejects.toThrow(/--only=oxlint and --skip=oxlint/) - }) - - it('updates its own entry in place and leaves strings that only mention the hook alone', async () => { - const mentions = { - _comment: 'the Stop hook runs npx uncheck hooks run --fix', - permissions: { allow: ['Bash(npx uncheck hooks run:*)'] }, - hooks: { - PostToolUse: [ - { - hooks: [ - { - type: 'command', - command: 'cd packages/web && npx uncheck hooks run --fix && notify', - }, - { type: 'command', command: 'npx uncheck hooks run --fix --only=oxfmt' }, - ], - }, - ], - }, - } - const dir = fixture({ 'package.json': '{}\n', '.claude/settings.json': mentions }, []) - const hook = { type: 'command', command: 'npx --no uncheck hooks run --fix', timeout: 600 } - - const { result, stdout } = await run(dir, ['hooks', 'install', 'claude']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - ...mentions, - hooks: { ...mentions.hooks, Stop: [{ hooks: [hook] }] }, - }) - - const older = { - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: 'npx uncheck hooks run --fix --only=oxlint', - statusMessage: 'Checking', - }, - ], - }, - ], - }, - } - - writeFileSync(join(dir, '.claude/settings.json'), JSON.stringify(older)) - - const upgraded = await run(dir, ['hooks', 'install', 'claude']) - - expect(upgraded.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ ...hook, statusMessage: 'Checking' }] }] }, - }) - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - - writeFileSync( - join(dir, '.claude/settings.json'), - JSON.stringify({ hooks: { Stop: [{ hooks: [{ ...hook, timeout: 1800 }] }] } }), - ) - mkdirSync(join(dir, '.github/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.github/hooks/uncheck.json'), - JSON.stringify({ hooks: { agentStop: [{ bash: hook.command, timeout: 1800 }] } }), - ) - - const raised = await run(dir, ['hooks', 'install', 'claude', 'copilot']) - - expect(raised.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - expect( - JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).hooks.agentStop, - ).toEqual([{ type: 'command', bash: hook.command, powershell: hook.command, timeout: 1800 }]) - }) - - it('runs the root binary of a Yarn 2+ workspace from wherever the agent moved to', async () => { - const dir = fixture({ 'package.json': { packageManager: 'yarn@4.18.0' }, 'yarn.lock': '' }, []) - - const { stdout } = await run(dir, ['hooks', 'install', 'claude']) - - expect(stdout).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix ') - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - - const workspace = committed({ - 'package.json': { packageManager: 'yarn@4.18.0', devDependencies: { uncheck: '*' } }, - 'yarn.lock': '', - 'packages/app/package.json': { name: 'app' }, - 'packages/lib/package.json': { name: 'lib', devDependencies: { uncheck: '*' } }, - }) - - expect( - (await run(join(workspace, 'packages/app'), ['hooks', 'install', 'claude'])).stdout, - ).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix --dir=packages/app ') - expect( - (await run(join(workspace, 'packages/lib'), ['hooks', 'install', 'claude'])).stdout, - ).toContain('The hook runs yarn run --silent uncheck hooks run --fix --dir=packages/lib ') - }) - - it('refuses a config that is not a JSON object and leaves every file as it is', async () => { - const broken = '{\n permissions: { "deny": ["Read(.env)"] },\n "model": "opus"\n}\n' - const dir = fixture( - { 'package.json': '{}\n', '.cursor/hooks.json': broken, '.github/hooks/uncheck.json': '[]' }, - [], - ) - - await expect(run(dir, ['hooks', 'install', 'claude', 'cursor'])).rejects.toThrow( - '.cursor/hooks.json has InvalidSymbol on line 2, fix it and run again', - ) - await expect(run(dir, ['hooks', 'install', 'copilot'])).rejects.toThrow( - '.github/hooks/uncheck.json is not a JSON object', - ) - expect(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8')).toBe(broken) - expect(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).toBe('[]') - expect(existsSync(join(dir, '.claude/settings.json'))).toBe(false) - - writeFileSync(join(dir, '.cursor/hooks.json'), '\n') - - const { result, stdout } = await run(dir, ['hooks', 'install', 'cursor']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Cursor .cursor/hooks.json updated\n') - }) - - it('names the directory below the top of the repository in the hook command', async () => { - const dir = committed({ 'package.json': '{}\n', 'packages/web/package.json': '{}\n' }) - mkdirSync(join(dir, 'packages/my web')) - - const { result, stdout } = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') - expect( - JSON.parse(readFileSync(join(dir, 'packages/web/.claude/settings.json'), 'utf8')), - ).toMatchObject({ - hooks: { - Stop: [{ hooks: [{ command: 'npx --no uncheck hooks run --fix --dir=packages/web' }] }], - }, - }) - - const again = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - await expect(run(join(dir, 'packages/my web'), ['hooks', 'install', 'claude'])).rejects.toThrow( - /cannot name packages\/my web/, - ) - await expect(run(join(dir, 'packages/web'), ['hooks', 'install', 'copilot'])).rejects.toThrow( - /Copilot reads \.github\/hooks only at the top of the repository/, - ) - }) -}) - -/** Runs git in `dir` with a throwaway identity and returns what it printed. */ -function gitIn(dir: string, ...args: string[]): string { - return execFileSync( - 'git', - ['-c', 'user.name=uncheck', '-c', 'user.email=uncheck@example.com', ...args], - { - cwd: dir, - encoding: 'utf8', - }, - ) -} - -/** A git repository with one clean commit, so later edits show up as working-tree changes. */ -function committed(files: Record) { - const dir = fixture(files) - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - - return dir -} - -/** Commits `files` on a new `side` branch and goes back to the branch before it. */ -function commitOnSide(dir: string, files: Record) { - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - - for (const [file, content] of Object.entries(files)) { - writeFileSync(join(dir, file), content) - } - - gitIn(dir, 'add', '-A') - gitIn(dir, 'commit', '--quiet', '-m', 'side') - gitIn(dir, 'checkout', '--quiet', '-') -} - -const clean = { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'src/other.ts': 'export const other = 2;\n', -} - -function installPreCommitHook(dir: string, args: string, folder = '.') { - mkdirSync(join(dir, '.git/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.git/hooks/pre-commit'), - `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${cliBin}" ${args}) || exit 1\n`, - { mode: 0o755 }, - ) -} - -describe('uncheck hooks run', { timeout: 120_000 }, () => { - it('checks the files changed since the last commit and sends the agent back once', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - writeFileSync(join(dir, 'src/fresh.ts'), 'export const fresh = 3;\n') - - const claude = await run(dir, ['hooks', 'run', '--fix'], CLAUDE_STOP) - - expect(claude.result).toBe('blocked') - expect(claude.stdout).toBe('') - expect(claude.stderr).toContain( - '▶ oxlint --fix --no-error-on-unmatched-pattern src/fresh.ts src/index.ts\n', - ) - expect(claude.stderr).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(claude.stderr).toContain('TS2322') - expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1;\n', - ) - - const continuing = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: true }), - ) - - expect(continuing.result).toBe('ok') - expect(JSON.parse(continuing.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(continuing.stderr).toContain('TS2322') - - const cursor = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'stop', loop_count: 0 }), - ) - - expect(cursor.result).toBe('ok') - expect((JSON.parse(cursor.stdout) as { followup_message: string }).followup_message).toContain( - 'TS2322', - ) - - const copilot = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ stopReason: 'end_turn', stop_hook_active: false }), - ) - - expect(copilot.result).toBe('ok') - expect(JSON.parse(copilot.stdout)).toMatchObject({ decision: 'block' }) - - const copilotInClaudeFormat = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop', stop_reason: 'end_turn', stop_hook_active: false }), - ) - - expect(copilotInClaudeFormat.result).toBe('ok') - expect(JSON.parse(copilotInClaudeFormat.stdout)).toMatchObject({ decision: 'block' }) - }) - - it('passes a change no selected check covers, unless that check is required', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'README.md'), '# Project\n') - - const optional = await run(dir, ['hooks', 'run', '--fix', '--only=tsc'], CLAUDE_STOP) - - expect(optional.result).toBe('ok') - expect(optional.stderr).toContain('○ nothing to check') - - const required = await run( - dir, - ['hooks', 'run', '--fix', '--only=tsc', '--require=tsc'], - CLAUDE_STOP, - ) - - expect(required.result).toBe('blocked') - }) - - it('never takes a deleted file for a pattern that matches its neighbours', async () => { - const dir = committed({ - ...clean, - 'app/[id].ts': 'export const id = 1;\n', - 'app/i.ts': 'export const i = 1\n', - }) - rmSync(join(dir, 'app/[id].ts')) - - const { result, stderr } = await run( - dir, - ['hooks', 'run', '--fix', '--only=oxfmt'], - CLAUDE_STOP, - ) - - expect(result).toBe('ok') - expect(stderr).not.toContain('app/i.ts') - expect(readFileSync(join(dir, 'app/i.ts'), 'utf8')).toBe('export const i = 1\n') - }) - - it('checks the top of the repository wherever the agent moved to, or the directory in --dir', async () => { - const dir = committed({ - ...clean, - 'docs/guide.md': '# Guide\n', - 'packages/app/src/index.ts': 'export const app = 1;\n', - 'packages/web/src/index.ts': 'export const web = 1;\n', - }) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1;\n') - const moved = spawnSync(process.execPath, [cliBin, 'hooks', 'run', '--fix'], { - cwd: join(dir, 'docs'), - input: CLAUDE_STOP, - encoding: 'utf8', - }) - - expect(moved.status).toBe(2) - expect(moved.stderr).toContain('TS2322') - - writeFileSync(join(dir, 'packages/app/src/index.ts'), 'export const app = 2\n') - writeFileSync(join(dir, 'packages/web/src/index.ts'), 'export const web = 2\n') - - const { result, stderr } = await run( - join(dir, 'packages/web'), - ['hooks', 'run', '--fix', '--only=oxfmt', '--dir=packages/app'], - CLAUDE_STOP, - ) - - expect(result).toBe('ok') - expect(stderr).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(readFileSync(join(dir, 'packages/app/src/index.ts'), 'utf8')).toBe( - 'export const app = 2;\n', - ) - expect(readFileSync(join(dir, 'packages/web/src/index.ts'), 'utf8')).toBe( - 'export const web = 2\n', - ) - }) - - it('reports a --dir that names nothing instead of sending the agent back', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer = 1\n') - - await expect( - run(dir, ['hooks', 'run', '--fix', '--dir=packages/gone'], CLAUDE_STOP), - ).rejects.toThrow(/--dir=packages\/gone names nothing/) - }) - - it('leaves a Cursor turn the user stopped, or that failed, alone', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - - for (const status of ['aborted', 'error']) { - const { result, stdout } = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'stop', status, loop_count: 0 }), - ) - - expect(result).toBe('ok') - expect(stdout).toBe('') - } - - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1\n', - ) - }) - - it('stays silent when nothing changed and passes quietly when the changes are clean', async () => { - const dir = committed(clean) - - const nothing = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop' }), - ) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe('') - expect(nothing.stderr).toBe('') - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - - const ok = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop' }), - ) - - expect(ok.result).toBe('ok') - expect(ok.stdout).toBe('') - expect(ok.stderr).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/other.ts\n') - expect(ok.stderr).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - - const fast = await run( - dir, - ['hooks', 'run', '--fix', '--only=oxlint', '--only=oxfmt'], - CLAUDE_STOP, - ) - - expect(fast.result).toBe('ok') - expect(fast.stdout).toBe('') - expect(fast.stderr).toContain('○ tsc skipped, not selected by --only\n') - expect(fast.stderr).toContain('✔ all checks passed (oxlint, oxfmt)') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1;\n', - ) - }) - - it('checks everything under the directory outside a git repository', async () => { - const dir = fixture(clean) - - const { result, stderr } = await run(dir, ['hooks', 'run'], '{}') - - expect(result).toBe('ok') - expect(stderr).toContain('▶ oxlint\n') - expect(stderr).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - }) -}) - -describe('uncheck staged', { timeout: 120_000 }, () => { - it('checks the staged files, stages the fixes and keeps unstaged changes out of the way', async () => { - const dir = committed(clean) - - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\n', - ) - gitIn(dir, 'add', 'src/index.ts') - // An unstaged hunk in the staged file, an unstaged file and an untracked one: none of them is checked. - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\nexport const three = 3;\n', - ) - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3\n') - writeFileSync(join(dir, 'src/fresh.ts'), 'var fresh = 4\n') - - const { result, stdout } = await run(dir, ['staged', '--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain( - `uncheck staged in ${dir}\n○ unstaged changes of src/index.ts set aside until the checks finish\n`, - ) - expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)\n') - expect(stdout).toContain( - '✔ staged the fixes to src/index.ts\n○ unstaged changes of src/index.ts restored\n', - ) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\n', - ) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\nexport const three = 3;\n', - ) - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 3\n') - expect(readFileSync(join(dir, 'src/fresh.ts'), 'utf8')).toBe('var fresh = 4\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe( - 'MM src/index.ts\n M src/other.ts\n?? src/fresh.ts\n', - ) - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('undoes the fixes when they conflict with unstaged changes, so nothing is lost', async () => { - const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) - const dir = committed({ ...clean, 'src/far.ts': far.join('') }) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - // The unstaged change touches the line the formatter rewrites. - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - writeFileSync(join(dir, 'src/far.ts'), ['export const f0 = 0;\n', ...far.slice(1)].join('')) - gitIn(dir, 'add', 'src/far.ts') - writeFileSync( - join(dir, 'src/far.ts'), - ['export const f0 = 0;\n', ...far.slice(1, -1), 'export const f29 = 30;\n'].join(''), - ) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 42\n') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 43\n', - ) - // The fixes to the other staged file are undone too, so a commit attempt never half applies. - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 2\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 2\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('fails when the fixes undo every staged change, unless empty commits are allowed', async () => { - const dir = committed({ - ...clean, - 'src/index.ts': 'export const answer: number = 42;\nexport const two = 2;\n', - }) - - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\n', - ) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\nexport const three = 3;\n', - ) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /fixes undid every staged change, so the commit would be empty/, - ) - - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\nexport const three = 3;\n', - ) - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - - const allowed = await run(dir, ['staged', '--fix', '--allow-empty']) - - expect(allowed.result).toBe('ok') - expect(allowed.stdout).toContain('✔ staged the fixes to src/other.ts\n') - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - - const unborn = fixture({ ...clean, 'src/index.ts': 'export const answer: number = 42\n' }) - - gitIn(unborn, 'init', '--quiet') - gitIn(unborn, 'add', '.') - - const first = await run(unborn, ['staged', '--fix']) - - expect(first.result).toBe('ok') - expect(first.stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(unborn, 'show', ':src/index.ts')).toBe('export const answer: number = 42;\n') - }) - - it('lets a merge through when the fixes turn its tree back into what HEAD has', async () => { - const dir = committed(clean) - - commitOnSide(dir, { 'src/other.ts': 'export const other = 3;\n' }) - gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ staged the fixes to src/other.ts\n') - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - }) - - it('only reports without --fix, stages the fixes even when a check fails, and needs staged files', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - gitIn(dir, 'add', 'src/index.ts') - - const check = await run(dir, ['staged']) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json among the given files' }, - { name: 'oxlint', status: 'passed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'failed' }, - ]) - expect(check.stdout).toContain('▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts\n') - expect(check.stdout).not.toContain('staged the fixes') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: string = 1\n') - - const fix = await run(dir, ['staged', '--fix']) - - expect(fix.result).toBeInstanceOf(CheckFailed) - expect(fix.stdout).toContain('TS2322') - expect(fix.stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: string = 1;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M src/index.ts\n') - - gitIn(dir, 'commit', '--quiet', '-m', 'wip') - - const nothing = await run(dir, ['staged', '--fix']) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe(`uncheck staged in ${dir}\n○ nothing to check, no staged files\n`) - - await expect(run(fixture(clean), ['staged'])).rejects.toThrow(/needs a git repository/) - }) - - it('puts unstaged lines back where they were after the fixes move or change lines around them', async () => { - const split = 'export const list = [\n 1,\n 2,\n];\n' - const joined = 'export const list = [1, 2];\n' - const staged = - "\nexport function f() {\n return 1;\n}\n\nexport function g() {\n return 'g'\n}\n" - const fixed = staged.replace("'g'", '"g";') - const edit = (text: string) => - `${text.replace(' return 1;', ' // in f\n return 1;')}\nexport const z = 1;\n` - const dir = committed({ 'packages/app/src/index.ts': joined }) - const file = join(dir, 'packages/app/src/index.ts') - - writeFileSync(file, split + staged) - gitIn(dir, 'add', '.') - writeFileSync(file, split + edit(staged)) - - const { result } = await run(join(dir, 'packages/app'), ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe(joined + fixed) - expect(readFileSync(file, 'utf8')).toBe(joined + edit(fixed)) - }) - - it('stages only the staged files, even when their names look like patterns', async () => { - const dir = committed({ - 'app/[id]/page.ts': 'export const id = 1;\n', - 'app/i/page.ts': 'export const i = 1;\n', - }) - - writeFileSync(join(dir, 'app/[id]/page.ts'), 'export const id = 2\n') - writeFileSync(join(dir, 'app/i/page.ts'), 'export const i = 2\n') - gitIn(dir, '--literal-pathspecs', 'add', 'app/[id]/page.ts') - vi.stubEnv('GIT_GLOB_PATHSPECS', '1') - vi.stubEnv('GIT_ICASE_PATHSPECS', '1') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']).finally(() => - vi.unstubAllEnvs(), - ) - - expect(result).toBe('ok') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M app/[id]/page.ts\n M app/i/page.ts\n') - }) - - it('never stages the commit a submodule has checked out but not staged', async () => { - const dir = committed(clean) - const sub = join(dir, 'sub') - - mkdirSync(sub) - gitIn(sub, 'init', '--quiet') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'one') - gitIn(dir, 'add', 'sub') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'two') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - gitIn(dir, 'add', 'sub', 'src/index.ts') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'three') - const staged = gitIn(dir, 'rev-parse', ':sub') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'rev-parse', ':sub')).toBe(staged) - }) - - it('merges what git stores, so line endings a formatter rewrites are no change', async () => { - const dir = committed(clean) - const crlf = (text: string) => text.replaceAll('\n', '\r\n') - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - - gitIn(dir, 'config', 'core.autocrlf', 'true') - writeFileSync(join(dir, 'src/index.ts'), crlf(`export const answer: number = 43\n${lines}`)) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - crlf(`export const answer: number = 43\n${lines}export const d = 4;\n`), - ) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const answer: number = 43;\n${lines}`) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - crlf(`export const answer: number = 43;\n${lines}export const d = 4;\n`), - ) - }) - - it('reads every unstaged change right, a rename among them', async () => { - const dir = committed({ ...clean, 'src/aaa.ts': 'export const aaa = 1;\n' }) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src/other.ts') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\nexport const more = 4;\n') - gitIn(dir, 'mv', 'src/aaa.ts', 'src/moved.ts') - gitIn(dir, 'reset', '--quiet', '--', 'src/aaa.ts', 'src/moved.ts') - gitIn(dir, 'add', '--intent-to-add', 'src/moved.ts') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 3;\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe( - 'export const other = 3;\nexport const more = 4;\n', - ) - }) - - it('merges by plain text whatever merge driver the repository sets', async () => { - const dir = committed(clean) - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), '*.ts merge=ours\n') - gitIn(dir, 'config', 'merge.ours.driver', 'true') - writeFileSync(join(dir, 'src/index.ts'), `export const answer: number = 42\n${lines}`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - `export const answer: number = 42\n${lines.replace('c = 3', 'c = 30')}`, - ) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - `export const answer: number = 42;\n${lines.replace('c = 3', 'c = 30')}`, - ) - }) - - it('refuses to set aside an unstaged change that is not an edit to a file', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src/other.ts') - rmSync(join(dir, 'src/other.ts')) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /src\/other\.ts are not edits to a file/, - ) - expect(existsSync(join(dir, 'src/other.ts'))).toBe(false) - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 3;\n') - }) - - it('keeps the unstaged changes when setting them aside fails halfway', async () => { - const dir = committed(clean) - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), 'src/other.ts filter=flaky\n') - gitIn(dir, 'config', 'filter.flaky.clean', 'cat') - gitIn( - dir, - 'config', - 'filter.flaky.smudge', - 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi', - ) - gitIn(dir, 'config', 'filter.flaky.required', 'true') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43;\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4;\n') - - await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout-index -f \[2 paths\] failed/) - - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 44;\n', - ) - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 4;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/index.ts\nMM src/other.ts\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('keeps the unstaged changes aside and stops the commit when they cannot be put back', async () => { - const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' - const dir = committed({ ...clean, 'src/index.ts': `export const a = 1;\n${rest} 1;\n` }) - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), 'src/index.ts filter=once\n') - gitIn(dir, 'config', 'filter.once.clean', 'cat') - gitIn( - dir, - 'config', - 'filter.once.smudge', - 'if [ -e .git/smudged ]; then exit 1; else touch .git/smudged; cat; fi', - ) - gitIn(dir, 'config', 'filter.once.required', 'true') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /The unstaged changes of src\/index\.ts could not be put back/, - ) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) - expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe( - `export const a = 2\n${rest} 2;\n`, - ) - }) - - it('keeps an edit saved to a partially staged file while the checks run', async () => { - const lines = Array.from({ length: 30 }, (_, index) => `export const v${index} = ${index};\n`) - const dir = fixture( - { - 'src/lines.ts': lines.join(''), - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, - 'node_modules/oxlint/lint.js': - "const fs = require('node:fs')\nfs.writeFileSync('src/lines.ts', fs.readFileSync('src/lines.ts', 'utf8').replace('v15 = 15', 'v15 = 1500'))\n", - }, - [], - ) - const file = join(dir, 'src/lines.ts') - const staged = ['export const v0 = 100;\n', ...lines.slice(1)] - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - - writeFileSync(file, staged.join('')) - gitIn(dir, 'add', 'src/lines.ts') - writeFileSync(file, [...staged.slice(0, -1), 'export const v29 = 2900;\n'].join('')) - - const { result } = await run(dir, ['staged', '--only=oxlint']) - - expect(result).toBe('ok') - expect(readFileSync(file, 'utf8')).toBe( - [ - ...staged.slice(0, 15), - 'export const v15 = 1500;\n', - ...staged.slice(16, -1), - 'export const v29 = 2900;\n', - ].join(''), - ) - expect(gitIn(dir, 'show', ':src/lines.ts')).toBe(staged.join('')) - }) - - it('stages the fixes in the index `git commit ` leaves behind, not only in its own', () => { - const dir = committed(clean) - - installPreCommitHook(dir, 'staged --fix --only=oxfmt') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - gitIn(dir, 'commit', '--quiet', '-m', 'fix', 'src/index.ts') - - expect(gitIn(dir, 'show', 'HEAD:src/index.ts')).toBe('export const answer: number = 43;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('') - }) - - it('runs from a package folder of a linked worktree, whose hook git hands GIT_DIR', () => { - const dir = committed({ - ...clean, - '.gitignore': 'node_modules\nworktrees\n', - 'packages/app/src/index.ts': 'export const app = 1;\n', - }) - const worktree = join(dir, 'worktrees/wt') - - installPreCommitHook(dir, 'staged --fix --only=oxfmt', 'packages/app') - gitIn(dir, 'worktree', 'add', '--quiet', worktree) - symlinkSync(join(dir, 'node_modules'), join(worktree, 'node_modules')) - writeFileSync(join(worktree, 'packages/app/src/index.ts'), 'export const app = 2\n') - gitIn(worktree, 'commit', '--quiet', '-am', 'fix') - - expect(gitIn(worktree, 'show', 'HEAD:packages/app/src/index.ts')).toBe( - 'export const app = 2;\n', - ) - expect(gitIn(worktree, 'status', '--porcelain')).toBe('') - }) - - it('stops instead of overwriting unstaged changes an earlier run left behind', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43;\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n') - mkdirSync(join(dir, '.git/uncheck-unstaged/src'), { recursive: true }) - writeFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'left behind') - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /An earlier run left the unstaged versions of your files in .*uncheck-unstaged/, - ) - expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe( - 'left behind', - ) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 44;\n', - ) - }) - - it.skipIf(process.platform === 'win32')( - 'checks a symlink turned into a file, never what a staged symlink points to', - async () => { - const dir = committed(clean) - - symlinkSync('other.ts', join(dir, 'src/link.ts')) - gitIn(dir, 'add', 'src/link.ts') - gitIn(dir, 'commit', '--quiet', '-m', 'link') - rmSync(join(dir, 'src/link.ts')) - writeFileSync(join(dir, 'src/link.ts'), 'export const link = 1\n') - symlinkSync('other.ts', join(dir, 'src/alias.ts')) - gitIn(dir, 'add', 'src/link.ts', 'src/alias.ts') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3\n') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts\n') - expect(stdout).toContain('✔ staged the fixes to src/link.ts\n') - expect(gitIn(dir, 'show', ':src/link.ts')).toBe('export const link = 1;\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 3\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe( - 'A src/alias.ts\nT src/link.ts\n M src/other.ts\n', - ) - }, - ) - - it('checks only the files of a merge that differ from the side merged in', async () => { - const dir = committed(clean) - - commitOnSide(dir, { - 'src/theirs.ts': 'export const theirs = 1\n', - 'src/other.ts': 'export const other = 3;\n', - }) - gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') - - expect((await run(dir, ['staged', '--fix', '--only=oxfmt'])).stdout).toContain( - '○ nothing to check, every staged file comes from the branch being merged in\n', - ) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4\n') - gitIn(dir, 'add', 'src/other.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts\n') - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 4;\n') - expect(gitIn(dir, 'show', ':src/theirs.ts')).toBe('export const theirs = 1\n') - }) - - it('undoes or stages only what the fixes changed, so a merge can bring in files outside a sparse checkout', async () => { - const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - - commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) - gitIn(dir, 'sparse-checkout', 'set', 'src') - gitIn(dir, 'merge', '--quiet', '--squash', 'side') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('M lib/lib.ts\nMM src/index.ts\n') - - gitIn(dir, 'add', 'src/index.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(existsSync(join(dir, 'lib'))).toBe(false) - expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 2;\n') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 43;\n') - }) - - it('stages fixes to a conflict a merge left outside a sparse checkout', async () => { - const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - - commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 3;\n') - gitIn(dir, 'commit', '--quiet', '-am', 'main') - gitIn(dir, 'sparse-checkout', 'set', 'src') - spawnSync('git', ['-c', 'user.name=u', '-c', 'user.email=u@e', 'merge', '--quiet', 'side'], { - cwd: dir, - }) - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 4\n') - gitIn(dir, 'add', '--sparse', 'lib/lib.ts') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 4;\n') - }) - - it('undoes fixes to a file git keeps with CRLF line endings rather than turn them to LF', async () => { - const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' - const dir = committed({ ...clean, '.oxlintrc.json': oxlintrc, 'src/legacy.ts': lines }) - const file = join(dir, 'src/legacy.ts') - const unstaged = `var c = 3;\r\n${lines}export { c };\r\nexport const d = 4;\r\n` - - writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') - gitIn(dir, 'add', '.gitattributes') - gitIn(dir, 'commit', '--quiet', '-m', 'attributes') - writeFileSync(file, `var c = 3;\r\n${lines}export { c };\r\n`) - gitIn(dir, 'add', 'src/legacy.ts') - writeFileSync(file, unstaged) - - await expect(run(dir, ['staged', '--fix', '--only=oxlint'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/legacy\.ts and were undone/, - ) - expect(readFileSync(file, 'utf8')).toBe(unstaged) - }) - - it('sees no change in a file git keeps with CRLF line endings under text=auto', async () => { - const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' - const dir = committed({ ...clean, 'src/legacy.ts': lines }) - const file = join(dir, 'src/legacy.ts') - - writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') - gitIn(dir, 'add', '.gitattributes') - gitIn(dir, 'commit', '--quiet', '-m', 'attributes') - gitIn(dir, 'config', 'core.safecrlf', 'true') - writeFileSync(file, `${lines}export const c = 3;\r\n`) - gitIn(dir, 'add', 'src/legacy.ts') - writeFileSync(file, `${lines}export const c = 3;\r\nexport const d = 4;\r\n`) - - const { result } = await run(dir, ['staged', '--only=oxlint']) - - expect(result).toBe('ok') - expect(readFileSync(file, 'utf8')).toBe( - `${lines}export const c = 3;\r\nexport const d = 4;\r\n`, - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/legacy.ts\n') - }) - - it('sets unstaged changes aside and back without running the post-checkout hook', async () => { - const dir = committed(clean) - - mkdirSync(join(dir, '.git/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.git/hooks/post-checkout'), - '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n', - { mode: 0o755 }, - ) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - - expect(existsSync(join(dir, '.git/post-checkout-ran'))).toBe(false) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 42\n') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 43\n', - ) - }) - - it('only reports what sherif finds, since its fixes reach beyond the staged files', async () => { - const dir = workspace() - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync( - join(dir, 'packages/a/package.json'), - JSON.stringify({ name: 'a', version: '1.0.1', dependencies: { react: '^18.0.0' } }), - ) - gitIn(dir, 'add', 'packages/a/package.json') - vi.stubEnv('CI', undefined) - - const { result, stdout } = await run(dir, ['staged', '--fix']).finally(() => vi.unstubAllEnvs()) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain('▶ sherif\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M packages/a/package.json\n') - }) - - it('passes a commit no check has anything to do with, unless a check is required', async () => { - const dir = committed({ ...clean, 'README.md': '# readme\n' }) - - writeFileSync(join(dir, 'README.md'), '# readme\n\nmore\n') - gitIn(dir, 'add', 'README.md') - - const docs = await run(dir, ['staged', '--only=tsc']) - - expect(docs.result).toBe('ok') - expect(docs.stdout).toContain( - '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files\n', - ) - - const required = await run(dir, ['staged', '--only=tsc', '--require=tsc']) - - expect(required.result).toBeInstanceOf(CheckFailed) - expect(required.stdout).toContain('✘ tsc no tsconfig.json covers the given files\n') - }) - - it('checks and fixes staged files whose names start with ! or -', async () => { - const dir = committed({ ...clean, 'x.ts': 'export const x = 1;\n' }) - - writeFileSync(join(dir, '!x.ts'), 'export const bang = 1\n') - writeFileSync(join(dir, '-x.ts'), 'export const dash = 1\n') - writeFileSync(join(dir, 'x.ts'), 'export const x = 2\n') - gitIn(dir, 'add', '--', '!x.ts', '-x.ts', 'x.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxlint', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt)\n') - expect(stdout).toContain('✔ staged the fixes to !x.ts -x.ts x.ts\n') - expect(gitIn(dir, 'show', ':./!x.ts')).toBe('export const bang = 1;\n') - expect(gitIn(dir, 'show', ':./-x.ts')).toBe('export const dash = 1;\n') - expect(gitIn(dir, 'show', ':x.ts')).toBe('export const x = 2;\n') - }) - - it.skipIf(process.platform === 'win32')( - 'puts unstaged changes back when a closed terminal hangs up during a slow check', - async () => { - const dir = fixture( - { - ...clean, - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, - 'node_modules/oxlint/lint.js': - "require('node:fs').writeFileSync('node_modules/started', '')\nsetTimeout(() => {}, 30_000)\n", - }, - [], - ) - const file = join(dir, 'src/index.ts') - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync(file, 'export const answer: number = 43;\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(file, 'export const answer: number = 43;\nexport const more = 1;\n') - const check = spawn(process.execPath, [cliBin, 'staged', '--only=oxlint'], { - cwd: dir, - stdio: 'ignore', - }) - const exited = once(check, 'exit') - onTestFinished(() => { - check.kill('SIGKILL') - }) - - await vi.waitFor(() => expect(existsSync(join(dir, 'node_modules/started'))).toBe(true), { - timeout: 10_000, - }) - check.kill('SIGHUP') - check.kill('SIGHUP') - - expect(await exited).toEqual([130, null]) - expect(readFileSync(file, 'utf8')).toBe( - 'export const answer: number = 43;\nexport const more = 1;\n', - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/index.ts\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }, - ) -}) - -describe('uncheck staged in a package', { timeout: 120_000 }, () => { - it('checks and stages from inside a package, the way the hook of a monorepo does', async () => { - const dir = committed({ - '.oxlintrc.json': oxlintrc, - 'src/root.ts': 'export const root = 1;\n', - 'packages/app/tsconfig.json': standaloneTsconfig, - 'packages/app/src/index.ts': 'export const answer: number = 42;\n', - }) - - writeFileSync(join(dir, 'packages/app/src/index.ts'), 'export const answer: number = 42\n') - writeFileSync(join(dir, 'src/root.ts'), 'export const root = 11\n') - gitIn(dir, 'add', 'packages/app/src/index.ts', 'src/root.ts') - - const { result, stdout } = await run(join(dir, 'packages/app'), ['staged', '--fix']) - - expect(result).toBe('ok') - // Staged files outside the package are another line's business, so they are neither checked nor fixed. - expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( - 'export const answer: number = 42;\n', - ) - expect(gitIn(dir, 'show', ':src/root.ts')).toBe('export const root = 11\n') - }) - - it('puts unstaged changes back with the line endings the package sets', async () => { - const crlf = (text: string) => text.replaceAll('\n', '\r\n') - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - const dir = committed({ - 'packages/app/.gitattributes': '*.ts text eol=crlf\n', - 'packages/app/src/index.ts': crlf(`export const answer: number = 42;\n${lines}`), - }) - const file = join(dir, 'packages/app/src/index.ts') - - writeFileSync(file, crlf(`export const answer: number = 43\n${lines}`)) - gitIn(dir, 'add', '.') - writeFileSync(file, crlf(`export const answer: number = 43\n${lines}export const d = 4;\n`)) - - const { result } = await run(join(dir, 'packages/app'), ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( - `export const answer: number = 43;\n${lines}`, - ) - expect(readFileSync(file, 'utf8')).toBe( - crlf(`export const answer: number = 43;\n${lines}export const d = 4;\n`), - ) - }) - - it('reports what git refused to do instead of crashing', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - // A lock left behind by another git process makes every write to the index fail. - writeFileSync(join(dir, '.git/index.lock'), '') - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow(/git .* failed:/) - }) - - it.skipIf(process.platform === 'win32')( - 'puts unstaged changes back in a repository whose path has a newline', - async () => { - const dir = join(fixture({}), 'new\nline') - const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' - - mkdirSync(join(dir, 'src'), { recursive: true }) - writeFileSync(join(dir, 'src/index.ts'), `export const a = 1;\n${rest} 1;\n`) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - `export const a = 2;\n${rest} 2;\n`, - ) - expect((await run(dir, ['hooks', 'install', 'claude'])).result).toBe('ok') - }, - ) -}) - -describe('uncheck prepare', { timeout: 120_000 }, () => { - const header = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' - - let globalConfig = '' - - beforeEach(() => { - globalConfig = join(fixture({ '.gitconfig': '' }, []), '.gitconfig') - vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) - vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - it('writes the pre-commit hook through the detected package manager and updates it in place', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - await expect(run(dir, ['prepare'])).rejects.toThrow(/Pass --pre-commit/) - expect(existsSync(hook)).toBe(false) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit created\n') - expect(stdout).toContain('The hook runs pnpm exec uncheck staged --fix before every commit') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged --fix || exit 1\n`) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0o111) - } - - const again = await run(dir, ['prepare', '--pre-commit']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n') - - const fast = await run(dir, ['prepare', '--pre-commit', '--only=oxlint', '--only=oxfmt']) - - expect(fast.result).toBe('ok') - expect(fast.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix --only=oxlint --only=oxfmt || exit 1\n`, - ) - - const allowEmpty = await run(dir, ['prepare', '--pre-commit', '--allow-empty']) - - expect(allowEmpty.result).toBe('ok') - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix --allow-empty || exit 1\n`, - ) - - const checkOnly = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(checkOnly.result).toBe('ok') - expect(checkOnly.stdout).toContain('The hook runs pnpm exec uncheck staged before every commit') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged || exit 1\n`) - - await expect( - run(dir, ['prepare', '--pre-commit', '--only=oxlint', '--skip=oxlint']), - ).rejects.toThrow(/--only=oxlint and --skip=oxlint/) - }) - - it('keeps one line per package, drops duplicates and leaves lines it did not write alone', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/app/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const app = '(cd "packages/app" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1' - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/app'), ['prepare', '--pre-commit', '--only=oxlint']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n${app}\n`, - ) - - // Preparing the root again leaves the line of the nested package alone. - const root = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(root.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged || exit 1\n${app}\n`) - - const mine = 'echo "runs uncheck staged"' - - writeFileSync( - hook, - `${header}${mine}\npnpm exec uncheck staged --fix\npnpm test\nnpx uncheck staged --skip=tsc\n`, - ) - - const deduped = await run(dir, ['prepare', '--pre-commit']) - - expect(deduped.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - // The second copy goes, the line that only mentions the command and the unrelated one stay. - expect(readFileSync(hook, 'utf8')).toBe( - `${header}${mine}\npnpm exec uncheck staged --fix || exit 1\npnpm test\n`, - ) - - const settled = await run(dir, ['prepare', '--pre-commit']) - - expect(settled.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n') - - const chained = 'npx uncheck staged --only=oxfmt && pnpm test' - const advisory = 'pnpm exec uncheck staged --fix || echo "not blocking"' - - writeFileSync(hook, `#!/bin/sh\n${chained}\n${advisory}\n`) - - const handWritten = await run(dir, ['prepare', '--pre-commit']) - - expect(handWritten.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe( - `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${chained}\n${advisory}\n`, - ) - }) - - it.skipIf(process.platform === 'win32')( - 'writes a hook that runs each line from the top of the working tree and fails when any line fails', - async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/a/package.json': '{}\n', - 'packages/b/package.json': '{}\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const top = realpathSync(dir) - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/a'), ['prepare', '--pre-commit', '--only=oxlint']) - await run(join(dir, 'packages/b'), ['prepare', '--pre-commit']) - - const bin = fixture( - { pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\ntest ! -e fail\n' }, - [], - ) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - - function commit() { - writeFileSync(log, '') - - const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log }, - }) - - return { status, ran: readFileSync(log, 'utf8').split('\n').filter(Boolean) } - } - - const root = `${top} exec uncheck staged --fix` - const a = `${top}/packages/a exec uncheck staged --fix --only=oxlint` - const b = `${top}/packages/b exec uncheck staged --fix` - - expect(commit()).toEqual({ status: 0, ran: [root, a, b] }) - - writeFileSync(join(dir, 'fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [root] }) - - rmSync(join(dir, 'fail')) - writeFileSync(join(dir, 'packages/a/fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [root, a] }) - }, - ) - - it('keeps the line of every package when they all prepare at once, as a workspace install does', async () => { - const packages = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] - const folders = ['.', ...packages.map((name) => `packages/${name}`)] - const dir = fixture( - { - 'pnpm-lock.yaml': '', - ...Object.fromEntries(folders.map((folder) => [`${folder}/package.json`, '{}\n'])), - }, - [], - ) - gitIn(dir, 'init', '--quiet') - - const exits = await Promise.all( - folders.map((folder) => { - const prepared = spawn(process.execPath, [cliBin, 'prepare', '--pre-commit'], { - cwd: join(dir, folder), - stdio: 'ignore', - }) - - return once(prepared, 'exit') - }), - ) - - expect(exits.map(([code]) => code)).toEqual(folders.map(() => 0)) - expect(readFileSync(join(dir, '.git/hooks/pre-commit'), 'utf8').split('\n').sort()).toEqual( - [ - ...header.split('\n'), - 'pnpm exec uncheck staged --fix || exit 1', - ...packages.map( - (name) => `(cd "packages/${name}" && pnpm exec uncheck staged --fix) || exit 1`, - ), - ].sort(), - ) - expect(readdirSync(join(dir, '.git/hooks')).filter((name) => name.endsWith('.lock'))).toEqual( - [], - ) - }) - - it.skipIf(process.platform === 'win32')( - 'swaps in the new hook whole, so a commit already running it finishes the old one', - async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/a/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const top = realpathSync(dir) - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/a'), ['prepare', '--pre-commit']) - - const bin = fixture( - { - pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\nwhile [ ! -e "$GO" ]; do sleep 0.05; done\n', - }, - [], - ) - const log = join(bin, 'log') - const go = join(bin, 'go') - chmodSync(join(bin, 'pnpm'), 0o755) - writeFileSync(log, '') - - const commit = spawn('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log, GO: go }, - stdio: 'ignore', - }) - const exited = once(commit, 'exit') - onTestFinished(() => writeFileSync(go, '')) - - await vi.waitFor(() => expect(readFileSync(log, 'utf8')).not.toBe(''), { timeout: 10_000 }) - - const { stdout } = await run(dir, ['prepare', '--pre-commit', '--only=oxlint']) - - writeFileSync(go, '') - - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect((await exited)[0]).toBe(0) - expect(readFileSync(log, 'utf8').split('\n').filter(Boolean)).toEqual([ - `${top} exec uncheck staged --fix`, - `${top}/packages/a exec uncheck staged --fix`, - ]) - }, - ) - - it.skipIf(process.platform === 'win32')( - 'writes the script a symlinked hook points to, even one not there yet, and keeps the link', - async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'scripts/pre-commit': '#!/bin/sh\npnpm test\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - symlinkSync('../../scripts/pre-commit', hook) - - const { stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(lstatSync(hook).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dir, 'scripts/pre-commit'), 'utf8')).toBe( - '#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\npnpm test\n', - ) - expect(statSync(hook).mode & 0o777).toBe(0o755) - - rmSync(hook) - symlinkSync('../../scripts/later', hook) - - const dangling = await run(dir, ['prepare', '--pre-commit']) - - expect(dangling.stdout).toContain('✔ pre-commit .git/hooks/pre-commit created\n') - expect(lstatSync(hook).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dir, 'scripts/later'), 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n`, - ) - expect(statSync(hook).mode & 0o777).toBe(0o755) - }, - ) - - it('updates the lines older versions wrote, whichever package prepares next', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/a/package.json': '{}\n', - 'packages/b/package.json': '{}\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - writeFileSync( - hook, - [ - `${header}pnpm exec uncheck staged --fix`, - 'pnpm test', - 'cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint', - 'cd "packages/b" && pnpm exec uncheck staged --fix', - '', - ].join('\n'), - ) - - const { stdout } = await run(join(dir, 'packages/b'), ['prepare', '--pre-commit', '--no-fix']) - - expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - [ - `${header}pnpm exec uncheck staged --fix || exit 1`, - 'pnpm test', - '(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', - '(cd "packages/b" && pnpm exec uncheck staged) || exit 1', - '', - ].join('\n'), - ) - - const again = await run(join(dir, 'packages/b'), ['prepare', '--pre-commit', '--no-fix']) - - expect(again.stdout).toContain(`✔ pre-commit ${hook} unchanged\n`) - }) - - it('switches the lines older versions wrote to the current runner in place', async () => { - const npm = fixture({ 'package.json': '{}\n', 'package-lock.json': '{}\n' }, []) - gitIn(npm, 'init', '--quiet') - const npmHook = join(npm, '.git/hooks/pre-commit') - writeFileSync(npmHook, `${header}npx uncheck staged --fix || exit 1\npnpm test\n`) - - await run(npm, ['prepare', '--pre-commit']) - - expect(readFileSync(npmHook, 'utf8')).toBe( - `${header}npx --no uncheck staged --fix || exit 1\npnpm test\n`, - ) - - const yarn = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) - gitIn(yarn, 'init', '--quiet') - const yarnHook = join(yarn, '.git/hooks/pre-commit') - writeFileSync(yarnHook, '#!/bin/sh\nyarn uncheck staged --fix\npnpm test\n') - - await run(yarn, ['prepare', '--pre-commit']) - - expect(readFileSync(yarnHook, 'utf8')).toBe( - '#!/bin/sh\nyarn run --silent uncheck staged --fix || exit 1\npnpm test\n', - ) - }) - - it('takes the runner from the packageManager field and reports an unwritable hook', async () => { - const dir = fixture({ 'package.json': { packageManager: 'bun@1.2.0' } }, []) - gitIn(dir, 'init', '--quiet') - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain( - 'The hook runs bunx --no-install uncheck staged --fix before every commit', - ) - - const blocked = fixture({ 'package.json': '{}\n' }, []) - gitIn(blocked, 'init', '--quiet') - mkdirSync(join(blocked, '.git/hooks/pre-commit'), { recursive: true }) - - // A `prepare` script that fails would fail the install, so it reports and carries on. - const refused = await run(blocked, ['prepare', '--pre-commit']) - - expect(refused.result).toBe('ok') - expect(refused.stdout).toContain('✘ pre-commit .git/hooks/pre-commit not written,') - expect( - readdirSync(join(blocked, '.git/hooks')).filter( - (name) => name.includes('uncheck') || name.endsWith('.lock'), - ), - ).toEqual([]) - }) - - it('adds itself to an existing hook, enters a nested project and does nothing outside git', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'packages/app/package.json': '{}\n', 'packages/app/yarn.lock': '' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - writeFileSync(hook, '#!/bin/sh\necho hi', { mode: 0o600 }) - - const { result, stdout } = await run(join(dir, 'packages/app'), ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - '#!/bin/sh\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\necho hi', - ) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o777).toBe(0o755) - } - - const plain = fixture({ 'package.json': '{}\n' }, []) - const skipped = await run(plain, ['prepare', '--pre-commit']) - - expect(skipped.result).toBe('ok') - expect(skipped.stdout).toBe('○ no git repository found, nothing to prepare\n') - }) - - it.skipIf(process.platform === 'win32')( - 'runs before the commands of an existing hook, so they still fail it and cannot skip it', - async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const bin = fixture({ pnpm: '#!/bin/sh\necho "$*" >> "$LOG"\ntest "$1" = exec\n' }, []) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`) - const setup = - '#!/bin/sh\n# lint\nexport HOOKS="$(dirname "$0")"\n[ -s "$HOOKS/env" ] && \\. "$HOOKS/env"\n' - writeFileSync(hook, `${setup}export CI=1 && exec pnpm lint-staged\n`) - - await run(dir, ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${setup}pnpm exec uncheck staged --fix || exit 1\nexport CI=1 && exec pnpm lint-staged\n`, - ) - - const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, LOG: log }, - }) - - expect(status).toBe(1) - expect(readFileSync(log, 'utf8')).toBe('exec uncheck staged --fix\nlint-staged\n') - }, - ) - - it('runs before a setup line that continues onto the next, not inside it', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - for (const setup of [ - 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n', - 'A="\n. b\n"\n', - "A='\n. b\n'\n", - ]) { - writeFileSync(hook, `#!/bin/sh\n${setup}npx lint-staged\n`) - - await run(dir, ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${setup}npx lint-staged\n`, - ) - } - }) - - it('leaves a hook in another language alone and says what it should run', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const script = '#!/usr/bin/env node\nconsole.log("checked")\n' - writeFileSync(hook, script, { mode: 0o755 }) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - '✘ pre-commit .git/hooks/pre-commit not written, it is not a shell script, have it run `pnpm exec uncheck staged --fix || exit 1` yourself\n', - ) - expect(readFileSync(hook, 'utf8')).toBe(script) - - const binary = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 0xff]) - writeFileSync(hook, binary) - - expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain('not a shell script') - expect(readFileSync(hook)).toEqual(binary) - - if (process.platform !== 'win32' && process.getuid?.() !== 0) { - writeFileSync(hook, script) - chmodSync(hook, 0) - - expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain( - '✘ pre-commit .git/hooks/pre-commit not written, EACCES', - ) - chmodSync(hook, 0o755) - expect(readFileSync(hook, 'utf8')).toBe(script) - } - }) - - it('writes nothing for a package whose folder name sh would expand', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/a$b/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - const { result, stdout } = await run(join(dir, 'packages/a$b'), ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - `✘ pre-commit ${hook} not written, sh would misread the folder name "packages/a$b" between double quotes\n`, - ) - expect(existsSync(hook)).toBe(false) - }) - - it('writes the hook the husky 9 and Vite+ dispatcher runs, not their generated shim', async () => { - const dispatcher = [ - 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', - '[ ! -f "$s" ] && exit 0', - 'export PATH="node_modules/.bin:$PATH"', - 'sh -e "$s" "$@"', - 'exit $?', - '', - ].join('\n') - const shim = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/app/package.json': '{}\n', - '.husky/_/h': dispatcher, - '.husky/_/pre-commit': shim, - '.husky/pre-commit': 'pnpm test\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'config', 'core.hooksPath', '.husky/_') - chmodSync(join(dir, '.husky/_/pre-commit'), 0o755) - const hook = join(dir, '.husky/pre-commit') - const app = '(cd "packages/app" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1' - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .husky/pre-commit updated\n') - - const nested = await run(join(dir, 'packages/app'), [ - 'prepare', - '--pre-commit', - '--only=oxlint', - ]) - - expect(nested.stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - `pnpm exec uncheck staged --fix || exit 1\n${app}\npnpm test\n`, - ) - expect(readFileSync(join(dir, '.husky/_/pre-commit'), 'utf8')).toBe(shim) - - const again = await run(dir, ['prepare', '--pre-commit']) - - expect(again.stdout).toContain('✔ pre-commit .husky/pre-commit unchanged\n') - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0) - - const bin = fixture( - { pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\ntest ! -e fail\n' }, - [], - ) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - - function commit() { - writeFileSync(log, '') - - const { status } = spawnSync( - 'git', - [ - '-c', - 'user.name=uncheck', - '-c', - 'user.email=uncheck@example.com', - 'commit', - '--allow-empty', - '--quiet', - '--message=test', - ], - { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log }, - }, - ) - - return { status, ran: readFileSync(log, 'utf8').split('\n').filter(Boolean) } - } - - const top = realpathSync(dir) - - expect(commit()).toEqual({ - status: 0, - ran: [ - `${top} exec uncheck staged --fix`, - `${top}/packages/app exec uncheck staged --fix --only=oxlint`, - `${top} test`, - ], - }) - - writeFileSync(join(dir, 'fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [`${top} exec uncheck staged --fix`] }) - - chmodSync(hook, 0o755) - - const committed = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(committed.stdout).toContain('✔ pre-commit .husky/pre-commit updated\n') - expect(statSync(hook).mode & 0o777).toBe(0o755) - } - - const vite = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', '.vite-hooks/_/h': dispatcher }, - [], - ) - gitIn(vite, 'init', '--quiet') - gitIn(vite, 'config', 'core.hooksPath', '.vite-hooks/_') - - const created = await run(vite, ['prepare', '--pre-commit']) - - expect(created.stdout).toContain('✔ pre-commit .vite-hooks/pre-commit created\n') - expect(readFileSync(join(vite, '.vite-hooks/pre-commit'), 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n`, - ) - expect(existsSync(join(vite, '.vite-hooks/_/pre-commit'))).toBe(false) - }) - - it('writes into a core.hooksPath set by hand as it is, even one holding an `h` script', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/app/package.json': '{}\n', - '.githooks/h': 'echo "help"\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'config', 'core.hooksPath', '.githooks') - const hook = join(dir, '.githooks/pre-commit') - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') - - await run(join(dir, 'packages/app'), ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1\n`, - ) - expect(existsSync(join(dir, '.git/hooks/pre-commit'))).toBe(false) - expect(existsSync(join(dir, 'pre-commit'))).toBe(false) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0o111) - } - }) - - it('leaves the core.hooksPath of the global git config alone, since every repository runs it', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const shared = fixture({ 'pre-commit': '#!/bin/sh\necho "scanning for secrets"\n' }, []) - gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - `✘ pre-commit ${join(shared, 'pre-commit')} not written, core.hooksPath is set in the global git config, so every repository runs it\n`, - ) - expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe( - '#!/bin/sh\necho "scanning for secrets"\n', - ) - - gitIn(dir, 'config', 'core.hooksPath', '.githooks') - - const local = await run(dir, ['prepare', '--pre-commit']) - - expect(local.stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') - }) - - it.skipIf(process.platform === 'win32')( - 'leaves a global core.hooksPath alone with a git too old to say where a setting comes from', - async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const shared = fixture({ 'pre-commit': '#!/bin/sh\n' }, []) - const included = join(fixture({ 'work.gitconfig': '' }, []), 'work.gitconfig') - gitIn(dir, 'config', '--file', globalConfig, 'include.path', included) - gitIn(dir, 'config', '--file', included, 'core.hooksPath', shared) - const git = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() - const bin = fixture( - { - git: `#!/bin/sh\ncase "$*" in *--show-scope*) echo "error: unknown option" >&2; exit 129;; esac\nexec "${git}" "$@"\n`, - }, - [], - ) - chmodSync(join(bin, 'git'), 0o755) - vi.stubEnv('PATH', `${bin}${delimiter}${process.env.PATH}`) - - const { stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(stdout).toContain('not written, core.hooksPath is set in the global git config') - expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe('#!/bin/sh\n') - }, - ) -}) - -describe('uncheck presets', { timeout: 120_000 }, () => { - it('lints with the middleapi oxlint preset and formats with the middleapi oxfmt preset', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintPreset, - '.oxfmtrc.json': oxfmtPreset, - 'src/index.ts': - 'import { a } from "./lib";\nimport { b } from "./lib";\nconsole.log(a, b)\nexport const enum Level { Low }\n', - 'src/lib.ts': 'export const a = 1\nexport const b = 2\n', - 'src/warn.ts': - "import { a } from './lib'\nimport { b } from './lib'\n\nexport function pause() {\n debugger\n return a + b\n}\n", - 'src/bugs.ts': [ - 'export function parse() {', - ' try { JSON.parse("x") }', - ' catch (error) { throw new Error("parse failed") }', - '}', - 'export function assign(a: number, b: number) { a -= a - b; return a }', - 'export function collect(xs: number[]) { let out: number[] = []; for (const x of xs) { out = [...out, x] } return out }', - 'export function confuse(a: string | null, b: string) { return a! == b }', - 'export class Recurse { get v(): number { return this.v } }', - 'export function fill() { return new Array(3).fill([]) }', - 'export function negate(a: boolean, b: boolean) { return !a === b }', - 'export class Construct { constructor() { return { x: 1 } } }', - '', - ].join('\n'), - }) - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'failed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'skipped', reason: 'no tsconfig.json found' }, - ]) - expect(check.stdout).toContain('eslint(no-console)') - expect(check.stdout).toContain('import(no-duplicates)') - expect(check.stdout).toContain('oxc(no-const-enum)') - - for (const rule of [ - 'eslint(preserve-caught-error)', - 'oxc(misrefactored-assign-op)', - 'oxc(no-accumulating-spread)', - 'typescript(no-confusing-non-null-assertion)', - 'unicorn(no-accessor-recursion)', - 'unicorn(no-array-fill-with-reference-type)', - 'unicorn(no-negation-in-equality-check)', - 'eslint(no-constructor-return)', - ]) { - expect(check.stdout).toContain(rule) - } - - // `warn` rules, a default one and `import/no-duplicates`, report without failing the run; the - // output format depends on the environment (a terminal, CI or an agent), so only names are matched - const warned = await run(dir, ['--only=oxlint', 'src/warn.ts']) - - expect(warned.result).toBe('ok') - expect(warned.stdout).toContain('eslint(no-debugger)') - expect(warned.stdout).toContain('import(no-duplicates)') - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBeInstanceOf(CheckFailed) - expect(fix.stdout).toContain('eslint(no-console)') - expect(fix.stdout).not.toContain('import(no-duplicates)') - expect(fix.stdout).not.toContain('oxc(no-const-enum)') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - "import { a, b } from './lib'\nconsole.log(a, b)\nexport enum Level {\n Low,\n}\n", - ) - }) - - it('type checks with the middleapi tsconfig presets', async () => { - const dir = fixture( - { - 'tsconfig.json': { extends: 'uncheck/tsconfig/middleapi/lib', include: ['src'] }, - 'src/index.ts': [ - 'export function identity(value) {', - ' return value', - '}', - 'export function first(items: string[]): string {', - ' return items[0]', - '}', - '', - ].join('\n'), - 'src/use.ts': - "import { first } from './index.ts'\n\nexport const name: string = first(['a'])\n", - }, - ['typescript'], - ) - symlinkSync(fileURLToPath(new URL('..', import.meta.url)), join(dir, 'node_modules', 'uncheck')) - - const check = await run(dir, ['--only=tsc', 'src/index.ts']) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect(check.stdout).toContain('▶ tsc -p tsconfig.json --noEmit') - // `strict` and `noUncheckedIndexedAccess` come from the base preset, through the lib one - expect(check.stdout).toContain('error TS7006') - expect(check.stdout).toContain('error TS2322') - // Node runs TypeScript only through imports that name the .ts file. - expect(check.stdout).not.toContain('TS5097') - }) -}) - -describe('uncheck cli', { timeout: 120_000 }, () => { - it.skipIf(process.platform === 'win32')( - 'prints a file system failure as an error and has only its own global flags', - () => { - const dir = fixture({ '.claude': '' }, []) - const spawnCli = (...args: string[]) => - spawnSync(process.execPath, [cliBin, ...args], { cwd: dir, encoding: 'utf8' }) - - const install = spawnCli('hooks', 'install', 'claude') - - expect(install.status).toBe(1) - expect(install.stdout).toBe('') - expect(install.stderr).toContain(join(realpathSync(dir), '.claude', 'settings.json')) - - for (const flag of ['--log-level', '--wizard']) { - const refused = spawnCli(flag) - - expect(refused.status).toBe(1) - expect(stripVTControlCharacters(refused.stderr)).toContain(`Unrecognized flag: ${flag}`) - } - }, - ) -}) diff --git a/packages/uncheck/tests/files.test.ts b/packages/uncheck/tests/files.test.ts deleted file mode 100644 index 4154527..0000000 --- a/packages/uncheck/tests/files.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' -import process from 'node:process' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { existingFiles, listProjectFiles, resolvePaths } from '../src/files' -import { fixture } from './fixture' - -const project = { - '.gitignore': 'node_modules\ndist\n', - 'src/a.ts': '', - 'src/b.ts': '', - 'src/sub/c.ts': '', - 'docs/readme.md': '', - 'app/[id].ts': '', - 'dist/out.js': '', -} - -function resolve(dir: string, paths: string[]) { - return Effect.runPromise( - Effect.provide( - Effect.flatMap(Effect.cached(listProjectFiles(dir)), (projectFiles) => - resolvePaths(paths, dir, projectFiles), - ), - NodeServices.layer, - ), - ) -} - -describe('resolvePaths', () => { - it('turns files, directories, globs and negations into one file list', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['src/a.ts'])).toEqual({ files: ['src/a.ts'], unmatched: [] }) - expect(await resolve(dir, ['src'])).toEqual({ - files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['./src/', 'src/a.ts'])).toEqual({ - files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['src/**/*.ts', '!src/sub'])).toEqual({ - files: ['src/a.ts', 'src/b.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['**/*.md'])).toEqual({ files: ['docs/readme.md'], unmatched: [] }) - // An existing path is not read as a glob, so route files with brackets can be named. - expect(await resolve(dir, ['app/[id].ts'])).toEqual({ files: ['app/[id].ts'], unmatched: [] }) - expect(await resolve(dir, ['.'])).toEqual({ - files: [ - '.gitignore', - '.prettierignore', - 'app/[id].ts', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ], - unmatched: [], - }) - }) - - it('reports patterns that match nothing and keeps ignored files out unless named', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['nope.ts', 'src/**/*.tsx', 'dist'])).toEqual({ - files: [], - unmatched: ['nope.ts', 'src/**/*.tsx', 'dist'], - }) - expect(await resolve(dir, ['src/a.ts', 'missing/'])).toEqual({ - files: ['src/a.ts'], - unmatched: ['missing/'], - }) - // An explicitly named file counts even when git ignores it. - expect(await resolve(dir, ['dist/out.js'])).toEqual({ files: ['dist/out.js'], unmatched: [] }) - }) - - it('walks the tree outside a git repository', async () => { - const dir = fixture(project, []) - - expect(await resolve(dir, ['src', 'dist/**'])).toEqual({ - files: ['dist/out.js', 'src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - }) - - it('walks past folders it cannot read and never into linked folders', async () => { - const dir = fixture(project, []) - symlinkSync('.', join(dir, 'loop')) - symlinkSync('..', join(dir, 'src/up')) - const locked = join(dir, 'locked') - mkdirSync(locked) - - if (process.platform !== 'win32') { - chmodSync(locked, 0) - } - - try { - expect(await resolve(dir, ['.'])).toEqual({ - files: [ - 'app/[id].ts', - 'dist/out.js', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ], - unmatched: [], - }) - } finally { - chmodSync(locked, 0o755) - } - }) - - it('lets globs and exclusions match dot files the way directories include them', async () => { - const dir = fixture( - { - ...project, - '.github/workflows/ci.yml': '', - '.vscode/settings.json': '', - 'package.json': '{}', - 'src/.env.ts': '', - }, - [], - ) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['**/*.yml'])).toEqual({ - files: ['.github/workflows/ci.yml'], - unmatched: [], - }) - expect(await resolve(dir, ['src/**'])).toEqual(await resolve(dir, ['src'])) - expect(await resolve(dir, ['src/{.,sub}/*.ts'])).toEqual(await resolve(dir, ['src'])) - expect((await resolve(dir, ['.', '!**/*.json'])).files).not.toContain('.vscode/settings.json') - }) - - it('reads [!x] as any character but x and parentheses literally, as in route groups', async () => { - const dir = fixture( - { ...project, 'app/(marketing)/page.ts': '', 'app/marketing/page.ts': '' }, - [], - ) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['src/[!a]*.ts'])).toEqual({ files: ['src/b.ts'], unmatched: [] }) - expect(await resolve(dir, ['src', '!src/[!a]*.ts'])).toEqual({ - files: ['src/a.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['app/(marketing)/**'])).toEqual({ - files: ['app/(marketing)/page.ts'], - unmatched: [], - }) - expect((await resolve(dir, ['app', '!app/(marketing)/**'])).files).toEqual([ - 'app/[id].ts', - 'app/marketing/page.ts', - ]) - }) - - it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { - const store = fixture({ 'dep/index.js': '' }, []) - const dir = fixture({ ...project, '.gitignore': 'node_modules/\ndist/\n' }, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - symlinkSync(store, join(dir, 'node_modules')) - symlinkSync(store, join(dir, 'src/node_modules')) - - expect((await resolve(dir, ['.', 'src'])).files).toEqual([ - '.gitignore', - '.prettierignore', - 'app/[id].ts', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ]) - }) - - it('excludes from everything when only exclusions are given', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['!src/sub', '!.*'])).toEqual({ - files: ['app/[id].ts', 'docs/readme.md', 'src/a.ts', 'src/b.ts'], - unmatched: [], - }) - }) -}) - -describe('existingFiles', () => { - it('takes file names as they are and drops the ones that are gone', async () => { - const dir = fixture({ ...project, '!notes.ts': '', '-draft.ts': '' }, []) - - expect( - await Effect.runPromise( - Effect.provide( - existingFiles(['!notes.ts', '-draft.ts', 'app/[id].ts', 'app/i.ts', 'src'], dir), - NodeServices.layer, - ), - ), - ).toEqual(['!notes.ts', '-draft.ts', 'app/[id].ts']) - }) -}) diff --git a/packages/uncheck/tests/fixture.ts b/packages/uncheck/tests/fixture.ts deleted file mode 100644 index cb12389..0000000 --- a/packages/uncheck/tests/fixture.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -export const TOOLS = ['sherif', 'oxlint', 'oxfmt', 'typescript'] as const - -const packageNodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) -const fixtures: string[] = [] - -afterAll(() => { - for (const dir of fixtures) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -/** - * Creates a throwaway project in the OS temp dir. Objects are written as JSON, the requested tools - * are symlinked into `node_modules` so they resolve like a regular install, and config files are - * kept out of the format check so tests only see the formatting issues they put in. - */ -export function fixture( - files: Record, - tools: ReadonlyArray<(typeof TOOLS)[number]> = TOOLS, -): string { - const dir = mkdtempSync(join(tmpdir(), 'uncheck-')) - fixtures.push(dir) - - if (tools.length > 0) { - mkdirSync(join(dir, 'node_modules')) - - for (const tool of tools) { - symlinkSync(realpathSync(join(packageNodeModules, tool)), join(dir, 'node_modules', tool)) - } - } - - const defaults = { - '.gitignore': 'node_modules\ndist\n', - '.prettierignore': 'tsconfig.json\n.oxlintrc.json\n', - } - - for (const [relative, content] of Object.entries({ ...defaults, ...files })) { - // The tools are symlinks into the shared pnpm store, so writing below one corrupts every install. - if (tools.some((tool) => relative.startsWith(`node_modules/${tool}/`))) { - throw new Error(`${relative} would be written into the installed ${relative.split('/')[1]}`) - } - - mkdirSync(dirname(join(dir, relative)), { recursive: true }) - writeFileSync( - join(dir, relative), - typeof content === 'string' ? content : JSON.stringify(content), - ) - } - - return dir -} diff --git a/packages/uncheck/tests/tool.test.ts b/packages/uncheck/tests/tool.test.ts deleted file mode 100644 index 09f5797..0000000 --- a/packages/uncheck/tests/tool.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import Module from 'node:module' -import { join } from 'node:path' -import process from 'node:process' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { argvBatches, resolveBin } from '../src/tool' -import { fixture } from './fixture' - -describe('argvBatches', () => { - it('splits long file lists into batches every platform can spawn, keeping their order', () => { - const files = Array.from({ length: 3000 }, (_, index) => `packages/app/src/feature ${index}.ts`) - const batches = argvBatches(files) - - expect(batches.length).toBeGreaterThan(1) - expect(batches.flat()).toEqual(files) - - for (const batch of batches) { - const length = batch.reduce((total, file) => total + file.length + 3, 0) - - expect(length).toBeLessThanOrEqual(process.platform === 'win32' ? 30_000 : 65_536) - } - }) - - it('keeps a short list in one batch', () => { - expect(argvBatches(['a.ts', 'b.ts'])).toEqual([['a.ts', 'b.ts']]) - expect(argvBatches([])).toEqual([[]]) - }) -}) - -describe('resolveBin', () => { - it('asks the Yarn PnP resolver of each folder up the tree, then its node_modules', async () => { - const dir = fixture( - { - 'package.json': '{}', - 'packages/app/package.json': '{}', - 'packages/app/node_modules/oxfmt/package.json': { bin: 'oxfmt.js' }, - '.yarn/oxlint/package.json': { bin: { oxlint: 'bin.js' } }, - }, - [], - ) - const loader = Module as unknown as { - _resolveFilename: ( - request: string, - parent: { filename: string }, - ...rest: unknown[] - ) => string - } - const resolveFilename = loader._resolveFilename - - loader._resolveFilename = (request, parent, ...rest) => - request === 'oxlint/package.json' && parent.filename === join(dir, 'package.json') - ? join(dir, '.yarn/oxlint/package.json') - : Reflect.apply(resolveFilename, Module, [request, parent, ...rest]) - process.versions.pnp = '3' - - const resolve = (pkg: string) => - Effect.runPromise( - resolveBin(pkg, join(dir, 'packages/app')).pipe(Effect.provide(NodeServices.layer)), - ) - - try { - expect(await resolve('oxlint')).toEqual({ - name: 'oxlint', - entry: join(dir, '.yarn/oxlint/bin.js'), - }) - expect(await resolve('oxfmt')).toEqual({ - name: 'oxfmt', - entry: join(dir, 'packages/app/node_modules/oxfmt/oxfmt.js'), - }) - expect(await resolve('sherif')).toBeUndefined() - } finally { - loader._resolveFilename = resolveFilename - delete process.versions.pnp - } - }) -}) diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts deleted file mode 100644 index f062178..0000000 --- a/packages/uncheck/tests/tsc.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { mkdirSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { tsc } from '../src/checks/tsc' -import { listProjectFiles } from '../src/files' -import { fixture } from './fixture' - -/** Plans the tsc check in `dir`: the tsc command lines it would run, or the reason it does not run. */ -function plan(dir: string, files?: string[]): Promise { - return Effect.runPromise( - Effect.provide( - tsc.plan({ cwd: dir, fix: false, files, projectFiles: listProjectFiles(dir) }).pipe( - Effect.map((commands) => commands.map((command) => command.args.join(' '))), - Effect.catchTag('NothingToCheck', (error) => Effect.succeed(error.reason)), - Effect.catchTag('CannotCheck', (error) => Effect.succeed(error.reason)), - ), - NodeServices.layer, - ), - ) -} - -const NOT_COVERED = 'no tsconfig.json covers the given files' -// oxlint-disable-next-line no-template-curly-in-string -const CONFIG_DIR = '${configDir}' - -describe('tsc project references', () => { - it('checks standalone projects with tsc -p', async () => { - const dir = fixture({ 'tsconfig.json': {}, 'packages/a/tsconfig.json': {} }) - - expect(await plan(dir)).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - '-p tsconfig.json --noEmit', - ]) - }) - - it('builds only the roots of the reference graph and leaves the rest to tsc -b', async () => { - const dir = fixture({ - 'tsconfig.json': {}, - 'packages/shared/tsconfig.json': {}, - 'packages/client/tsconfig.json': { references: [{ path: '../shared' }] }, - 'packages/server/tsconfig.json': { - references: [{ path: '../client' }, { path: '../shared' }], - }, - 'packages/nest/tsconfig.json': { references: [{ path: '../server/tsconfig.json' }] }, - }) - - expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json --noEmit']) - }) - - it('builds a solution-style root that references configs not named tsconfig.json', async () => { - const dir = fixture({ - 'tsconfig.json': { - references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], - }, - 'tsconfig.app.json': {}, - 'tsconfig.node.json': {}, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - }) - - it('keeps a root with references in build mode even when it is not composite', async () => { - const dir = fixture({ - 'tsconfig.json': { references: [{ path: './packages/a' }, { path: './packages/b' }] }, - 'packages/a/tsconfig.json': {}, - 'packages/b/tsconfig.json': { references: [{ path: '../a' }] }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - }) - - it('handles diamonds and multiple roots', async () => { - const dir = fixture({ - 'a/tsconfig.json': {}, - 'b/tsconfig.json': { references: [{ path: '../a' }] }, - 'c/tsconfig.json': { references: [{ path: '../a' }] }, - 'd/tsconfig.json': { references: [{ path: '../b' }, { path: '../c' }] }, - 'e/tsconfig.json': { references: [{ path: '../a' }] }, - }) - - expect(await plan(dir)).toEqual(['-b d/tsconfig.json e/tsconfig.json']) - }) - - it('fails on circular references instead of silently skipping them', async () => { - const dir = fixture({ - 'a/tsconfig.json': { references: [{ path: '../b' }] }, - 'b/tsconfig.json': { references: [{ path: '../a' }] }, - 'c/tsconfig.json': {}, - }) - - const cycle = 'circular project references between a/tsconfig.json, b/tsconfig.json' - - expect(await plan(dir)).toBe(cycle) - expect(await plan(dir, ['a/src/index.ts'])).toBe(cycle) - expect(await plan(dir, ['c/src/index.ts'])).toBe(cycle) - }) - - it('reports only the cyclic part when a root also exists', async () => { - const dir = fixture({ - 'a/tsconfig.json': { references: [{ path: '../b' }] }, - 'b/tsconfig.json': { references: [{ path: '../c' }] }, - 'c/tsconfig.json': { references: [{ path: '../b' }] }, - }) - - expect(await plan(dir)).toBe( - 'circular project references between b/tsconfig.json, c/tsconfig.json', - ) - }) - - it('follows references as written, leaving the configDir token alone like tsc', async () => { - const dir = fixture({ - 'app/tsconfig.json': { references: [{ path: `${CONFIG_DIR}/../lib` }] }, - 'app/lib/tsconfig.json': {}, - 'lib/tsconfig.json': {}, - }) - - expect(await plan(dir)).toEqual(['-b app/tsconfig.json', '-p lib/tsconfig.json --noEmit']) - }) - - it('reads backslashes in tsconfig paths as separators, like tsc', async () => { - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { allowJs: true }, include: [`${CONFIG_DIR}/src`] }, - 'tsconfig.json': { files: [], references: [{ path: '.\\packages\\b' }] }, - 'packages/a/tsconfig.json': { extends: '..\\..\\tsconfig.base.json' }, - 'packages/b/tsconfig.json': { - extends: '..\\..\\tsconfig.base.json', - references: [{ path: '..\\a' }], - }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/a/scripts/build.ts'])).toBe(NOT_COVERED) - - const project = fixture({ 'a/tsconfig.json': { include: ['.\\src', '..\\shared'] } }) - - expect(await plan(project, ['a/src/x.ts', 'shared/y.ts'])).toEqual([ - '-p a/tsconfig.json --noEmit', - ]) - }) -}) - -describe('tsc project selection', () => { - it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { - const dir = fixture({ - 'tsconfig.json': { - include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], - exclude: ['src/legacy'], - }, - }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('src/index.ts')).toBe(true) - expect(await covers('src/deep/nested/component.tsx')).toBe(true) - expect(await covers('src/types.d.ts')).toBe(true) - expect(await covers('src/index.js')).toBe(false) - expect(await covers('src/README.md')).toBe(false) - expect(await covers('src/.hidden/index.ts')).toBe(false) - expect(await covers('src/node_modules/dep/index.ts')).toBe(false) - expect(await covers('src/legacy/old.ts')).toBe(false) - expect(await covers('scripts/build.ts')).toBe(true) - expect(await covers('scripts/nested/deep/build.ts')).toBe(false) - expect(await covers('config/app.json')).toBe(true) - expect(await covers('src/data.json')).toBe(false) - expect(await covers('packages/app/build.config.ts')).toBe(true) - expect(await covers('packages/app/src/index.ts')).toBe(false) - expect(await covers('node_modules/dep/index.ts')).toBe(false) - }) - - it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { - const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('index.ts')).toBe(true) - expect(await covers('deep/index.ts')).toBe(true) - expect(await covers('dist/index.d.ts')).toBe(false) - expect(await covers('node_modules/dep/index.d.ts')).toBe(false) - expect(await covers('.git/hooks/pre-commit.ts')).toBe(false) - }) - - it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { - const dir = fixture({ - 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, - 'node_modules/@shared/tsconfig/base.json': { - compilerOptions: { allowJs: true }, - // oxlint-disable-next-line no-template-curly-in-string - include: ['${configDir}/lib'], - // oxlint-disable-next-line no-template-curly-in-string - exclude: ['${configDir}/lib/vendor'], - }, - 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, - 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, - 'packages/a/tsconfig.files.json': { - files: ['entry.ts'], - compilerOptions: { allowJs: false }, - }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, - }) - const a = ['-p packages/a/tsconfig.json --noEmit'] - const b = ['-p packages/b/tsconfig.json --noEmit'] - - // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. - expect(await plan(dir, ['packages/a/entry.ts'])).toEqual(a) - expect(await plan(dir, ['src/index.ts'])).toEqual(a) - expect(await plan(dir, ['packages/a/src/index.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['src/index.js'])).toBe(NOT_COVERED) - - // The base's `${configDir}` include and exclude point at the leaf config's folder. - expect(await plan(dir, ['packages/b/lib/util.ts'])).toEqual(b) - expect(await plan(dir, ['packages/b/lib/util.js'])).toEqual(b) - expect(await plan(dir, ['packages/b/lib/vendor/x.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['packages/b/src/index.ts'])).toBe(NOT_COVERED) - }) - - it('resolves extends through the exports of a package like tsc, and nothing they leave out', async () => { - const js = { compilerOptions: { allowJs: true } } - const dir = fixture({ - 'node_modules/@shared/tsconfig/package.json': { - exports: { - './base': './presets/base.json', - './lib': { import: './missing.json', require: './presets/lib.json' }, - './extra/*': './presets/extra/*.json', - }, - }, - 'node_modules/@shared/tsconfig/presets/base.json': js, - 'node_modules/@shared/tsconfig/presets/lib.json': js, - 'node_modules/@shared/tsconfig/presets/extra/web.json': js, - 'packages/a/tsconfig.json': { extends: '@shared/tsconfig/base', include: ['src'] }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig/lib', include: ['src'] }, - 'packages/c/tsconfig.json': { extends: '@shared/tsconfig/extra/web', include: ['src'] }, - 'packages/d/tsconfig.json': { - extends: '@shared/tsconfig/presets/base.json', - include: ['src'], - }, - }) - - // `allowJs` from the preset puts `.js` files in the project, so it was resolved. - expect(await plan(dir, ['packages/a/src/index.js'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - ]) - // `import` is not a condition tsc uses for `extends`, so `require` is picked instead. - expect(await plan(dir, ['packages/b/src/index.js'])).toEqual([ - '-p packages/b/tsconfig.json --noEmit', - ]) - expect(await plan(dir, ['packages/c/src/index.js'])).toEqual([ - '-p packages/c/tsconfig.json --noEmit', - ]) - // The file exists, but the package does not export it. - expect(await plan(dir, ['packages/d/src/index.js'])).toBe(NOT_COVERED) - }) - - it('selects only the projects whose inputs contain a given file', async () => { - const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': { include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'] }, - 'packages/a/tsconfig.json': lib, - 'packages/b/tsconfig.json': lib, - }) - - expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - ]) - // Tests are excluded by the package and picked up by the root instead. - expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - '-p packages/b/tsconfig.json --noEmit', - ]) - expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED) - }) - - it('selects the configs a solution-style root references, whatever their name, through the root', async () => { - const dir = fixture({ - 'tsconfig.json': { - files: [], - references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], - }, - 'tsconfig.app.json': { include: ['src'] }, - 'tsconfig.node.json': { include: ['vite.config.ts'] }, - }) - - expect(await plan(dir, ['src/main.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['vite.config.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['tsconfig.app.json'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['scripts/release.ts'])).toBe(NOT_COVERED) - }) - - it('never selects a referenced config that does not exist, leaving the reference to tsc -b', async () => { - const dir = fixture({ - 'tsconfig.json': { include: ['src'], references: [{ path: './packages/gone' }] }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['src/index.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/gone/index.ts'])).toBe(NOT_COVERED) - }) - - it('builds every root that depends on a selected project, and no other', async () => { - const dir = fixture({ - 'lib/tsconfig.json': {}, - 'app/tsconfig.json': { references: [{ path: '../lib' }] }, - 'web/tsconfig.json': { references: [{ path: '../lib' }] }, - 'core/tsconfig.json': {}, - 'cli/tsconfig.json': { references: [{ path: '../core' }] }, - 'solo/tsconfig.json': {}, - }) - - expect(await plan(dir, ['lib/src/index.ts'])).toEqual([ - '-b app/tsconfig.json web/tsconfig.json', - ]) - expect(await plan(dir, ['app/src/index.ts'])).toEqual(['-b app/tsconfig.json']) - expect(await plan(dir, ['core/src/index.ts', 'solo/src/index.ts'])).toEqual([ - '-b cli/tsconfig.json', - '-p solo/tsconfig.json --noEmit', - ]) - }) - - it('selects the projects a changed config applies to, also through a workspace package linked into node_modules', async () => { - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': { include: ['scripts'] }, - 'packages/config/package.json': { name: '@repo/config' }, - 'packages/config/strict.json': { compilerOptions: { noUncheckedIndexedAccess: true } }, - 'packages/a/tsconfig.json': { extends: '../../tsconfig.base.json', include: ['src'] }, - 'packages/b/tsconfig.json': { - extends: ['../../tsconfig.base.json', '@repo/config/strict.json'], - include: ['src'], - }, - }) - mkdirSync(join(dir, 'node_modules/@repo')) - symlinkSync(join(dir, 'packages/config'), join(dir, 'node_modules/@repo/config')) - const a = '-p packages/a/tsconfig.json --noEmit' - const b = '-p packages/b/tsconfig.json --noEmit' - - expect(await plan(dir, ['tsconfig.base.json'])).toEqual([a, b]) - expect(await plan(dir, ['packages/config/strict.json'])).toEqual([b]) - expect(await plan(dir, ['packages/a/tsconfig.json'])).toEqual([a]) - expect(await plan(dir, ['tsconfig.json'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/config/package.json'])).toBe(NOT_COVERED) - }) - - it('applies a base reached through two extends branches in both, like tsc, and stops at circular extends', async () => { - const dir = fixture({ - 'tsconfig.base.json': { include: [`${CONFIG_DIR}/lib`] }, - 'tsconfig.b.json': { extends: './tsconfig.base.json', include: [`${CONFIG_DIR}/src`] }, - 'tsconfig.c.json': { extends: './tsconfig.base.json' }, - 'pkg/tsconfig.json': { extends: ['../tsconfig.b.json', '../tsconfig.c.json'] }, - 'loop/tsconfig.json': { extends: './tsconfig.other.json' }, - 'loop/tsconfig.other.json': { extends: './tsconfig.json', include: ['src'] }, - }) - - expect(await plan(dir, ['pkg/lib/index.ts'])).toEqual(['-p pkg/tsconfig.json --noEmit']) - expect(await plan(dir, ['pkg/src/index.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['loop/src/index.ts'])).toEqual(['-p loop/tsconfig.json --noEmit']) - }) - - it('takes the inputs tsc takes: no declarationDir, minified scripts or upper-case extensions, but an included node_modules folder', async () => { - const dir = fixture({ - 'tsconfig.json': { - compilerOptions: { allowJs: true, declarationDir: 'src/types' }, - include: ['src', 'vendor/*', 'plugins/jquery*', 'node_modules/x'], - }, - }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('src/index.ts')).toBe(true) - expect(await covers('src/types/index.d.ts')).toBe(false) - expect(await covers('vendor/jquery.js')).toBe(true) - expect(await covers('vendor/jquery.min.js')).toBe(false) - expect(await covers('vendor/jquery.min.jsx')).toBe(true) - expect(await covers('plugins/jquery.ui.js')).toBe(true) - expect(await covers('plugins/jquery.min.js')).toBe(false) - expect(await covers('src/UPPER.TS')).toBe(false) - expect(await covers('node_modules/x/index.ts')).toBe(true) - expect(await covers('node_modules/y/index.ts')).toBe(false) - }) -}) diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts new file mode 100644 index 0000000..b9587e8 --- /dev/null +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -0,0 +1,13 @@ +import { LAYOUTS, report } from '../utils/project' + +describe.each(LAYOUTS)('uncheck in a $name', ({ create }) => { + it('passes a clean project', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck() + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toMatch(/^✔ all checks passed/) + }) +}) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts new file mode 100644 index 0000000..372c6f5 --- /dev/null +++ b/packages/uncheck/tests/utils/project.ts @@ -0,0 +1,447 @@ +import { execFileSync, spawn } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, delimiter, dirname, join, sep } from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { stripVTControlCharacters } from 'node:util' + +export const TOOLS = ['sherif', 'oxlint', 'oxfmt', 'typescript'] as const + +export type Tool = (typeof TOOLS)[number] + +export type Files = Readonly> + +export type Env = Readonly> + +export interface ProjectOptions { + readonly tools?: ReadonlyArray + readonly git?: 'none' | 'init' | 'commit' +} + +export interface RunOptions { + /** Relative to the project. */ + readonly cwd?: string + readonly input?: string + readonly env?: Env +} + +export interface TerminalOptions { + readonly cwd?: string + readonly env?: Env + /** Typed one at a time once the output shows `waitFor`. */ + readonly keys?: ReadonlyArray + readonly waitFor?: string +} + +export interface Run { + readonly exitCode: number | null + readonly signal: NodeJS.Signals | null + readonly stdout: string + readonly stderr: string +} + +const PACKAGE = fileURLToPath(new URL('../..', import.meta.url)) +const REGISTER = fileURLToPath(new URL('register.ts', import.meta.url)) +export const CLI = [process.execPath, '--import', REGISTER, join(PACKAGE, 'src/bin.ts')] as const + +const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'uncheck-e2e-'))) +const GIT_CONFIG = join(ROOT, 'gitconfig') +const SHIMS = join(ROOT, 'bin') + +writeFileSync(GIT_CONFIG, '') +mkdirSync(SHIMS) +writeFileSync( + join(SHIMS, 'pnpm'), + [ + '#!/bin/sh', + '[ "$1" = exec ] && shift', + 'dir=$PWD', + 'until [ -x "$dir/node_modules/.bin/$1" ] || [ "$dir" = / ]; do dir=$(dirname "$dir"); done', + 'bin=$1', + 'shift', + 'exec "$dir/node_modules/.bin/$bin" "$@"', + '', + ].join('\n'), + { mode: 0o755 }, +) + +afterAll(() => { + rmSync(ROOT, { recursive: true, force: true }) +}) + +// A stray path would run git or the CLI in the repository these tests live in. +function inside(path: string): string { + if (!path.startsWith(`${ROOT}${sep}`)) { + throw new Error(`${path} is outside ${ROOT}`) + } + + return path +} + +export function temporaryDirectory(): string { + return mkdtempSync(join(ROOT, 'tmp-')) +} + +/** The environment of every process the tests start, free of whatever the machine or a git hook set. */ +export function environment(overrides: Env = {}): NodeJS.ProcessEnv { + const inherited = Object.entries(process.env).filter( + ([key]) => !/^(?:GIT_\w+|CI|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS)$/.test(key), + ) + + return { + ...Object.fromEntries(inherited), + PATH: `${SHIMS}${delimiter}${process.env.PATH}`, + GIT_CONFIG_GLOBAL: GIT_CONFIG, + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'uncheck', + GIT_AUTHOR_EMAIL: 'uncheck@example.com', + GIT_COMMITTER_NAME: 'uncheck', + GIT_COMMITTER_EMAIL: 'uncheck@example.com', + // Functions deserialized from the compile cache report imprecise coverage. + NODE_DISABLE_COMPILE_CACHE: '1', + ...overrides, + } +} + +export function git(cwd: string, args: ReadonlyArray, env?: Env): string { + return execFileSync('git', args, { + cwd: inside(cwd), + env: environment(env), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +export function run( + command: ReadonlyArray, + { cwd, input = '', env }: Omit & { readonly cwd: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command[0]!, command.slice(1), { + cwd: inside(cwd), + env: environment(env), + }) + let stdout = '' + let stderr = '' + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + child.stdin.end(input) + }) +} + +function shellQuote(arg: string): string { + return `'${arg.replaceAll("'", `'\\''`)}'` +} + +/** Runs `command` in a pseudo-terminal, which merges stdout and stderr into `stdout`. */ +export function runInTerminal( + command: ReadonlyArray, + { cwd, env, keys = [], waitFor }: Omit & { readonly cwd: string }, +): Promise { + const args = + process.platform === 'darwin' + ? ['-q', '/dev/null', ...command] + : ['-qfec', command.map(shellQuote).join(' '), '/dev/null'] + + return new Promise((resolve, reject) => { + const child = spawn('script', args, { cwd: inside(cwd), env: environment(env) }) + let stdout = '' + let typing = false + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + + if (!typing && waitFor !== undefined && stripVTControlCharacters(stdout).includes(waitFor)) { + typing = true + keys.forEach((key, index) => setTimeout(() => child.stdin.write(key), 100 * (index + 1))) + } + }) + child.on('error', reject) + child.on('close', (exitCode, signal) => + resolve({ + exitCode, + signal, + stdout: stripVTControlCharacters(stdout).replaceAll('\r\n', '\n'), + stderr: '', + }), + ) + }) +} + +/** The lines uncheck itself prints, without the timings that differ between runs. */ +export function report(output: string): string[] { + return output + .split('\n') + .filter((line) => /^(?:uncheck (?:staged )?in |[▶✔✘○] | {2}rerun )/.test(line)) + .map((line) => line.replace(/ (?:\d+ms|\d+\.\ds)$/, '')) +} + +/** JSON the way oxfmt writes it, so the files a test adds pass the format check. */ +function json(value: object, file: string): string { + const text = JSON.stringify(value, null, 2) + + if (basename(file) === 'package.json') { + return `${text}\n` + } + + const primitive = String.raw`(?:"(?:[^"\\\n]|\\.)*"|-?[\d.e+-]+|true|false|null)` + const list = new RegExp(String.raw`\[\n\s*(${primitive}(?:,\n\s*${primitive})*)\n\s*\]`, 'g') + + return `${text.replace(list, (_, items: string) => `[${items.split(/,\n\s*/).join(', ')}]`)}\n` +} + +export class Project { + constructor(readonly dir: string) {} + + path(...segments: ReadonlyArray): string { + return join(this.dir, ...segments) + } + + write(files: Files): this { + for (const [file, content] of Object.entries(files)) { + const target = this.path(file) + + if (content === null) { + rmSync(target, { recursive: true, force: true }) + continue + } + + mkdirSync(dirname(target), { recursive: true }) + // Tools are linked from the shared pnpm store, which a write through the link would corrupt. + inside(realpathSync(existsSync(target) ? target : dirname(target))) + writeFileSync(target, typeof content === 'string' ? content : json(content, file)) + } + + return this + } + + read(file: string): string { + return readFileSync(this.path(file), 'utf8') + } + + exists(file: string): boolean { + return existsSync(this.path(file)) + } + + link(file: string, target: string): this { + mkdirSync(dirname(this.path(file)), { recursive: true }) + symlinkSync(target, this.path(file)) + + return this + } + + /** Installs a stand-in for `tool` that runs `script` with node, in place of the real one. */ + fake(tool: Tool, script: string): this { + const bin = tool === 'typescript' ? 'tsc' : tool + + return this.write({ + [`node_modules/${tool}`]: null, + [`node_modules/${tool}/package.json`]: { name: tool, bin: { [bin]: 'bin.js' } }, + [`node_modules/${tool}/bin.js`]: script, + }) + } + + git(...args: ReadonlyArray): string { + return git(this.dir, args) + } + + commit(message = 'change'): this { + this.git('add', '--all') + this.git('commit', '--quiet', '--no-verify', `--message=${message}`) + + return this + } + + uncheck(args: ReadonlyArray = [], options: RunOptions = {}): Promise { + return run([...CLI, ...args], { ...options, cwd: this.path(options.cwd ?? '.') }) + } + + uncheckInTerminal(args: ReadonlyArray, options: TerminalOptions = {}): Promise { + return runInTerminal([...CLI, ...args], { ...options, cwd: this.path(options.cwd ?? '.') }) + } +} + +function install(project: Project, tools: ReadonlyArray): void { + for (const tool of tools) { + project.link(`node_modules/${tool}`, realpathSync(join(PACKAGE, 'node_modules', tool))) + } + + const manifest = JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')) as { + exports: Record + } + + for (const target of Object.values(manifest.exports)) { + if (typeof target === 'object') { + const source = join(PACKAGE, target.default.replace('./dist/', 'src/').replace(/\.mjs$/, '.ts')) + + project.write({ + [`node_modules/uncheck/${target.default}`]: `export * from '${pathToFileURL(source)}'\n`, + }) + } + } + + project + .write({ + 'node_modules/uncheck/package.json': manifest, + 'node_modules/.bin/uncheck': `#!/bin/sh\nexec ${CLI.map(shellQuote).join(' ')} "$@"\n`, + }) + .link('node_modules/uncheck/tsconfig', join(PACKAGE, 'tsconfig')) + chmodSync(project.path('node_modules/.bin/uncheck'), 0o755) +} + +/** A project in a fresh directory, with the tools linked in as a package manager installs them. */ +export function project(files: Files = {}, { tools = TOOLS, git = 'commit' }: ProjectOptions = {}) { + const created = new Project(temporaryDirectory()) + + install(created, tools) + created.write({ '.gitignore': 'node_modules\ndist\n', ...files }) + + if (git !== 'none') { + created.git('init', '--quiet', '--initial-branch=main') + } + + if (git === 'commit') { + created.commit('init') + } + + return created +} + +const OXLINT_CONFIG = { rules: { 'no-var': 'error' } } + +function compilerOptions(extra: object = {}) { + return { + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + ...extra, + } +} + +const TOOL_VERSIONS = { + oxfmt: '^0.68.0', + oxlint: '^1.83.0', + sherif: '^1.13.0', + typescript: '^7.0.2', + uncheck: '^0.0.3', +} + +/** One package at the top of the repository. */ +export function singleRepo(files: Files = {}, options?: ProjectOptions): Project { + return project( + { + 'package.json': { + name: 'app', + version: '1.0.0', + private: true, + type: 'module', + devDependencies: TOOL_VERSIONS, + }, + 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', + '.oxlintrc.json': OXLINT_CONFIG, + 'tsconfig.json': { compilerOptions: compilerOptions({ noEmit: true }), include: ['src'] }, + 'src/index.ts': 'import { double } from "./utils";\n\nexport const answer: number = double(21);\n', + 'src/utils.ts': 'export function double(value: number): number {\n return value * 2;\n}\n', + ...files, + }, + options, + ) +} + +/** A pnpm workspace whose `app` package builds on its `core` package through project references. */ +export function monorepo(files: Files = {}, options?: ProjectOptions): Project { + const composite = compilerOptions({ + composite: true, + emitDeclarationOnly: true, + outDir: 'dist', + rootDir: 'src', + }) + + const created = project( + { + 'package.json': { + name: 'monorepo', + private: true, + type: 'module', + devDependencies: TOOL_VERSIONS, + packageManager: 'pnpm@10.0.0', + }, + 'pnpm-workspace.yaml': 'packages:\n - packages/*\n', + 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', + '.oxlintrc.json': OXLINT_CONFIG, + 'tsconfig.json': { + files: [], + references: [{ path: 'packages/core' }, { path: 'packages/app' }], + }, + 'packages/core/package.json': { + name: '@repo/core', + version: '1.0.0', + private: true, + type: 'module', + exports: { '.': { types: './dist/index.d.ts', default: './src/index.ts' } }, + }, + 'packages/core/tsconfig.json': { compilerOptions: composite, include: ['src'] }, + 'packages/core/src/index.ts': + 'export function double(value: number): number {\n return value * 2;\n}\n', + 'packages/app/package.json': { + name: '@repo/app', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/app/tsconfig.json': { + compilerOptions: composite, + references: [{ path: '../core' }], + include: ['src'], + }, + 'packages/app/src/index.ts': + 'import { double } from "@repo/core";\n\nexport const answer: number = double(21);\n', + ...files, + }, + { ...options, git: 'none' }, + ) + + created.link('packages/app/node_modules/@repo/core', '../../../core') + + if (options?.git !== 'none') { + created.git('init', '--quiet', '--initial-branch=main') + } + + if ((options?.git ?? 'commit') === 'commit') { + created.commit('init') + } + + return created +} + +export interface Layout { + readonly name: string + readonly create: (files?: Files, options?: ProjectOptions) => Project + /** Where the code of the package the tests work on lives: `''` or a folder ending in `/`. */ + readonly app: string +} + +export const LAYOUTS: ReadonlyArray = [ + { name: 'single repo', create: singleRepo, app: '' }, + { name: 'monorepo', create: monorepo, app: 'packages/app/' }, +] diff --git a/packages/uncheck/tests/utils/register.ts b/packages/uncheck/tests/utils/register.ts new file mode 100644 index 0000000..e670354 --- /dev/null +++ b/packages/uncheck/tests/utils/register.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { fileURLToPath } from 'node:url' + +// The sources import without extensions and read package.json without attributes, which only the +// bundler understands. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.(?:[cm]?[jt]s|json)$/.test(specifier)) { + for (const candidate of [`${specifier}.ts`, `${specifier}/index.ts`]) { + try { + return nextResolve(candidate, context) + } catch {} + } + } + + return nextResolve(specifier, context) + }, + load(url, context, nextLoad) { + if (url.endsWith('.json') && context.importAttributes.type === undefined) { + return { + format: 'module', + shortCircuit: true, + source: `export default ${readFileSync(fileURLToPath(url), 'utf8')}`, + } + } + + return nextLoad(url, context) + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0eb877c..6ace99b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,9 +7,12 @@ export default defineConfig(() => ({ globals: true, include: ['**/*.test.ts'], exclude: [...defaultExclude, '**/.claude/**'], + testTimeout: 120_000, coverage: { include: ['packages/*/src/**'], exclude: ['**.test-d.*', '**.test.*', '**/*.bench.ts'], + autoAttachSubprocess: true, + thresholds: { 100: true }, }, benchmark: { include: ['**/*.bench.ts'], From f5befbf0d24848434a237082428816c10e6b4bbb Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 30 Sep 2026 20:09:02 +0700 Subject: [PATCH 2/9] wip: format e2e test harness --- packages/uncheck/tests/utils/project.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 372c6f5..b31d8d1 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -290,7 +290,10 @@ function install(project: Project, tools: ReadonlyArray): void { for (const target of Object.values(manifest.exports)) { if (typeof target === 'object') { - const source = join(PACKAGE, target.default.replace('./dist/', 'src/').replace(/\.mjs$/, '.ts')) + const source = join( + PACKAGE, + target.default.replace('./dist/', 'src/').replace(/\.mjs$/, '.ts'), + ) project.write({ [`node_modules/uncheck/${target.default}`]: `export * from '${pathToFileURL(source)}'\n`, @@ -359,7 +362,8 @@ export function singleRepo(files: Files = {}, options?: ProjectOptions): Project 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', '.oxlintrc.json': OXLINT_CONFIG, 'tsconfig.json': { compilerOptions: compilerOptions({ noEmit: true }), include: ['src'] }, - 'src/index.ts': 'import { double } from "./utils";\n\nexport const answer: number = double(21);\n', + 'src/index.ts': + 'import { double } from "./utils";\n\nexport const answer: number = double(21);\n', 'src/utils.ts': 'export function double(value: number): number {\n return value * 2;\n}\n', ...files, }, From 133c680df9ae775cbe5c388194bb40813e0773ab Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 30 Sep 2026 21:20:18 +0700 Subject: [PATCH 3/9] wip: e2e suites per command --- packages/uncheck/src/checks/tsc.ts | 4 +- packages/uncheck/src/commands/hooks/run.ts | 9 +- packages/uncheck/src/commands/prepare.ts | 4 +- packages/uncheck/src/commands/staged.ts | 22 +- packages/uncheck/src/errors.ts | 2 +- .../tests/hooks/install/agents.test.ts | 145 ++++++ .../tests/hooks/install/existing.test.ts | 351 +++++++++++++ .../tests/hooks/install/location.test.ts | 148 ++++++ .../hooks/install/package-manager.test.ts | 80 +++ .../tests/hooks/install/prompt.test.ts | 63 +++ packages/uncheck/tests/hooks/install/utils.ts | 59 +++ .../uncheck/tests/hooks/run/changes.test.ts | 141 ++++++ .../uncheck/tests/hooks/run/families.test.ts | 109 ++++ .../uncheck/tests/hooks/run/installed.test.ts | 105 ++++ .../uncheck/tests/hooks/run/location.test.ts | 165 ++++++ .../uncheck/tests/hooks/run/payload.test.ts | 60 +++ .../uncheck/tests/hooks/run/selection.test.ts | 85 ++++ packages/uncheck/tests/hooks/run/utils.ts | 60 +++ packages/uncheck/tests/prepare/commit.test.ts | 110 ++++ .../tests/prepare/existing-hook.test.ts | 216 ++++++++ .../uncheck/tests/prepare/failures.test.ts | 178 +++++++ packages/uncheck/tests/prepare/hook.test.ts | 214 ++++++++ .../tests/prepare/linked-checkouts.test.ts | 75 +++ .../uncheck/tests/prepare/location.test.ts | 253 ++++++++++ .../tests/prepare/package-manager.test.ts | 102 ++++ packages/uncheck/tests/prepare/utils.ts | 100 ++++ packages/uncheck/tests/staged/basics.test.ts | 261 ++++++++++ packages/uncheck/tests/staged/commit.test.ts | 98 ++++ packages/uncheck/tests/staged/fix.test.ts | 256 ++++++++++ .../uncheck/tests/staged/interrupt.test.ts | 59 +++ .../uncheck/tests/staged/line-endings.test.ts | 94 ++++ .../uncheck/tests/staged/many-files.test.ts | 60 +++ packages/uncheck/tests/staged/merge.test.ts | 106 ++++ .../uncheck/tests/staged/precedence.test.ts | 96 ++++ .../tests/staged/sparse-checkout.test.ts | 64 +++ .../uncheck/tests/staged/unstaged.test.ts | 472 ++++++++++++++++++ packages/uncheck/tests/staged/utils.ts | 181 +++++++ packages/uncheck/tests/uncheck/cli.test.ts | 111 ++++ packages/uncheck/tests/uncheck/files.test.ts | 207 ++++++++ packages/uncheck/tests/uncheck/fix.test.ts | 220 ++++++++ .../uncheck/tests/uncheck/install.test.ts | 158 ++++++ packages/uncheck/tests/uncheck/output.test.ts | 237 +++++++++ .../tests/uncheck/paths-exclusions.test.ts | 133 +++++ .../uncheck/tests/uncheck/paths-git.test.ts | 132 +++++ .../uncheck/tests/uncheck/paths-globs.test.ts | 130 +++++ .../tests/uncheck/paths-selection.test.ts | 260 ++++++++++ .../tests/uncheck/paths-unmatched.test.ts | 64 +++ packages/uncheck/tests/uncheck/paths-utils.ts | 36 ++ .../uncheck/tests/uncheck/paths-walk.test.ts | 91 ++++ .../uncheck/tests/uncheck/presets.test.ts | 179 +++++++ .../uncheck/tests/uncheck/project.test.ts | 135 ++++- .../uncheck/tests/uncheck/selection.test.ts | 191 +++++++ packages/uncheck/tests/uncheck/sherif.test.ts | 152 ++++++ .../uncheck/tests/uncheck/tsc-extends.test.ts | 250 ++++++++++ .../uncheck/tests/uncheck/tsc-inputs.test.ts | 225 +++++++++ .../tests/uncheck/tsc-patterns.test.ts | 156 ++++++ .../tests/uncheck/tsc-references.test.ts | 206 ++++++++ packages/uncheck/tests/uncheck/tsc.test.ts | 196 ++++++++ packages/uncheck/tests/uncheck/tsc.utils.ts | 42 ++ packages/uncheck/tests/uncheck/utils.ts | 31 ++ 60 files changed, 8123 insertions(+), 26 deletions(-) create mode 100644 packages/uncheck/tests/hooks/install/agents.test.ts create mode 100644 packages/uncheck/tests/hooks/install/existing.test.ts create mode 100644 packages/uncheck/tests/hooks/install/location.test.ts create mode 100644 packages/uncheck/tests/hooks/install/package-manager.test.ts create mode 100644 packages/uncheck/tests/hooks/install/prompt.test.ts create mode 100644 packages/uncheck/tests/hooks/install/utils.ts create mode 100644 packages/uncheck/tests/hooks/run/changes.test.ts create mode 100644 packages/uncheck/tests/hooks/run/families.test.ts create mode 100644 packages/uncheck/tests/hooks/run/installed.test.ts create mode 100644 packages/uncheck/tests/hooks/run/location.test.ts create mode 100644 packages/uncheck/tests/hooks/run/payload.test.ts create mode 100644 packages/uncheck/tests/hooks/run/selection.test.ts create mode 100644 packages/uncheck/tests/hooks/run/utils.ts create mode 100644 packages/uncheck/tests/prepare/commit.test.ts create mode 100644 packages/uncheck/tests/prepare/existing-hook.test.ts create mode 100644 packages/uncheck/tests/prepare/failures.test.ts create mode 100644 packages/uncheck/tests/prepare/hook.test.ts create mode 100644 packages/uncheck/tests/prepare/linked-checkouts.test.ts create mode 100644 packages/uncheck/tests/prepare/location.test.ts create mode 100644 packages/uncheck/tests/prepare/package-manager.test.ts create mode 100644 packages/uncheck/tests/prepare/utils.ts create mode 100644 packages/uncheck/tests/staged/basics.test.ts create mode 100644 packages/uncheck/tests/staged/commit.test.ts create mode 100644 packages/uncheck/tests/staged/fix.test.ts create mode 100644 packages/uncheck/tests/staged/interrupt.test.ts create mode 100644 packages/uncheck/tests/staged/line-endings.test.ts create mode 100644 packages/uncheck/tests/staged/many-files.test.ts create mode 100644 packages/uncheck/tests/staged/merge.test.ts create mode 100644 packages/uncheck/tests/staged/precedence.test.ts create mode 100644 packages/uncheck/tests/staged/sparse-checkout.test.ts create mode 100644 packages/uncheck/tests/staged/unstaged.test.ts create mode 100644 packages/uncheck/tests/staged/utils.ts create mode 100644 packages/uncheck/tests/uncheck/cli.test.ts create mode 100644 packages/uncheck/tests/uncheck/files.test.ts create mode 100644 packages/uncheck/tests/uncheck/fix.test.ts create mode 100644 packages/uncheck/tests/uncheck/install.test.ts create mode 100644 packages/uncheck/tests/uncheck/output.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-exclusions.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-git.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-globs.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-selection.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-unmatched.test.ts create mode 100644 packages/uncheck/tests/uncheck/paths-utils.ts create mode 100644 packages/uncheck/tests/uncheck/paths-walk.test.ts create mode 100644 packages/uncheck/tests/uncheck/presets.test.ts create mode 100644 packages/uncheck/tests/uncheck/selection.test.ts create mode 100644 packages/uncheck/tests/uncheck/sherif.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc-extends.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc-inputs.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc-patterns.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc-references.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc.test.ts create mode 100644 packages/uncheck/tests/uncheck/tsc.utils.ts create mode 100644 packages/uncheck/tests/uncheck/utils.ts diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 8094671..3304102 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -543,9 +543,7 @@ const EXPORT_CONDITIONS = new Set(['node', 'require', 'types', 'default']) */ function resolveExports(exports: unknown, subpath: string): string | undefined { const map: Record = - Predicate.isObject(exports) && - !Array.isArray(exports) && - Object.keys(exports).some((key) => key.startsWith('.')) + Predicate.isObject(exports) && Object.keys(exports).some((key) => key.startsWith('.')) ? exports : { '.': exports } diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index fe72fb4..308ddb5 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -97,11 +97,10 @@ export const run = Command.make( if (alreadyContinued) { if (payload.hook_event_name === 'Stop') { - const summary = - report - .split('\n') - .filter((line) => line.startsWith('✘ ')) - .at(-1) ?? '' + const summary = report + .split('\n') + .filter((line) => line.startsWith('✘ ')) + .at(-1)! yield* Console.log( JSON.stringify({ systemMessage: `uncheck still fails: ${summary.slice(2)}` }), diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 7a7ef9d..66b8761 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -146,7 +146,7 @@ export const prepare = Command.make( const { prefix, - paths: [hooks = ''], + paths: [hooks], } = repository.value const inside = prefix.replace(/\/$/, '') const exec = yield* detectExec(cwd) @@ -161,7 +161,7 @@ export const prepare = Command.make( // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h` // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above. - const configured = path.resolve(cwd, hooks) + const configured = path.resolve(cwd, hooks!) const dispatched = path.basename(configured) === '_' && (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false))) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 7fc2c7b..e940c76 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -69,13 +69,13 @@ export const staged = Command.make( const [ { prefix, - paths: [folder = '', indexLock = ''], + paths: [folder, indexLock], }, unstaged, ] = yield* Effect.all([gitLocation(cwd, ['uncheck-unstaged', 'index.lock']), rawDiff(cwd)], { concurrency: 'unbounded', }) - const saved = path.resolve(cwd, folder) + const saved = path.resolve(cwd, folder!) const aside = { cwd, saved, prefix } // A run that was killed, or could not put them back, left the only copy of unstaged changes. @@ -129,7 +129,7 @@ export const staged = Command.make( const active = process.env.GIT_INDEX_FILE if (active?.endsWith('.lock') === true) { - const lock = path.resolve(cwd, indexLock) + const lock = path.resolve(cwd, indexLock!) if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { yield* stage({ GIT_INDEX_FILE: lock }) @@ -218,9 +218,9 @@ const rawDiff = (cwd: string, ...args: ReadonlyArray) => const fields = output.split('\0') return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { - const [fromMode = '', toMode = ''] = fields[index * 2]!.slice(1).split(' ') + const [fromMode, toMode] = fields[index * 2]!.slice(1).split(' ') - return { file: fields[index * 2 + 1]!, fromMode, toMode } + return { file: fields[index * 2 + 1]!, fromMode: fromMode!, toMode: toMode! } }) }, ) @@ -274,18 +274,16 @@ interface SetAside { readonly base: string } -/** Runs `git -- ` in batches; with no files, some commands would act on every path. */ +/** Runs `git -- ` in batches. Without files, `reset` would reset every path. */ function gitEach( cwd: string, args: ReadonlyArray, files: ReadonlyArray, env?: Readonly>, ) { - return Effect.forEach( - files.length === 0 ? [] : argvBatches(files), - (batch) => git(cwd, [...args, '--', ...batch], env), - { discard: true }, - ) + return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch], env), { + discard: true, + }) } const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { @@ -402,7 +400,7 @@ const merge = Effect.fn(function* ( // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it // back through the filters would turn every line ending of the file to LF. - const [staged = '', stored = ''] = (yield* git(cwd, [ + const [staged, stored] = (yield* git(cwd, [ 'rev-parse', `:0:${prefix}${file}`, `${before}:${prefix}${file}`, diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index 7fd2a55..5315837 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -23,5 +23,5 @@ export function userError(userMessage: string): Effect.Effect { + it('writes the stop hook of Claude Code, CodeBuddy and Cursor into the package', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'cursor', 'claude', 'codebuddy'], + { cwd: app }, + ) + + expect(exitCode).toBe(0) + expect(stderr).toBe('') + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json created', + 'CodeBuddy .codebuddy/settings.json created', + 'Cursor .cursor/hooks.json created', + ], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.codebuddy/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + expect(project.exists(`${app}.github`)).toBe(false) + }) + + it('leaves an installed hook as it is when run again', async () => { + const command = hookCommand(app) + const compact = JSON.stringify(cursorHooks(command)) + const project = create({ [`${app}.cursor/hooks.json`]: compact }) + + await project.uncheck(['hooks', 'install', 'claude'], { cwd: app }) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(compact) + }) + + it('writes the check selection into the hook and updates it in place when it changes', async () => { + const project = create() + const flags = ['--only=oxlint', '--only=oxfmt', '--require=oxfmt', '--skip=tsc'] + const fast = hookCommand(app, flags) + + const first = await project.uncheck(['hooks', 'install', 'claude', 'cursor', ...flags], { + cwd: app, + }) + + expect(first.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], + fast, + ), + ) + expect(readConfig(project, `${app}.claude/settings.json`)).toEqual(claudeSettings(fast)) + expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual(cursorHooks(fast)) + + const command = hookCommand(app) + const second = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(second.exitCode).toBe(0) + expect(second.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) +}) + +describe('hooks install for GitHub Copilot', () => { + it('writes the GitHub Copilot hook at the top of the repository', async () => { + const project = singleRepo() + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json created'], command), + ) + expect(project.read('.github/hooks/uncheck.json')).toBe(asWritten(copilotHooks(command))) + }) +}) + +describe('hooks install arguments', () => { + it.each([ + [['--require=tsc', '--skip=tsc'], '--require=tsc and --skip=tsc contradict each other.'], + [['--only=oxlint', '--skip=oxlint'], '--only=oxlint and --skip=oxlint contradict each other.'], + [ + ['--only=oxlint', '--only=oxfmt', '--require=tsc'], + '--require=tsc and --only=oxlint --only=oxfmt contradict each other.', + ], + ])('refuses the contradicting flags %j', async (flags, message) => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + ...flags, + ]) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe(`\nERROR\n ${message}\n`) + expect(project.exists('.claude')).toBe(false) + }) + + it('refuses an agent it does not know', async () => { + const project = singleRepo() + + const { exitCode, stderr } = await project.uncheck(['hooks', 'install', 'claude', 'emacs']) + + expect(exitCode).toBe(1) + expect(stderr).toBe( + '\nERROR\n Invalid value for argument : "emacs". Expected: "claude" | "codebuddy" | "cursor" | "copilot"\n', + ) + expect(project.exists('.claude')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/existing.test.ts b/packages/uncheck/tests/hooks/install/existing.test.ts new file mode 100644 index 0000000..c880ab3 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/existing.test.ts @@ -0,0 +1,351 @@ +import { LAYOUTS, singleRepo } from '../../utils/project' +import { + TIMEOUT, + asWritten, + claudeSettings, + cursorHooks, + hookCommand, + installOutput, + readConfig, +} from './utils' + +const MENTIONS = [ + 'cd web && npx uncheck hooks run --fix && notify', + 'npx uncheck hooks run --fix src', + 'npx uncheck hooks run-all --fix', + 'echo npx uncheck hooks run --fix', +] + +describe.each(LAYOUTS)( + 'hooks install merges into existing configs in a $name', + ({ create, app }) => { + it('adds its hook to a JSONC config, keeping every other setting and hook', async () => { + const project = create({ + [`${app}.claude/settings.json`]: `{ + // Shared with the team. + "permissions": { "allow": ["Bash(pnpm test)"], }, + "hooks": { + "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }], + }, +} +`, + [`${app}.cursor/hooks.json`]: { hooks: { afterFileEdit: [{ command: 'echo edited' }] } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ + permissions: { allow: ['Bash(pnpm test)'] }, + hooks: { + PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }, + ], + }, + }) + expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ + version: 1, + hooks: { + afterFileEdit: [{ command: 'echo edited' }], + stop: [{ command, timeout: TIMEOUT }], + }, + }) + }) + + it('replaces its own stop entry where it is, keeping what the user set on it', async () => { + const postToolUse = [ + { hooks: [{ type: 'command', command: 'npx uncheck hooks run --fix --only=oxfmt' }] }, + ] + const project = create({ + [`${app}.claude/settings.json`]: { + hooks: { + PostToolUse: postToolUse, + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { + matcher: '', + hooks: [ + { + type: 'command', + command: 'npx uncheck hooks run --fix --only=oxlint', + statusMessage: 'Checking', + timeout: 1800, + }, + ], + }, + { hooks: [{ type: 'command', command: 'echo last' }] }, + ], + }, + }, + [`${app}.cursor/hooks.json`]: { + version: 2, + hooks: { stop: [{ command: 'yarn uncheck hooks run' }, { command: 'echo last' }] }, + }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ + hooks: { + PostToolUse: postToolUse, + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { + matcher: '', + hooks: [{ type: 'command', command, statusMessage: 'Checking', timeout: 1800 }], + }, + { hooks: [{ type: 'command', command: 'echo last' }] }, + ], + }, + }) + expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ + version: 2, + hooks: { stop: [{ command, timeout: TIMEOUT }, { command: 'echo last' }] }, + }) + + const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(again.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + }) + + it('leaves stop hooks that only mention it alone, adding its own next to them', async () => { + const claudeStop = [ + { + hooks: [ + ...MENTIONS.map((mention) => ({ type: 'command', command: mention })), + { + type: 'command', + command: 'notify-send done', + statusMessage: 'Waiting for uncheck hooks run --fix', + }, + ], + }, + ] + const cursorStop = MENTIONS.map((mention) => ({ command: mention })) + const project = create({ + [`${app}.claude/settings.json`]: { hooks: { Stop: claudeStop } }, + [`${app}.cursor/hooks.json`]: { hooks: { stop: cursorStop } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ + hooks: { + Stop: [...claudeStop, { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }], + }, + }) + expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ + version: 1, + hooks: { stop: [...cursorStop, { command, timeout: TIMEOUT }] }, + }) + + const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(again.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + }) + + it('replaces a stop event that is not a list of hooks', async () => { + const project = create({ + [`${app}.claude/settings.json`]: { hooks: { Stop: 'notify-send done' } }, + [`${app}.cursor/hooks.json`]: { version: 1, hooks: { stop: { command: 'echo done' } } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) + + it('writes a new config over an empty file or a hooks value that is not an object', async () => { + const project = create({ + [`${app}.claude/settings.json`]: { model: 'opus', hooks: ['notify-send done'] }, + [`${app}.codebuddy/settings.json`]: '', + [`${app}.cursor/hooks.json`]: ' \n\t\n', + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck( + ['hooks', 'install', 'claude', 'codebuddy', 'cursor'], + { cwd: app }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json updated', + 'CodeBuddy .codebuddy/settings.json updated', + 'Cursor .cursor/hooks.json updated', + ], + command, + ), + ) + expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ + model: 'opus', + ...claudeSettings(command), + }) + expect(project.read(`${app}.codebuddy/settings.json`)).toBe( + asWritten(claudeSettings(command)), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) + + it('refuses a config it cannot read as a JSON object and leaves every file as it is', async () => { + const broken = '{\n "version": 1,\n hooks: {}\n}\n' + const project = create({ + [`${app}.cursor/hooks.json`]: broken, + [`${app}.claude/settings.json`]: '["Stop"]\n', + [`${app}.codebuddy/settings.json`]: '"hooks"\n', + }) + + const cursor = await project.uncheck(['hooks', 'install', 'cursor'], { cwd: app }) + + expect(cursor.exitCode).toBe(1) + expect(cursor.stdout).toBe('') + expect(cursor.stderr).toBe( + '\nERROR\n .cursor/hooks.json has InvalidSymbol on line 3, fix it and run again\n', + ) + + for (const [agent, file] of [ + ['claude', '.claude/settings.json'], + ['codebuddy', '.codebuddy/settings.json'], + ] as const) { + const { exitCode, stderr } = await project.uncheck(['hooks', 'install', agent], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(stderr).toBe(`\nERROR\n ${file} is not a JSON object, fix it and run again\n`) + } + + expect(project.read(`${app}.cursor/hooks.json`)).toBe(broken) + expect(project.read(`${app}.claude/settings.json`)).toBe('["Stop"]\n') + expect(project.read(`${app}.codebuddy/settings.json`)).toBe('"hooks"\n') + }) + + it('writes no config when another one it was asked for is broken', async () => { + const project = create({ [`${app}.cursor/hooks.json`]: '{ "version": 1' }) + + const { exitCode, stderr } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(stderr).toBe( + '\nERROR\n .cursor/hooks.json has CloseBraceExpected on line 1, fix it and run again\n', + ) + expect(project.exists(`${app}.claude`)).toBe(false) + }) + }, +) + +describe('hooks install merges into an existing GitHub Copilot config', () => { + it('keeps a Copilot timeout under either of its names', async () => { + const project = singleRepo({ + '.github/hooks/uncheck.json': { + version: 1, + hooks: { + agentStop: [ + { type: 'command', bash: 'npx uncheck hooks run --fix', timeout: 1800 }, + { type: 'command', powershell: 'npx uncheck hooks run --fix', timeoutSec: 1200 }, + ], + }, + }, + }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), + ) + expect(readConfig(project, '.github/hooks/uncheck.json')).toEqual({ + version: 1, + hooks: { + agentStop: [ + { type: 'command', bash: command, powershell: command, timeout: 1800 }, + { type: 'command', bash: command, powershell: command, timeoutSec: 1200 }, + ], + }, + }) + }) + + it('leaves Copilot hooks that only mention it alone, adding its own next to them', async () => { + const agentStop = MENTIONS.map((mention) => ({ + type: 'command', + bash: mention, + powershell: mention, + })) + const project = singleRepo({ '.github/hooks/uncheck.json': { hooks: { agentStop } } }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), + ) + expect(readConfig(project, '.github/hooks/uncheck.json')).toEqual({ + version: 1, + hooks: { + agentStop: [ + ...agentStop, + { type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }, + ], + }, + }) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/location.test.ts b/packages/uncheck/tests/hooks/install/location.test.ts new file mode 100644 index 0000000..6297702 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/location.test.ts @@ -0,0 +1,148 @@ +import { CLI, LAYOUTS, monorepo, run, temporaryDirectory } from '../../utils/project' +import { + PERMISSIONS_ENFORCED, + asWritten, + claudeSettings, + copilotHooks, + hookCommand, + installOutput, + withMode, +} from './utils' + +describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, app }) => { + it('names a folder below the top of the repository in the hook command', async () => { + const folder = `${app}tools/@scope_v=1.2+x-y` + const project = create({ [`${folder}/index.ts`]: 'export const tool = 1;\n' }) + const command = `pnpm exec uncheck hooks run --fix --dir=${folder}` + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + expect(project.read(`${folder}/.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + }) + + it.each(['my tools', "it's", 'café'])( + 'refuses a folder named %j that the hook command cannot name', + async (name) => { + const folder = `${app}${name}` + const project = create({ [`${folder}/index.ts`]: 'export const tool = 1;\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: folder, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + `\nERROR\n The hook command cannot name ${folder}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-\n`, + ) + expect(project.exists(`${folder}/.claude`)).toBe(false) + }, + ) + + it('writes a hook for the directory it runs in outside git', async () => { + const project = create({}, { git: 'none' }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'copilot'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json created', + 'GitHub Copilot .github/hooks/uncheck.json created', + ], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.github/hooks/uncheck.json`)).toBe(asWritten(copilotHooks(command))) + }) + + it('installs into the directory given with --cwd, wherever it runs', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout } = await run( + [...CLI, 'hooks', 'install', 'claude', `--cwd=${project.path(app)}`], + { cwd: temporaryDirectory() }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + }) + + it('reports a config it cannot read', async () => { + const project = create({ [`${app}.claude`]: 'not a folder\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + `\nERROR\n BadResource: FileSystem.readFile (${project.path(`${app}.claude/settings.json`)})\n`, + ) + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'reports a config it cannot write, after writing the ones before it', + async () => { + const project = create({ [`${app}.cursor/rules.md`]: '# Rules\n' }) + const command = hookCommand(app) + + const { exitCode, stdout, stderr } = await withMode(project, `${app}.cursor`, 0o555, () => + project.uncheck(['hooks', 'install', 'cursor', 'claude'], { cwd: app }), + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('✔ Claude Code .claude/settings.json created\n') + expect(stderr).toBe( + `\nERROR\n PermissionDenied: FileSystem.writeFile (${project.path(`${app}.cursor/hooks.json`)})\n`, + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.exists(`${app}.cursor/hooks.json`)).toBe(false) + }, + ) + + it.runIf(PERMISSIONS_ENFORCED)('reports a folder it cannot look into', async () => { + const project = create({ [`${app}locked/notes.md`]: '# Notes\n' }) + + const { exitCode, stdout, stderr } = await withMode(project, `${app}locked`, 0o644, () => + project.uncheck(['hooks', 'install', 'claude', `--cwd=${project.path(`${app}locked`)}`]), + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + `\nERROR\n PermissionDenied: FileSystem.readFile (${project.path(`${app}locked/.claude/settings.json`)})\n`, + ) + }) +}) + +describe('hooks install in a monorepo package', () => { + it('refuses Copilot, which reads its hooks only at the top of the repository', async () => { + const project = monorepo() + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'claude', 'copilot'], + { cwd: 'packages/app' }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + '\nERROR\n Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there\n', + ) + expect(project.exists('packages/app/.claude')).toBe(false) + expect(project.exists('packages/app/.github')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/package-manager.test.ts b/packages/uncheck/tests/hooks/install/package-manager.test.ts new file mode 100644 index 0000000..0861bb9 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/package-manager.test.ts @@ -0,0 +1,80 @@ +import { monorepo, singleRepo } from '../../utils/project' +import { installOutput } from './utils' + +function manifest(packageManager: string) { + return { 'package.json': { name: 'app', private: true, type: 'module', packageManager } } +} + +describe('hooks install runs uncheck through the package manager of the project', () => { + it.each([ + ['a pnpm lockfile', {}, 'pnpm exec'], + ['a Yarn lockfile', { 'pnpm-lock.yaml': null, 'yarn.lock': '' }, 'yarn run --silent'], + ['a Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lock': '{}\n' }, 'bunx --no-install'], + ['a binary Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lockb': '' }, 'bunx --no-install'], + ['an npm lockfile', { 'pnpm-lock.yaml': null, 'package-lock.json': {} }, 'npx --no'], + ['no lockfile', { 'pnpm-lock.yaml': null }, 'npx --no'], + ['npm declared over a pnpm lockfile', manifest('npm@10.9.0'), 'npx --no'], + [ + 'Yarn 1 declared', + { ...manifest('yarn@1.22.22'), 'pnpm-lock.yaml': null }, + 'yarn run --silent', + ], + [ + 'Yarn 4 declared, which runs the binaries of the root with -T', + { ...manifest('yarn@4.5.0'), 'pnpm-lock.yaml': null }, + 'yarn run -T --silent', + ], + [ + 'an unknown package manager declared over a pnpm lockfile', + manifest('deno@2.0.0'), + 'pnpm exec', + ], + ])('with %s', async (_, files, exec) => { + const project = singleRepo(files) + const command = `${exec} uncheck hooks run --fix` + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude']) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + }) +}) + +describe('hooks install in a Yarn 4 monorepo', () => { + it("runs the root's binary unless the package declares uncheck itself", async () => { + const project = monorepo({ + 'package.json': { + name: 'monorepo', + private: true, + workspaces: ['packages/*'], + devDependencies: { uncheck: '^0.0.3' }, + packageManager: 'yarn@4.5.0', + }, + 'pnpm-workspace.yaml': null, + 'pnpm-lock.yaml': null, + 'yarn.lock': '', + 'packages/core/package.json': { + name: '@repo/core', + private: true, + devDependencies: { uncheck: '^0.0.3' }, + }, + 'packages/cli/package.json': { + name: '@repo/cli', + private: true, + dependencies: { uncheck: '^0.0.3' }, + }, + }) + + for (const [cwd, command] of [ + ['.', 'yarn run -T --silent uncheck hooks run --fix'], + ['packages/app', 'yarn run -T --silent uncheck hooks run --fix --dir=packages/app'], + ['packages/core', 'yarn run --silent uncheck hooks run --fix --dir=packages/core'], + ['packages/cli', 'yarn run --silent uncheck hooks run --fix --dir=packages/cli'], + ] as const) { + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude'], { cwd }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + } + }) +}) diff --git a/packages/uncheck/tests/hooks/install/prompt.test.ts b/packages/uncheck/tests/hooks/install/prompt.test.ts new file mode 100644 index 0000000..28c096f --- /dev/null +++ b/packages/uncheck/tests/hooks/install/prompt.test.ts @@ -0,0 +1,63 @@ +import { LAYOUTS, monorepo } from '../../utils/project' +import { asWritten, claudeSettings, cursorHooks, hookCommand, installOutput } from './utils' + +const QUESTION = 'Which agents should run uncheck when they finish a turn?' +const DOWN = '\u001B[B' +const SPACE = ' ' +const ENTER = '\r' + +describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app }) => { + it('writes the agents chosen in a terminal', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { + cwd: app, + waitFor: QUESTION, + keys: [DOWN, DOWN, SPACE, DOWN, DOWN, SPACE, ENTER], + }) + + expect(exitCode).toBe(0) + expect(stdout.split(' Claude Code, Cursor\n').at(-1)).toBe( + installOutput( + ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + expect(project.exists(`${app}.codebuddy`)).toBe(false) + }) + + it('asks for the agents on the command line when stdin is not a terminal', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install'], { cwd: app }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + '\nERROR\n Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot\n', + ) + expect(project.exists(`${app}.claude`)).toBe(false) + }) +}) + +describe('hooks install without agents in a monorepo package', () => { + it('refuses Copilot chosen in a terminal, writing none of the others', async () => { + const project = monorepo() + + const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { + cwd: 'packages/app', + waitFor: QUESTION, + keys: [SPACE, ENTER], + }) + + expect(exitCode).toBe(1) + expect(stdout).toContain( + '\nERROR\n Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there\n', + ) + expect(project.exists('packages/app/.claude')).toBe(false) + expect(project.exists('packages/app/.github')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/utils.ts b/packages/uncheck/tests/hooks/install/utils.ts new file mode 100644 index 0000000..3cf40db --- /dev/null +++ b/packages/uncheck/tests/hooks/install/utils.ts @@ -0,0 +1,59 @@ +import { chmodSync } from 'node:fs' +import process from 'node:process' + +import type { Project } from '../../utils/project' + +export const TIMEOUT = 600 + +export const PERMISSIONS_ENFORCED = process.getuid?.() !== 0 + +// The harness cannot remove the project once a folder in it is left unreadable. +export async function withMode( + project: Project, + folder: string, + mode: number, + action: () => Promise, +): Promise { + chmodSync(project.path(folder), mode) + + try { + return await action() + } finally { + chmodSync(project.path(folder), 0o755) + } +} + +export function hookCommand(app: string, flags: ReadonlyArray = []): string { + const dir = app === '' ? [] : [`--dir=${app.slice(0, -1)}`] + + return ['pnpm exec uncheck hooks run --fix', ...flags, ...dir].join(' ') +} + +export function installOutput(results: ReadonlyArray, command: string): string { + return `${results.map((result) => `✔ ${result}\n`).join('')}\nThe hook runs ${command} whenever the agent finishes a turn.\n` +} + +export function claudeSettings(command: string) { + return { hooks: { Stop: [{ hooks: [{ type: 'command', command, timeout: TIMEOUT }] }] } } +} + +export function cursorHooks(command: string) { + return { version: 1, hooks: { stop: [{ command, timeout: TIMEOUT }] } } +} + +export function copilotHooks(command: string) { + return { + version: 1, + hooks: { + agentStop: [{ type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }], + }, + } +} + +export function asWritten(config: object): string { + return `${JSON.stringify(config, null, 2)}\n` +} + +export function readConfig(project: Project, file: string): unknown { + return JSON.parse(project.read(file)) +} diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts new file mode 100644 index 0000000..c57e853 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -0,0 +1,141 @@ +import { stripVTControlCharacters } from 'node:util' + +import { LAYOUTS } from '../../utils/project' +import { CLAUDE_CODE_STOP, GITIGNORE, lines, location, stopHook, tscCommand } from './utils' + +const UNFORMATTED = 'export const legacy = 1\n' + +describe.each(LAYOUTS)( + 'hooks run checks what changed since the last commit in a $name', + ({ create, app }) => { + it('stays silent when nothing changed', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stderr).toBe('') + expect(project.read(`${app}src/legacy.ts`)).toBe(UNFORMATTED) + }) + + it('fixes the changed and untracked files, leaving ignored and unchanged ones alone', async () => { + const project = create({ + ...GITIGNORE, + [`${app}src/legacy.ts`]: UNFORMATTED, + }) + const index = project.read(`${app}src/index.ts`) + + project.write({ + [`${app}src/index.ts`]: `${index}export var later = 2\n`, + [`${app}src/extra.ts`]: 'export const extra = 1\n', + [`${app}dist/bundle.js`]: 'export var bundle = 1\n', + }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + env: { FORCE_COLOR: '1' }, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stripVTControlCharacters(stderr)).toBe(stderr) + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxfmt passed', + tscCommand(app), + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(project.read(`${app}src/index.ts`)).toBe(`${index}export const later = 2;\n`) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.read(`${app}src/legacy.ts`)).toBe(UNFORMATTED) + expect(project.read(`${app}dist/bundle.js`)).toBe('export var bundle = 1\n') + }) + + it('leaves a file deleted since the last commit out, even one whose name reads as a pattern', async () => { + const project = create({ + [`${app}src/[id].ts`]: 'export const id = 1;\n', + [`${app}src/i.ts`]: UNFORMATTED, + }).write({ [`${app}src/[id].ts`]: null }) + + const deleted = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(deleted.exitCode).toBe(0) + expect(deleted.stdout).toBe('') + expect(lines(project, deleted.stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ nothing to check, no files match src/[id].ts', + ]) + expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) + + project.write({ [`${app}src/new.ts`]: 'export const fresh = 1\n' }) + + const alongside = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxfmt'], + }) + + expect(alongside.exitCode).toBe(0) + expect(lines(project, alongside.stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/new.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/new.ts`)).toBe('export const fresh = 1;\n') + expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) + }) + + it('checks everything under the directory outside git', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'none' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: [], + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tscCommand(app), + '✔ tsc passed', + '✘ 1 of 3 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(stderr).toContain('src/legacy.ts') + }) + + it('checks everything before the first commit', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'init' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(stderr).toContain('src/legacy.ts') + }) + }, +) diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts new file mode 100644 index 0000000..9d7835f --- /dev/null +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -0,0 +1,109 @@ +import { LAYOUTS } from '../../utils/project' +import { + CLAUDE_CODE_STOP, + CLAUDE_CODE_STOP_AGAIN, + COPILOT_AGENT_STOP, + COPILOT_STOP_IN_CLAUDE_FORMAT, + CURSOR_STOP, + TYPE_ERROR, + lines, + location, + stopHook, + tscCommand, +} from './utils' + +const DIAGNOSTIC = + "src/index.ts(1,14): error TS2322: Type 'number' is not assignable to type 'string'." + +describe.each(LAYOUTS)( + 'hooks run sends each agent back its own way in a $name', + ({ create, app }) => { + const failing = () => + create({}, { tools: ['sherif', 'oxlint', 'oxfmt'] }) + .fake('typescript', `console.log(${JSON.stringify(DIAGNOSTIC)})\nprocess.exitCode = 2\n`) + .write({ [`${app}src/index.ts`]: TYPE_ERROR }) + const failure = [ + `uncheck in ${location(app)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', + '✔ oxfmt passed', + tscCommand(app), + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ] + const reason = (stderr: string) => + `uncheck found problems, fix them before finishing:\n\n${stderr.replace(/\n$/, '')}` + + it('blocks Claude Code and CodeBuddy with exit code 2 and the report on stderr', async () => { + const project = failing() + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual(failure) + expect(stderr).toContain(`\n${DIAGNOSTIC}\n`) + }) + + it('lets Claude Code stop once it already went back, telling the user what still fails', async () => { + const project = failing() + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(exitCode).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(lines(project, stderr)).toEqual(failure) + }) + + it('sends Cursor a follow-up message with the report', async () => { + const project = failing() + + const { exitCode, stdout, stderr } = await stopHook(project, app, CURSOR_STOP) + + expect(exitCode).toBe(0) + expect(lines(project, stderr)).toEqual(failure) + expect(JSON.parse(stdout)).toEqual({ followup_message: reason(stderr) }) + }) + + it('lets Cursor stop once it already followed up', async () => { + const project = failing() + + const { exitCode, stdout, stderr } = await stopHook(project, app, { + ...CURSOR_STOP, + loop_count: 1, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual(failure) + }) + + it('blocks Copilot with a decision, whichever payload format it sends', async () => { + const project = failing() + + for (const payload of [COPILOT_AGENT_STOP, COPILOT_STOP_IN_CLAUDE_FORMAT]) { + const { exitCode, stdout, stderr } = await stopHook(project, app, payload) + + expect(exitCode).toBe(0) + expect(lines(project, stderr)).toEqual(failure) + expect(JSON.parse(stdout)).toEqual({ decision: 'block', reason: reason(stderr) }) + } + }) + + it('only reports to an agent it does not know', async () => { + const project = failing() + + const { exitCode, stdout, stderr } = await stopHook(project, app, { + hook_event_name: 'SubagentStop', + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual(failure) + }) + }, +) diff --git a/packages/uncheck/tests/hooks/run/installed.test.ts b/packages/uncheck/tests/hooks/run/installed.test.ts new file mode 100644 index 0000000..6948509 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/installed.test.ts @@ -0,0 +1,105 @@ +import type { Project, Run } from '../../utils/project' +import { LAYOUTS, run } from '../../utils/project' +import { + CLAUDE_CODE_STOP, + CLAUDE_CODE_STOP_AGAIN, + CURSOR_STOP, + GITIGNORE, + TYPE_ERROR, + lines, + location, + tscCommand, +} from './utils' + +interface ClaudeSettings { + readonly hooks: { + readonly Stop: ReadonlyArray<{ readonly hooks: [{ readonly command: string }] }> + } +} + +interface CursorHooks { + readonly hooks: { readonly stop: ReadonlyArray<{ readonly command: string }> } +} + +async function installHook( + project: Project, + app: string, + agent: 'claude' | 'cursor', +): Promise<(payload: object) => Promise> { + const install = await project.uncheck(['hooks', 'install', agent], { cwd: app }) + + expect(install.exitCode).toBe(0) + + const command = + agent === 'claude' + ? (JSON.parse(project.read(`${app}.claude/settings.json`)) as ClaudeSettings).hooks.Stop[0]! + .hooks[0].command + : (JSON.parse(project.read(`${app}.cursor/hooks.json`)) as CursorHooks).hooks.stop[0]!.command + + return (payload) => + run(['sh', '-c', command], { cwd: project.path(`${app}src`), input: JSON.stringify(payload) }) +} + +describe.each(LAYOUTS)('the installed stop hook in a $name', ({ create, app }) => { + const failure = [ + `uncheck in ${location(app)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', + '✔ oxfmt passed', + tscCommand(app), + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ] + + it('sends Claude Code back until its change passes, from wherever it moved to', async () => { + const project = create(GITIGNORE) + const stop = await installHook(project, app, 'claude') + + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const blocked = await stop(CLAUDE_CODE_STOP) + + expect(blocked.exitCode).toBe(2) + expect(blocked.stdout).toBe('') + expect(lines(project, blocked.stderr)).toEqual(failure) + + const again = await stop(CLAUDE_CODE_STOP_AGAIN) + + expect(again.exitCode).toBe(0) + expect(JSON.parse(again.stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + + project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) + + const passed = await stop(CLAUDE_CODE_STOP) + + expect(passed.exitCode).toBe(0) + expect(passed.stdout).toBe('') + expect(lines(project, passed.stderr).at(-1)).toBe('✔ all checks passed (oxlint, oxfmt, tsc)') + expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') + }) + + it('sends Cursor back once with a follow-up message, from wherever it moved to', async () => { + const project = create(GITIGNORE) + const stop = await installHook(project, app, 'cursor') + + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const followUp = await stop(CURSOR_STOP) + + expect(followUp.exitCode).toBe(0) + expect(lines(project, followUp.stderr)).toEqual(failure) + expect(JSON.parse(followUp.stdout)).toEqual({ + followup_message: `uncheck found problems, fix them before finishing:\n\n${followUp.stderr.replace(/\n$/, '')}`, + }) + + const again = await stop({ ...CURSOR_STOP, loop_count: 1 }) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe('') + expect(lines(project, again.stderr)).toEqual(failure) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts new file mode 100644 index 0000000..e8958a0 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -0,0 +1,165 @@ +import { CLI, LAYOUTS, monorepo, run, singleRepo, temporaryDirectory } from '../../utils/project' +import { CLAUDE_CODE_STOP, lines, location, stopHook } from './utils' + +const UNFORMATTED = 'export const extra = 1\n' +const FORMATTED = 'export const extra = 1;\n' + +const ONLY_OXFMT = [ + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', +] + +describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app }) => { + it('checks the package it was installed for from wherever the agent moved to', async () => { + const project = create({ 'docs/guide.md': '# Guide\n' }).write({ + [`${app}src/extra.ts`]: UNFORMATTED, + }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxfmt'], + cwd: 'docs', + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) + }) + + it('checks the directory given with --cwd, wherever it runs', async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await run( + [...CLI, 'hooks', 'run', '--fix', '--only=oxfmt', `--cwd=${project.path(app)}`], + { cwd: temporaryDirectory(), input: JSON.stringify(CLAUDE_CODE_STOP) }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) + }) + + it('reports a --dir that names nothing instead of sending the agent back', async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', `--dir=${app}gone`], + cwd: `${app}src`, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr.replaceAll(project.dir, '')).toBe( + `\nERROR\n --dir=${app}gone names nothing in , run \`uncheck hooks install\` again from the project\n`, + ) + expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) + }) +}) + +describe('hooks run in a monorepo', () => { + it('checks only the package in --dir, and the whole repository without it', async () => { + const project = monorepo().write({ + 'packages/app/src/extra.ts': UNFORMATTED, + 'packages/core/src/extra.ts': UNFORMATTED, + }) + + const app = await stopHook(project, 'packages/app/', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'packages/core/src', + }) + + expect(app.exitCode).toBe(2) + expect(lines(project, app.stderr)).toEqual([ + 'uncheck in /packages/app', + ...ONLY_OXFMT, + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + + const top = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxfmt'], + cwd: 'packages/core/src', + }) + + expect(top.exitCode).toBe(0) + expect(lines(project, top.stderr)).toEqual([ + 'uncheck in ', + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern packages/app/src/extra.ts packages/core/src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read('packages/app/src/extra.ts')).toBe(FORMATTED) + expect(project.read('packages/core/src/extra.ts')).toBe(FORMATTED) + }) + + it('takes --dir from the top of the repository around --cwd, not around where it runs', async () => { + const project = monorepo().write({ + 'packages/app/src/extra.ts': UNFORMATTED, + 'packages/core/src/extra.ts': UNFORMATTED, + }) + + const { exitCode, stdout, stderr } = await run( + [ + ...CLI, + 'hooks', + 'run', + '--fix', + '--only=oxfmt', + `--cwd=${project.path('packages/core/src')}`, + '--dir=packages/app', + ], + { cwd: temporaryDirectory(), input: JSON.stringify(CLAUDE_CODE_STOP) }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + 'uncheck in /packages/app', + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read('packages/app/src/extra.ts')).toBe(FORMATTED) + expect(project.read('packages/core/src/extra.ts')).toBe(UNFORMATTED) + }) +}) + +describe('hooks run arguments', () => { + it('refuses a --cwd that does not exist, checking nothing', async () => { + const project = singleRepo().write({ 'src/extra.ts': UNFORMATTED }) + const gone = project.path('gone') + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', `--cwd=${gone}`], + }) + + expect(exitCode).toBe(1) + expect(stdout).toContain('USAGE\n uncheck hooks run [flags]\n') + expect(stderr).toBe( + `\nERROR\n Invalid value for flag --cwd: "${gone}". Expected: Path does not exist: ${gone}\n`, + ) + expect(project.read('src/extra.ts')).toBe(UNFORMATTED) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/payload.test.ts b/packages/uncheck/tests/hooks/run/payload.test.ts new file mode 100644 index 0000000..7f68c16 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/payload.test.ts @@ -0,0 +1,60 @@ +import { LAYOUTS } from '../../utils/project' +import { CURSOR_STOP, dirFlags, lines, location, stopHook } from './utils' + +const UNFORMATTED = 'export const answer = 42\n' + +describe.each(LAYOUTS)('hooks run reads the payload in a $name', ({ create, app }) => { + it('refuses a terminal on stdin, where no agent sends a payload', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + const { exitCode, stdout } = await project.uncheckInTerminal([ + 'hooks', + 'run', + '--fix', + ...dirFlags(app), + ]) + + expect(exitCode).toBe(1) + expect(stdout).toContain( + 'ERROR\n `uncheck hooks run` expects the agent hook payload as JSON on stdin\n', + ) + expect(project.read(`${app}src/index.ts`)).toBe(UNFORMATTED) + }) + + it('takes a payload that is empty, not JSON or not an object as one from no agent in particular', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + for (const payload of ['', '{"hook_event_name": "Stop"', 'null', '["Stop"]']) { + const { exitCode, stdout, stderr } = await stopHook(project, app, payload, { + args: ['--only=oxfmt'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + } + }) + + it('leaves a turn the user stopped, or that failed, as it is', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + for (const status of ['aborted', 'error']) { + const { exitCode, stdout, stderr } = await stopHook(project, app, { ...CURSOR_STOP, status }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stderr).toBe('') + } + + expect(project.read(`${app}src/index.ts`)).toBe(UNFORMATTED) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/selection.test.ts b/packages/uncheck/tests/hooks/run/selection.test.ts new file mode 100644 index 0000000..aabb055 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/selection.test.ts @@ -0,0 +1,85 @@ +import { LAYOUTS, singleRepo } from '../../utils/project' +import { CLAUDE_CODE_STOP, TYPE_ERROR, lines, location, stopHook } from './utils' + +describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ create, app }) => { + it('skips a check with --skip', async () => { + const project = create().write({ + [`${app}src/index.ts`]: TYPE_ERROR, + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--skip=tsc', '--skip=oxlint'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(lines(project, stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, disabled with --skip=oxlint', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + }) + + it('passes a change no check selected with --only covers, unless it is required', async () => { + const project = create().write({ [`${app}README.md`]: '# App\n' }) + + const optional = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc'], + }) + + expect(optional.exitCode).toBe(0) + expect(optional.stdout).toBe('') + expect(lines(project, optional.stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, no tsconfig.json covers the given files', + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', + ]) + + const required = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc', '--require=tsc'], + }) + + expect(required.exitCode).toBe(2) + expect(required.stdout).toBe('') + expect(lines(project, required.stderr)).toEqual([ + `uncheck in ${location(app)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '✘ tsc no tsconfig.json covers the given files', + '✘ 1 of 1 checks failed: tsc', + ]) + }) +}) + +describe('hooks run check selection', () => { + it('refuses contradicting flags once there is something to check', async () => { + const project = singleRepo() + + const unchanged = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--only=tsc', '--skip=tsc'], + }) + + expect(unchanged.exitCode).toBe(0) + expect(unchanged.stderr).toBe('') + + project.write({ 'src/extra.ts': 'export const extra = 1;\n' }) + + const changed = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--only=tsc', '--skip=tsc'], + }) + + expect(changed.exitCode).toBe(1) + expect(changed.stdout).toBe('') + expect(changed.stderr).toBe('\nERROR\n --only=tsc and --skip=tsc contradict each other.\n') + }) +}) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts new file mode 100644 index 0000000..79871ba --- /dev/null +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -0,0 +1,60 @@ +import type { Env, Project, Run } from '../../utils/project' +import { report } from '../../utils/project' + +export const CLAUDE_CODE_STOP = { + session_id: 'session', + hook_event_name: 'Stop', + stop_hook_active: false, +} + +export const CLAUDE_CODE_STOP_AGAIN = { ...CLAUDE_CODE_STOP, stop_hook_active: true } + +export const CURSOR_STOP = { hook_event_name: 'stop', status: 'completed', loop_count: 0 } + +export const COPILOT_AGENT_STOP = { stopReason: 'end_turn' } + +export const COPILOT_STOP_IN_CLAUDE_FORMAT = { + hook_event_name: 'Stop', + stop_reason: 'end_turn', + stop_hook_active: false, +} + +// `tsc -b` writes its build info next to the tsconfig, where it would count as a change. +export const GITIGNORE = { '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n' } + +export const TYPE_ERROR = 'export const answer: string = 1;\n' + +export function dirFlags(app: string): string[] { + return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] +} + +export function location(app: string): string { + return `${app === '' ? '' : `/${app.slice(0, -1)}`}` +} + +export function tscCommand(app: string): string { + return app === '' ? '▶ tsc -p tsconfig.json --noEmit' : '▶ tsc -b tsconfig.json' +} + +export interface HookOptions { + readonly args?: ReadonlyArray + readonly cwd?: string + readonly env?: Env +} + +export function stopHook( + project: Project, + app: string, + payload: object | string, + { args = ['--fix'], cwd, env }: HookOptions = {}, +): Promise { + return project.uncheck(['hooks', 'run', ...dirFlags(app), ...args], { + cwd, + env, + input: typeof payload === 'string' ? payload : JSON.stringify(payload), + }) +} + +export function lines(project: Project, output: string): string[] { + return report(output.replaceAll(project.dir, '')) +} diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts new file mode 100644 index 0000000..31e3286 --- /dev/null +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -0,0 +1,110 @@ +import type { Files, Project } from '../utils/project' +import { LAYOUTS, monorepo, report, run } from '../utils/project' +import { installHusky, prepare } from './utils' + +const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } } + +const UNFIXABLE = 'export function ignore({}: object): void {}\n' + +function commit(project: Project, message: string) { + return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.path('.') }) +} + +function stage(project: Project, files: Files): void { + project.write(files) + project.git('add', '--all') +} + +function checks(output: string): string[] { + return report(output).filter((line) => !line.startsWith('▶ tsc ')) +} + +function blockedByLint(dir: string): string[] { + return [ + `uncheck staged in ${dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/ignore.ts', + '✘ oxlint failed', + '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + '✔ oxfmt passed', + '✔ tsc passed', + '✘ 1 of 3 checks failed: oxlint', + ] +} + +function fixedAndStaged(dir: string): string[] { + return [ + `uncheck staged in ${dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/spaced.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/spaced.ts', + '✔ oxfmt passed', + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + '✔ staged the fixes to src/spaced.ts', + ] +} + +describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create, app }) => { + it('blocks a commit with a lint error it cannot fix', async () => { + const project = create({ '.oxlintrc.json': LINT_CONFIG }) + await prepare(project, [], { cwd: app }) + stage(project, { [`${app}src/ignore.ts`]: UNFIXABLE }) + + const { exitCode, stderr } = await commit(project, 'ignore') + + expect(exitCode).toBe(1) + expect(checks(stderr)).toEqual(blockedByLint(project.path(app, '.'))) + expect(project.git('log', '--format=%s')).toBe('init\n') + expect(project.git('diff', '--cached', '--name-only')).toBe(`${app}src/ignore.ts\n`) + }) + + it('commits the fixes it applied', async () => { + const project = create() + await prepare(project, [], { cwd: app }) + stage(project, { [`${app}src/spaced.ts`]: 'export var spaced = 1\n' }) + + const { exitCode, stderr } = await commit(project, 'spaced') + + expect(exitCode).toBe(0) + expect(checks(stderr)).toEqual(fixedAndStaged(project.path(app, '.'))) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + expect(project.read(`${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + expect(project.git('status', '--porcelain', '--untracked-files=no')).toBe('') + }) + + it('runs through the husky 9 dispatcher before the commands of the hook', async () => { + const project = installHusky(create(), 'echo "husky hook ran"\n') + await prepare(project, [], { cwd: app }) + project.commit('husky') + stage(project, { [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) + + const { exitCode, stderr } = await commit(project, 'spaced') + + expect(exitCode).toBe(0) + expect(checks(stderr)).toEqual(fixedAndStaged(project.path(app, '.'))) + expect(stderr).toMatch(/\nhusky hook ran\n$/) + expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + }) +}) + +describe('committing with the prepared hook of several packages in a monorepo', () => { + it('stops at the first package whose line fails', async () => { + const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + stage(project, { + 'packages/core/src/ignore.ts': UNFIXABLE, + 'packages/app/src/spaced.ts': 'export const spaced = 1\n', + }) + + const { exitCode, stderr } = await commit(project, 'both') + + expect(exitCode).toBe(1) + expect(checks(stderr)).toEqual(blockedByLint(project.path('packages/core'))) + expect(project.read('packages/app/src/spaced.ts')).toBe('export const spaced = 1\n') + expect(project.git('log', '--format=%s')).toBe('init\n') + }) +}) diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts new file mode 100644 index 0000000..6822917 --- /dev/null +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -0,0 +1,216 @@ +import { Buffer } from 'node:buffer' +import { readFileSync, writeFileSync } from 'node:fs' + +import { LAYOUTS, monorepo } from '../utils/project' +import { COMMAND, HEADER, hookLine, mode, notWritten, prepare, shownHook, written } from './utils' + +const HOOK = '.git/hooks/pre-commit' + +describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, app }) => { + const line = hookLine(app) + const folder = app.slice(0, -1) + + async function prepareHook(hook: string) { + const project = create().write({ [HOOK]: hook }) + + const run = await prepare(project, [], { cwd: app }) + + return { ...run, project, hook: project.read(HOOK) } + } + + it('rewrites the line an older version wrote in place', async () => { + const old = + app === '' ? 'npx uncheck staged --fix' : `cd "${folder}" && yarn uncheck staged --fix` + + const { stdout, hook, project } = await prepareHook(`${HEADER}${old}\npnpm test\n`) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${HEADER}${line}\npnpm test\n`) + }) + + it('drops the copies of its line and settles on one', async () => { + const copies = [ + line, + app === '' ? 'uncheck staged' : `(cd "${folder}" && npx --no uncheck staged)`, + app === '' ? 'bunx uncheck staged --only=oxfmt' : `cd "${folder}" && bunx uncheck staged`, + ] + + const { stdout, hook, project } = await prepareHook( + `${HEADER}${copies.join('\npnpm test\n')}\n`, + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${HEADER}${line}\npnpm test\npnpm test\n`) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }) + + it('leaves alone the lines that only mention the command or run it another way', async () => { + const others = [ + 'echo "runs uncheck staged"', + 'npx uncheck staged --only=oxfmt && pnpm test', + 'pnpm exec uncheck staged --fix || echo "not blocking"', + 'pnpm exec uncheck staged src/index.ts', + 'pnpm exec uncheck stagedly', + 'pnpm dlx uncheck staged', + ] + + const { hook } = await prepareHook(`#!/bin/sh\n${others.join('\n')}\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${others.join('\n')}\n`) + }) + + it('runs before the commands of the hook and after its setup', async () => { + const setup = [ + '#!/usr/bin/env bash', + '# lint before committing', + '', + 'set -e', + 'unset GIT_INDEX_FILE', + 'export HOOKS="$(dirname "$0")"', + 'source ~/.profile', + '. "$HOOKS/env"', + '\\. "$HOOKS/nvm.sh"', + '[ -f "$HOOKS/local" ] && . "$HOOKS/local"', + 'test -s "$HOOKS/path" && source "$HOOKS/path"', + 'NODE_OPTIONS=--max-old-space-size=4096', + ].join('\n') + const commands = 'export CI=1 && exec pnpm lint-staged\npnpm test\n' + + const { hook } = await prepareHook(`${setup}\n${commands}`) + + expect(hook).toBe(`${setup}\n${line}\n${commands}`) + }) + + it('runs after the setup of husky 8, whose script runs the hook again', async () => { + const setup = '#!/usr/bin/env sh\n. "$(dirname -- "$0")/_/husky.sh"\n\n' + + const { hook } = await prepareHook(`${setup}npx lint-staged\n`) + + expect(hook).toBe(`${setup}${line}\nnpx lint-staged\n`) + }) + + it.each([ + ['a trailing backslash', 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n'], + ['an open double quote', 'MESSAGE="checks\n. before committing"\n'], + ['an open single quote', "MESSAGE='checks\n. before committing'\n"], + ])('runs before a setup line continued by %s', async (_, setup) => { + const { hook } = await prepareHook(`#!/bin/sh\n${setup}npx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${setup}npx lint-staged\n`) + }) + + it.each([ + ['an odd single quote', "# don't commit secrets"], + ['an odd double quote', '# the "main branch'], + ['a trailing backslash', '# see C:\\'], + ])('runs after a setup comment with %s, which continues nothing', async (_, comment) => { + const { hook } = await prepareHook(`#!/bin/sh\n${comment}\nnpx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${comment}\n${line}\nnpx lint-staged\n`) + }) + + it('goes at the end of a hook that only sets up', async () => { + const { hook } = await prepareHook('#!/bin/sh\n# nothing yet\n') + + expect(hook).toBe(`#!/bin/sh\n# nothing yet\n${line}\n`) + }) + + it('ends a hook without a trailing newline before adding its line', async () => { + const { hook } = await prepareHook('#!/bin/sh\n# nothing yet') + + expect(hook).toBe(`#!/bin/sh\n# nothing yet\n${line}\n`) + }) + + it('keeps a hook without a trailing newline as it is around its line', async () => { + const { hook } = await prepareHook('#!/bin/sh\necho "checking"') + + expect(hook).toBe(`#!/bin/sh\n${line}\necho "checking"`) + }) + + it('fills an empty hook with its line alone', async () => { + const { stdout, hook, project } = await prepareHook('') + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${line}\n`) + expect(mode(project, HOOK)).toBe(0o755) + }) + + it('leaves a hook in another language alone and says what it should run', async () => { + const script = '#!/usr/bin/env python3\nprint("checked")\n' + + const { exitCode, stdout, hook, project } = await prepareHook(script) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `it is not a shell script, have it run \`${line}\` yourself`, + ), + ) + expect(hook).toBe(script) + }) + + it.each([ + ['invalid UTF-8', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0xff, 0xfe])], + ['a NUL byte', Buffer.from('#!/bin/sh\necho "\0"\n')], + ])('leaves a hook with %s alone', async (_, bytes) => { + const project = create() + writeFileSync(project.path(HOOK), bytes) + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `it is not a shell script, have it run \`${line}\` yourself`, + ), + ) + expect(readFileSync(project.path(HOOK))).toEqual(bytes) + }) +}) + +describe('prepare with an existing hook in a monorepo', () => { + it('puts the line of a new package after the lines of the others', async () => { + const root = hookLine('') + const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) + const project = monorepo().write({ + [HOOK]: `#!/bin/sh\nexport CI=1\n${root}\n${core}\npnpm test\n`, + }) + + await prepare(project, [], { cwd: 'packages/app' }) + + expect(project.read(HOOK)).toBe( + `#!/bin/sh\nexport CI=1\n${root}\n${core}\n${hookLine('packages/app/')}\npnpm test\n`, + ) + }) + + it('rewrites the lines older versions wrote for the other packages', async () => { + const project = monorepo().write({ + [HOOK]: [ + '#!/bin/sh', + 'pnpm exec uncheck staged --fix', + 'pnpm test', + 'cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint', + 'cd "packages/app" && pnpm exec uncheck staged --fix', + '', + ].join('\n'), + }) + + const { stdout } = await prepare(project, ['--no-fix'], { cwd: 'packages/app' }) + + expect(stdout).toBe(written(project.path(HOOK), 'updated', 'pnpm exec uncheck staged')) + expect(project.read(HOOK)).toBe( + [ + '#!/bin/sh', + 'pnpm exec uncheck staged --fix || exit 1', + 'pnpm test', + '(cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', + '(cd "packages/app" && pnpm exec uncheck staged) || exit 1', + '', + ].join('\n'), + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts new file mode 100644 index 0000000..b4839c5 --- /dev/null +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -0,0 +1,178 @@ +import { readdirSync } from 'node:fs' +import process from 'node:process' + +import type { Project } from '../utils/project' +import { LAYOUTS, monorepo, singleRepo } from '../utils/project' +import { chmod, HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' + +const HOOK = '.git/hooks/pre-commit' + +const ROOT_IGNORES_MODES = process.getuid?.() === 0 + +function leftoverLocksAndCopies(project: Project, folder = '.git/hooks'): string[] { + return readdirSync(project.path(folder)).filter((name) => /\.(?:lock|uncheck-\w+)$/.test(name)) +} + +describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create, app }) => { + // Root reads and writes a file whatever its mode. + it.skipIf(ROOT_IGNORES_MODES)('reports a hooks folder it may not write to', async () => { + const project = create() + chmod(project, '.git/hooks', 0o555) + onTestFinished(() => chmod(project, '.git/hooks', 0o755)) + + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `EACCES: permission denied, open '${project.path(`${HOOK}.lock`)}'`, + ), + ) + expect(project.exists(HOOK)).toBe(false) + }) + + it.skipIf(ROOT_IGNORES_MODES)('reports a hook it may not read and leaves it alone', async () => { + const project = create().write({ [HOOK]: '#!/bin/sh\npnpm test\n' }) + chmod(project, HOOK, 0o200) + onTestFinished(() => chmod(project, HOOK, 0o644)) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `EACCES: permission denied, open '${project.path(HOOK)}'`, + ), + ) + chmod(project, HOOK, 0o644) + expect(project.read(HOOK)).toBe('#!/bin/sh\npnpm test\n') + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) + + it('reports a hooks folder it cannot create below a file', async () => { + const project = create() + project.git('config', 'core.hooksPath', 'package.json/hooks') + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app, 'package.json/hooks/pre-commit'), + `ENOTDIR: not a directory, mkdir '${project.path('package.json/hooks')}'`, + ), + ) + expect(project.git('status', '--porcelain')).toBe('') + }) + + it('reports a hook that is a folder and releases its lock', async () => { + const project = create().write({ [`${HOOK}/.keep`]: '' }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten(shownHook(project, app), 'EISDIR: illegal operation on a directory, read'), + ) + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) + + it('reports a linked script whose name leaves no room for the copy it swaps in', async () => { + const name = 'x'.repeat(240) + const project = create({ [`scripts/${name}`]: '#!/bin/sh\npnpm test\n' }) + project.link(HOOK, `../../scripts/${name}`) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout.replace(/\.uncheck-[\da-f]{12}'/, ".uncheck-'")).toBe( + notWritten( + shownHook(project, app), + `ENAMETOOLONG: name too long, open '${project.path('scripts', name)}.uncheck-'`, + ), + ) + expect(project.read(`scripts/${name}`)).toBe('#!/bin/sh\npnpm test\n') + expect(leftoverLocksAndCopies(project, 'scripts')).toEqual([]) + }) +}) + +describe('prepare waiting for the lock of the hook', () => { + it('gives up on a lock held for too long and leaves it to its owner', async () => { + const project = singleRepo().write({ [`${HOOK}.lock`]: '' }) + + const { exitCode, stdout } = await prepare(project) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten(HOOK, `EEXIST: file already exists, open '${project.path(`${HOOK}.lock`)}'`), + ) + expect(project.exists(HOOK)).toBe(false) + expect(project.exists(`${HOOK}.lock`)).toBe(true) + }) +}) + +describe('prepare in every package of a monorepo at once', () => { + it('keeps the line of every package, as a workspace install running each prepare script does', async () => { + const packages = ['core', 'app', 'web', 'docs', 'cli'] + const project = monorepo( + Object.fromEntries( + packages.map((name) => [ + `packages/${name}/package.json`, + { name: `@repo/${name}`, version: '1.0.0', private: true }, + ]), + ), + ) + const folders = ['', ...packages.map((name) => `packages/${name}/`)] + + const runs = await Promise.all(folders.map((folder) => prepare(project, [], { cwd: folder }))) + + expect(runs.map(({ exitCode }) => exitCode)).toEqual(folders.map(() => 0)) + expect(runs.map(({ stdout }) => stdout.split('\n')[0])).toEqual( + folders.map((folder) => + expect.stringMatching( + new RegExp( + `^✔ pre-commit ${folder === '' ? '' : `${project.dir}/`}\\.git/hooks/pre-commit (?:created|updated)$`, + ), + ), + ), + ) + expect(project.read(HOOK).split('\n').sort()).toEqual( + [...HEADER.split('\n'), ...folders.map((folder) => hookLine(folder))].sort(), + ) + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) +}) + +describe('prepare in a package whose folder name sh would misread', () => { + it.each(['a"b', 'a$b', 'a`b', 'a\\b', 'a\nb'])('writes nothing for %j', async (name) => { + const project = monorepo({ + [`packages/${name}/package.json`]: { name: 'odd', version: '1.0.0', private: true }, + }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: `packages/${name}` }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + project.path(HOOK), + `sh would misread the folder name ${JSON.stringify(`packages/${name}`)} between double quotes`, + ), + ) + expect(project.exists(HOOK)).toBe(false) + }) + + it('writes the line for a folder name with spaces and single quotes, which sh reads as it is', async () => { + const name = "it's my app" + const project = monorepo({ + [`packages/${name}/package.json`]: { name: 'spaced', version: '1.0.0', private: true }, + }) + + const { stdout } = await prepare(project, [], { cwd: `packages/${name}` }) + + expect(stdout).toBe(written(project.path(HOOK), 'created')) + expect(project.read(HOOK)).toBe(`${HEADER}${hookLine(`packages/${name}/`)}\n`) + }) +}) diff --git a/packages/uncheck/tests/prepare/hook.test.ts b/packages/uncheck/tests/prepare/hook.test.ts new file mode 100644 index 0000000..b1ef993 --- /dev/null +++ b/packages/uncheck/tests/prepare/hook.test.ts @@ -0,0 +1,214 @@ +import { dirname } from 'node:path' + +import { LAYOUTS, monorepo, singleRepo } from '../utils/project' +import { chmod, COMMAND, HEADER, hookLine, mode, prepare, shownHook, written } from './utils' + +describe('prepare flags', () => { + it('describes itself and the hook its flags write', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['prepare', '--help']) + + expect(stdout).toContain( + [ + 'DESCRIPTION', + ' Set up git hooks, for the `prepare` script in package.json (`postinstall` with Yarn 2+) so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`', + '', + 'USAGE', + ' uncheck prepare [flags]', + '', + ].join('\n'), + ) + expect(stdout).toContain( + [ + ' --pre-commit Write the git pre-commit hook, which runs `uncheck staged` on the files of every commit', + ' --fix Have the hook apply and stage fixes as well as report. On by default, --no-fix only checks', + ' --allow-empty Have the hook let a commit through when the fixes undo every staged change, which makes it empty', + ].join('\n'), + ) + + for (const flag of ['--cwd', '--only', '--require', '--skip']) { + expect(stdout).toMatch(new RegExp(`^ {2}${flag} `, 'm')) + } + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('refuses a directory that does not exist', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await prepare(project, ['--cwd=missing']) + + expect(stdout).toContain('USAGE\n uncheck prepare [flags]\n') + expect(stderr).toBe( + `\nERROR\n Invalid value for flag --cwd: "missing". Expected: Path does not exist: ${project.path('missing')}\n`, + ) + expect(exitCode).toBe(1) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('asks for --pre-commit, the only thing it prepares', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck(['prepare']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + '\nERROR\n Nothing to prepare. Pass --pre-commit to write the git hook that runs `uncheck staged --fix` before every commit.\n', + ) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('refuses a check selection that contradicts itself before writing anything', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await prepare(project, ['--only=oxlint', '--skip=oxlint']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe('\nERROR\n --only=oxlint and --skip=oxlint contradict each other.\n') + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) + +describe.each(LAYOUTS)('prepare outside a working tree in a $name', ({ create, app }) => { + it('has nothing to prepare without a git repository', async () => { + const project = create({}, { git: 'none' }) + + // Without a ceiling, git would find a repository around the temporary folder and hook it. + const { exitCode, stdout, stderr } = await prepare(project, [], { + cwd: app, + env: { GIT_CEILING_DIRECTORIES: dirname(project.dir) }, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + }) + + it('has nothing to prepare inside the .git folder', async () => { + const project = create() + + const { exitCode, stdout } = await prepare(project, ['--cwd=.git/hooks']) + + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('has nothing to prepare in a bare repository', async () => { + const project = create() + project.git('clone', '--bare', '--quiet', '.', 'mirror.git') + + const { exitCode, stdout } = await prepare(project, [], { cwd: 'mirror.git' }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + expect(project.exists('mirror.git/hooks/pre-commit')).toBe(false) + }) +}) + +describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { + it('creates an executable pre-commit hook that runs uncheck staged', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + expect(project.git('status', '--porcelain')).toBe('') + }) + + it('creates the hooks folder when git has none', async () => { + const project = create() + project.write({ '.git/hooks': null }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it('leaves an up to date hook unchanged and makes it executable again', async () => { + const project = create() + await prepare(project, [], { cwd: app }) + chmod(project, '.git/hooks/pre-commit', 0o644) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'unchanged')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + }) + + it.each([ + [['--no-fix'], 'pnpm exec uncheck staged'], + [['--allow-empty'], 'pnpm exec uncheck staged --fix --allow-empty'], + [ + ['--only=oxlint', '--only=oxfmt'], + 'pnpm exec uncheck staged --fix --only=oxlint --only=oxfmt', + ], + [ + ['--no-fix', '--require=tsc', '--skip=sherif'], + 'pnpm exec uncheck staged --require=tsc --skip=sherif', + ], + ])('updates the hook command for %j', async (flags, command) => { + const project = create() + await prepare(project, [], { cwd: app }) + + const { exitCode, stdout } = await prepare(project, flags, { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'updated', command)) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app, command)}\n`) + expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + }) + + it('takes the directory from --cwd', async () => { + const project = create() + + const { exitCode, stdout } = await prepare(project, [`--cwd=${project.path(app)}`], { + cwd: '.git', + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) +}) + +describe('prepare in several packages of a monorepo', () => { + const root = hookLine('') + const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) + const app = hookLine('packages/app/') + + it('keeps a line for each package, in the order they were prepared', async () => { + const project = monorepo() + + await prepare(project) + await prepare(project, ['--only=oxlint'], { cwd: 'packages/core' }) + const { stdout } = await prepare(project, [], { cwd: 'packages/app' }) + + expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'updated')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${root}\n${core}\n${app}\n`) + }) + + it('updates the line of one package in place and leaves the others alone', async () => { + const project = monorepo() + await prepare(project) + await prepare(project, ['--only=oxlint'], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + + const { stdout } = await prepare(project, ['--no-fix']) + + expect(stdout).toBe(written('.git/hooks/pre-commit', 'updated', 'pnpm exec uncheck staged')) + expect(project.read('.git/hooks/pre-commit')).toBe( + `${HEADER}${hookLine('', 'pnpm exec uncheck staged')}\n${core}\n${app}\n`, + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/linked-checkouts.test.ts b/packages/uncheck/tests/prepare/linked-checkouts.test.ts new file mode 100644 index 0000000..23e8f73 --- /dev/null +++ b/packages/uncheck/tests/prepare/linked-checkouts.test.ts @@ -0,0 +1,75 @@ +import { join } from 'node:path' + +import type { Project } from '../utils/project' +import { CLI, LAYOUTS, monorepo, run, singleRepo, temporaryDirectory } from '../utils/project' +import { chmod, HEADER, hookLine, prepare, written } from './utils' + +function addWorktree(project: Project): string { + const worktree = join(temporaryDirectory(), 'wt') + + project.git('worktree', 'add', '--quiet', worktree) + + return worktree +} + +describe.each(LAYOUTS)('prepare in a linked worktree of a $name', ({ create, app }) => { + it('writes the hook the worktree shares with the main one, named by its full path', async () => { + const project = create() + const worktree = addWorktree(project) + + const { exitCode, stdout, stderr } = await run([...CLI, 'prepare', '--pre-commit'], { + cwd: join(worktree, app), + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.exists('.git/worktrees/wt/hooks')).toBe(false) + }) +}) + +describe('prepare run by a git hook in a linked worktree of a monorepo', () => { + it('enters the package folder although git exported the git folder of the worktree', async () => { + const project = monorepo() + const worktree = addWorktree(project) + project.write({ + '.git/hooks/post-checkout': `#!/bin/sh\ncd packages/app && '${project.path('node_modules/.bin/uncheck')}' prepare --pre-commit\n`, + }) + chmod(project, '.git/hooks/post-checkout', 0o755) + + const { exitCode, stderr } = await run(['git', 'checkout', '--quiet', '-b', 'feature'], { + cwd: worktree, + }) + + expect(exitCode).toBe(0) + expect(stderr).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine('packages/app/')}\n`) + }) +}) + +describe.each(LAYOUTS)('prepare in a submodule that is a $name', ({ create, app }) => { + it('writes the hook the superproject keeps for the submodule, named by its full path', async () => { + const superproject = singleRepo() + superproject.git( + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + create().dir, + 'vendor/lib', + ) + const hook = '.git/modules/vendor/lib/hooks/pre-commit' + + const { exitCode, stdout, stderr } = await prepare(superproject, [], { + cwd: `vendor/lib/${app}`, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe(written(superproject.path(hook), 'created')) + expect(superproject.read(hook)).toBe(`${HEADER}${hookLine(app)}\n`) + expect(superproject.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts new file mode 100644 index 0000000..3c9ea98 --- /dev/null +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -0,0 +1,253 @@ +import { lstatSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { LAYOUTS, temporaryDirectory } from '../utils/project' +import { + chmod, + DISPATCHER, + gitConfig, + HEADER, + hookLine, + HUSKY_SHIM, + installHusky, + mode, + notWritten, + prepare, + shownHook, + withGitWithoutShowScope, + written, +} from './utils' + +const SHARED_HOOK = '#!/bin/sh\necho "scanning for secrets"\n' + +function sharedHooks(): string { + const dir = temporaryDirectory() + + writeFileSync(join(dir, 'pre-commit'), SHARED_HOOK) + + return dir +} + +describe.each(LAYOUTS)('prepare with core.hooksPath in a $name', ({ create, app }) => { + it('writes into a hooks folder set in the repository config', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + expect(project.read('.githooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, '.githooks/pre-commit')).toBe(0o755) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('names a hooks folder outside the project by its full path', async () => { + const project = create() + const outside = temporaryDirectory() + project.git('config', 'core.hooksPath', outside) + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(join(outside, 'pre-commit'), 'created')) + expect(readFileSync(join(outside, 'pre-commit'), 'utf8')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it.each([ + ['global', (file: string) => ({ GIT_CONFIG_GLOBAL: file })], + ['system', (file: string) => ({ GIT_CONFIG_SYSTEM: file, GIT_CONFIG_NOSYSTEM: undefined })], + ])('leaves a hooks folder set in the %s config alone', async (scope, env) => { + const project = create() + const shared = sharedHooks() + + const { exitCode, stdout } = await prepare(project, [], { + cwd: app, + env: env(gitConfig(`[core]\n\thooksPath = ${shared}\n`)), + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + `core.hooksPath is set in the ${scope} git config, so every repository runs it`, + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) + + it('leaves a hooks folder set in a file the global config includes alone', async () => { + const project = create() + const shared = sharedHooks() + const included = gitConfig(`[core]\n\thooksPath = ${shared}\n`) + + const { stdout } = await prepare(project, [], { + cwd: app, + env: { GIT_CONFIG_GLOBAL: gitConfig(`[include]\n\tpath = ${included}\n`) }, + }) + + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + 'core.hooksPath is set in the global git config, so every repository runs it', + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) + + it('writes into a hooks folder the repository config sets over the global one', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { + cwd: app, + env: { GIT_CONFIG_GLOBAL: gitConfig(`[core]\n\thooksPath = ${sharedHooks()}\n`) }, + }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + }) +}) + +describe.each(LAYOUTS)('prepare with a git older than 2.26 in a $name', ({ create, app }) => { + it('writes the hook when core.hooksPath is not set', async () => { + const project = create() + + const { stdout } = await prepare(project, [], { cwd: app, env: withGitWithoutShowScope() }) + + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it('writes into a hooks folder set in the repository config', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { cwd: app, env: withGitWithoutShowScope() }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + }) + + it.each([ + ['global', (file: string) => ({ GIT_CONFIG_GLOBAL: file })], + ['system', (file: string) => ({ GIT_CONFIG_SYSTEM: file, GIT_CONFIG_NOSYSTEM: undefined })], + ])('leaves a hooks folder set in the %s config alone', async (scope, env) => { + const project = create() + const shared = sharedHooks() + + const { stdout } = await prepare(project, [], { + cwd: app, + env: withGitWithoutShowScope(env(gitConfig(`[core]\n\thooksPath = ${shared}\n`))), + }) + + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + `core.hooksPath is set in the ${scope} git config, so every repository runs it`, + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) +}) + +describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, app }) => { + function husky() { + return installHusky(create(), 'echo "husky hook ran"\n') + } + + it('writes the hook husky 9 runs, not its generated shim, and keeps its mode', async () => { + const project = husky() + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'updated')) + expect(project.read('.husky/pre-commit')).toBe(`${hookLine(app)}\necho "husky hook ran"\n`) + expect(mode(project, '.husky/pre-commit')).toBe(0o644) + expect(project.read('.husky/_/pre-commit')).toBe(HUSKY_SHIM) + }) + + it('leaves the mode of an unchanged husky hook alone', async () => { + const project = husky() + await prepare(project, [], { cwd: app }) + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'unchanged')) + expect(mode(project, '.husky/pre-commit')).toBe(0o644) + }) + + it('keeps the mode the user gave a husky hook when updating it', async () => { + const project = husky() + chmod(project, '.husky/pre-commit', 0o700) + + const { exitCode, stdout } = await prepare(project, ['--no-fix'], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + written(shownHook(project, app, '.husky/pre-commit'), 'updated', 'pnpm exec uncheck staged'), + ) + expect(project.read('.husky/pre-commit')).toBe( + `${hookLine(app, 'pnpm exec uncheck staged')}\necho "husky hook ran"\n`, + ) + expect(mode(project, '.husky/pre-commit')).toBe(0o700) + }) + + it('creates the hook the Vite+ dispatcher runs, without making it executable', async () => { + const project = create({ '.vite-hooks/_/h': DISPATCHER }) + project.git('config', 'core.hooksPath', '.vite-hooks/_') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.vite-hooks/pre-commit'), 'created')) + expect(project.read('.vite-hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, '.vite-hooks/pre-commit') & 0o111).toBe(0) + expect(project.exists('.vite-hooks/_/pre-commit')).toBe(false) + }) + + it('writes into a hooks folder with an h script as it is, when not named _', async () => { + const project = create({ '.githooks/h': 'echo "help"\n' }) + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + expect(mode(project, '.githooks/pre-commit')).toBe(0o755) + }) + + it('writes into a hooks folder named _ as it is, when its h cannot be read', async () => { + const project = create() + project.link('.hooks/_/h', 'h') + project.git('config', 'core.hooksPath', '.hooks/_') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.hooks/_/pre-commit'), 'created')) + expect(project.read('.hooks/_/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, '.hooks/_/pre-commit')).toBe(0o755) + }) +}) + +describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, app }) => { + it('updates the script the link points to and keeps the link', async () => { + const project = create({ 'scripts/pre-commit': '#!/bin/sh\npnpm test\n' }) + project.link('.git/hooks/pre-commit', '../../scripts/pre-commit') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) + expect(project.read('scripts/pre-commit')).toBe(`#!/bin/sh\n${hookLine(app)}\npnpm test\n`) + expect(mode(project, 'scripts/pre-commit')).toBe(0o755) + }) + + it('creates the script a dangling link points to', async () => { + const project = create() + project.link('.git/hooks/pre-commit', '../../hooks/pre-commit') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) + expect(project.read('hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(mode(project, 'hooks/pre-commit')).toBe(0o755) + }) +}) diff --git a/packages/uncheck/tests/prepare/package-manager.test.ts b/packages/uncheck/tests/prepare/package-manager.test.ts new file mode 100644 index 0000000..6eb267c --- /dev/null +++ b/packages/uncheck/tests/prepare/package-manager.test.ts @@ -0,0 +1,102 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, monorepo } from '../utils/project' +import { HEADER, hookLine, prepare, shownHook, written } from './utils' + +const YARN = 'yarn run --silent uncheck staged --fix' + +function declarePackageManager(project: Project, dir: string, packageManager: unknown): void { + const manifest = JSON.parse(project.read(`${dir}package.json`)) as object + + project.write({ [`${dir}package.json`]: { ...manifest, packageManager } }) +} + +describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ create, app }) => { + async function expectHookCommand(project: Project, command: string): Promise { + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'created', command)) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app, command)}\n`) + } + + it.each([ + ['pnpm-lock.yaml', 'pnpm exec uncheck staged --fix'], + ['yarn.lock', YARN], + ['bun.lock', 'bunx --no-install uncheck staged --fix'], + ['bun.lockb', 'bunx --no-install uncheck staged --fix'], + ['package-lock.json', 'npx --no uncheck staged --fix'], + ])('runs uncheck through the package manager of %s', async (lockfile, command) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}${lockfile}`]: '' }) + + await expectHookCommand(project, command) + }) + + it.each([ + ['pnpm@10.0.0', 'pnpm exec uncheck staged --fix'], + ['yarn@1.22.22', YARN], + ['yarn@4.5.0', YARN], + ['bun@1.2.0', 'bunx --no-install uncheck staged --fix'], + ['npm@11.0.0', 'npx --no uncheck staged --fix'], + ])( + 'runs uncheck through the declared packageManager %s over the lockfile', + async (declared, command) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + declarePackageManager(project, app, declared) + + await expectHookCommand(project, command) + }, + ) + + it.each([ + ['an unknown packageManager', 'deno@2.0.0'], + ['a packageManager that is not a string', { name: 'bun' }], + ])('falls back to the lockfile with %s', async (_, declared) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + declarePackageManager(project, app, declared) + + await expectHookCommand(project, YARN) + }) + + it('skips a lockfile that cannot be resolved', async () => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + project.link(`${app}pnpm-lock.yaml`, 'pnpm-lock.yaml') + + await expectHookCommand(project, YARN) + }) + + it.each([ + ['not valid JSON', '{ "packageManager": "bun@1.2.0", }\n'], + ['not an object', '"bun@1.2.0"\n'], + ])('skips a package.json that is %s', async (_, manifest) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + project.write({ [`${app}package.json`]: manifest }) + + await expectHookCommand(project, YARN) + }) + + it('runs uncheck with npx --no when nothing names a package manager', async () => { + const project = create({ 'pnpm-lock.yaml': null }) + declarePackageManager(project, '', undefined) + + await expectHookCommand(project, 'npx --no uncheck staged --fix') + }) +}) + +describe('prepare picking the package manager in the packages of a monorepo', () => { + it('takes the package manager of the nearest folder that names one', async () => { + const project = monorepo({ 'packages/app/bun.lockb': '' }) + const hook = project.path('.git/hooks/pre-commit') + + const app = await prepare(project, [], { cwd: 'packages/app' }) + const core = await prepare(project, [], { cwd: 'packages/core' }) + + expect(app.stdout).toBe(written(hook, 'created', 'bunx --no-install uncheck staged --fix')) + expect(core.stdout).toBe(written(hook, 'updated', 'pnpm exec uncheck staged --fix')) + expect(project.read('.git/hooks/pre-commit')).toBe( + [ + HEADER, + '(cd "packages/app" && bunx --no-install uncheck staged --fix) || exit 1\n', + '(cd "packages/core" && pnpm exec uncheck staged --fix) || exit 1\n', + ].join(''), + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts new file mode 100644 index 0000000..e941c12 --- /dev/null +++ b/packages/uncheck/tests/prepare/utils.ts @@ -0,0 +1,100 @@ +import { execFileSync } from 'node:child_process' +import { chmodSync, statSync, writeFileSync } from 'node:fs' +import { delimiter, join } from 'node:path' + +import type { Env, Project, Run, RunOptions } from '../utils/project' +import { environment, temporaryDirectory } from '../utils/project' + +export const HEADER = + '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' + +export const COMMAND = 'pnpm exec uncheck staged --fix' + +export function prepare( + project: Project, + args: ReadonlyArray = [], + options?: RunOptions, +): Promise { + return project.uncheck(['prepare', '--pre-commit', ...args], options) +} + +export function hookLine(app: string, command = COMMAND): string { + return app === '' ? `${command} || exit 1` : `(cd "${app.slice(0, -1)}" && ${command}) || exit 1` +} + +export function shownHook(project: Project, app: string, hook = '.git/hooks/pre-commit'): string { + return app === '' ? hook : project.path(hook) +} + +export function written( + shown: string, + result: 'created' | 'updated' | 'unchanged', + command = COMMAND, +): string { + return `✔ pre-commit ${shown} ${result}\n\nThe hook runs ${command} before every commit, \`git commit --no-verify\` skips it.\n` +} + +export function notWritten(shown: string, reason: string): string { + return `✘ pre-commit ${shown} not written, ${reason}\n` +} + +export function mode(project: Project, file: string): number { + return statSync(project.path(file)).mode & 0o7777 +} + +export function chmod(project: Project, file: string, value: number): void { + chmodSync(project.path(file), value) +} + +export function gitConfig(content: string): string { + const file = join(temporaryDirectory(), 'gitconfig') + + writeFileSync(file, content) + + return file +} + +export function withGitWithoutShowScope(env: Env = {}): Env { + const real = execFileSync('sh', ['-c', 'command -v git'], { + encoding: 'utf8', + env: environment(), + }).trim() + const bin = temporaryDirectory() + + writeFileSync( + join(bin, 'git'), + [ + '#!/bin/sh', + `case " $* " in *" --show-scope "*) echo "error: unknown option 'show-scope'" >&2; exit 129;; esac`, + `exec '${real}' "$@"`, + '', + ].join('\n'), + { mode: 0o755 }, + ) + + return { ...env, PATH: `${bin}${delimiter}${environment().PATH}` } +} + +export const DISPATCHER = [ + 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', + '[ ! -f "$s" ] && exit 0', + 'export PATH="node_modules/.bin:$PATH"', + 'sh -e "$s" "$@"', + 'exit $?', + '', +].join('\n') + +export const HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' + +export function installHusky(project: Project, hook: string): Project { + project.write({ + '.husky/_/h': DISPATCHER, + '.husky/_/pre-commit': HUSKY_SHIM, + '.husky/pre-commit': hook, + }) + chmod(project, '.husky/_/pre-commit', 0o755) + chmod(project, '.husky/pre-commit', 0o644) + project.git('config', 'core.hooksPath', '.husky/_') + + return project +} diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts new file mode 100644 index 0000000..a7898e5 --- /dev/null +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -0,0 +1,261 @@ +import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { failure, folderOf, inIndex, stage, TSC_COMMAND } from './utils' + +describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { + const folder = folderOf(app) + + it('needs a git repository', async () => { + const project = create({}, { git: 'none' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe(failure('`uncheck staged` needs a git repository')) + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) + + it('has nothing to check when nothing is staged', async () => { + const project = create() + + project.write({ [`${app}src/index.ts`]: 'var unstaged = 1\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, no staged files', + ]) + }) + + it('passes clean staged files and leaves unstaged and untracked ones out', async () => { + const project = create() + + stage(project, { [`${app}src/extra.ts`]: 'export const extra: number = 1;\n' }) + project.write({ + [`${app}src/index.ts`]: `${project.read(`${app}src/index.ts`)}var unstaged = 1\n`, + [`${app}src/untracked.ts`]: 'var untracked = 1\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + TSC_COMMAND[name], + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + }) + + it('fails on lint, format and type errors in the staged files without touching them', async () => { + const broken = 'var answer: number = "42"\nexport { answer }\n' + const project = stage(create(), { [`${app}src/index.ts`]: broken }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern src/index.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', + '✘ oxfmt failed', + TSC_COMMAND[name], + '✘ tsc failed', + '✘ 3 of 3 checks failed: oxlint, oxfmt, tsc', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(stdout).toContain("error TS2322: Type 'string' is not assignable to type 'number'.") + expect(inIndex(project, `${app}src/index.ts`)).toBe(broken) + expect(project.read(`${app}src/index.ts`)).toBe(broken) + }) + + it('passes --skip, --only and --require through to the checks', async () => { + const project = stage(create({ [`${app}README.md`]: '# app\n' }), { + [`${app}README.md`]: '# app\n\nmore\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + const skipped = await project.uncheck(['staged', '--skip=oxlint', '--skip=tsc'], { + cwd: folder, + }) + + expect(skipped.exitCode).toBe(1) + expect(report(skipped.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, disabled with --skip=oxlint', + '▶ oxfmt --check --no-error-on-unmatched-pattern README.md src/extra.ts', + '✘ oxfmt failed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + + project.git('reset', '--quiet', '--', `${app}src/extra.ts`) + + const unneeded = await project.uncheck(['staged', '--only=tsc'], { cwd: folder }) + + expect(unneeded.exitCode).toBe(0) + expect(report(unneeded.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, no tsconfig.json covers the given files', + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', + ]) + + const required = await project.uncheck(['staged', '--only=tsc', '--require=tsc'], { + cwd: folder, + }) + + expect(required.exitCode).toBe(1) + expect(report(required.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '✘ tsc no tsconfig.json covers the given files', + '✘ 1 of 1 checks failed: tsc', + ]) + }) + + it('lists more than three files as a count', async () => { + const project = stage(create(), { + [`${app}src/a.ts`]: 'export const a = 1\n', + [`${app}src/b.ts`]: 'export const b = 1\n', + [`${app}src/c.ts`]: 'export const c = 1\n', + [`${app}src/d.ts`]: 'export const d = 1\n', + }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern [4 files]', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + '✔ staged the fixes to [4 files]', + ]) + expect(inIndex(project, `${app}src/d.ts`)).toBe('export const d = 1;\n') + }) +}) + +describe('uncheck staged flags', () => { + it('rejects a selection that contradicts itself before looking at git', async () => { + const project = singleRepo({}, { git: 'none' }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--skip=oxlint', + ]) + + expect(stderr).toBe(failure('--only=oxlint and --skip=oxlint contradict each other.')) + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck staged in a package of a monorepo', () => { + const edits = { + 'packages/app/src/index.ts': 'export const app = 1\n', + 'packages/core/src/index.ts': 'export const core = 1\n', + } + + it('checks and fixes only the staged files of the package it runs in', async () => { + const project = stage(monorepo(), edits) + + const inside = await project.uncheck(['staged', '--only=oxfmt'], { cwd: 'packages/app' }) + const fromTop = await project.uncheck(['staged', '--only=oxfmt', '--cwd', 'packages/app']) + + for (const { exitCode, stdout } of [inside, fromTop]) { + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + } + + const fixed = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: 'packages/app', + }) + + expect(fixed.exitCode).toBe(0) + expect(report(fixed.stdout).at(-1)).toBe('✔ staged the fixes to src/index.ts') + expect(inIndex(project, 'packages/app/src/index.ts')).toBe('export const app = 1;\n') + expect(inIndex(project, 'packages/core/src/index.ts')).toBe(edits['packages/core/src/index.ts']) + }) + + it('reports what sherif finds in the workspace but leaves its fixes to `uncheck --fix`', async () => { + const manifest = { + name: '@repo/app', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { '@repo/core': 'workspace:*' }, + devDependencies: { typescript: '^6.0.0' }, + } + const project = stage(monorepo(), { + 'packages/app/package.json': manifest, + 'packages/app/src/index.ts': 'var app = 1\nexport { app }\n', + }) + + const checked = await project.uncheck(['staged', '--skip=tsc']) + + expect(checked.exitCode).toBe(1) + expect(report(checked.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 3 of 3 checks failed: sherif, oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + + const fixed = await project.uncheck(['staged', '--fix', '--skip=tsc']) + + expect(fixed.exitCode).toBe(1) + expect(report(fixed.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 1 of 3 checks failed: sherif', + '✔ staged the fixes to packages/app/src/index.ts', + ]) + expect(JSON.parse(inIndex(project, 'packages/app/package.json'))).toEqual(manifest) + expect(JSON.parse(project.read('packages/app/package.json'))).toEqual(manifest) + }) +}) diff --git a/packages/uncheck/tests/staged/commit.test.ts b/packages/uncheck/tests/staged/commit.test.ts new file mode 100644 index 0000000..24d00be --- /dev/null +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -0,0 +1,98 @@ +import { chmodSync } from 'node:fs' + +import { LAYOUTS, report, run } from '../utils/project' +import type { Project } from '../utils/project' +import { inIndex, linkedWorktree, stage, stagePartially, status, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ create, app }) => { + const file = `${app}src/extra.ts` + const other = `${app}src/other.ts` + + function hooked(): Project { + const project = create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }) + + project.write({ + '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix) || exit 1\n`, + }) + chmodSync(project.path('.git/hooks/pre-commit'), 0o755) + + return project + } + + it('fixes the files of `git commit ` in the commit and in the index it leaves', async () => { + const project = hooked() + + stage(project, { [other]: 'export const other = 2;\n' }) + project.write({ [file]: VERSIONS.staged }) + + const { exitCode, stderr } = await run( + ['git', 'commit', '--quiet', '--message=extra', '--', file], + { cwd: project.path('.') }, + ) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 1;\n') + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + expect(status(project)).toBe(`M ${other}\n`) + }) + + it('fixes the files of `git commit -a` in the commit it makes', async () => { + const project = hooked() + + project.write({ [file]: VERSIONS.staged, [other]: 'export const other = 2\n' }) + + const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--all', '--message=all'], { + cwd: project.path('.'), + }) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/other.ts') + expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') + expect(status(project)).toBe('') + }) + + describe('in a linked worktree, whose hook git hands GIT_DIR', () => { + it('sets the unstaged changes aside and puts them back', async () => { + const project = hooked() + const worktree = stagePartially(linkedWorktree(project, app), file) + + const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--message=extra'], { + cwd: worktree.dir, + }) + + expect(exitCode).toBe(0) + expect(report(stderr).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(worktree.read(file)).toBe(VERSIONS.merged) + expect(status(worktree)).toBe(` M ${file}\n`) + expect(project.exists('.git/worktrees/linked/uncheck-unstaged')).toBe(false) + }) + + it('fixes the files of `git commit ` in the index the worktree keeps', async () => { + const project = hooked() + const worktree = linkedWorktree(project, app) + + stage(worktree, { [other]: 'export const other = 2;\n' }) + worktree.write({ [file]: VERSIONS.staged }) + + const { exitCode, stderr } = await run( + ['git', 'commit', '--quiet', '--message=extra', '--', file], + { cwd: worktree.dir }, + ) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(inIndex(worktree, file)).toBe(VERSIONS.fixed) + expect(status(worktree)).toBe(`M ${other}\n`) + expect(status(project)).toBe('') + }) + }) +}) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts new file mode 100644 index 0000000..120ab87 --- /dev/null +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -0,0 +1,256 @@ +import { chmodSync, mkdirSync } from 'node:fs' + +import { git, LAYOUTS, report } from '../utils/project' +import { + EMPTY_COMMIT_ERROR, + failure, + folderOf, + inIndex, + mode, + normalized, + stage, + status, + TSC_COMMAND, + UNTRANSLATED, +} from './utils' + +describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app }) => { + const folder = folderOf(app) + + it('applies the lint and format fixes and stages them', async () => { + const project = stage(create(), { + [`${app}src/index.ts`]: 'var answer = 42\nexport { answer }\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', + '✔ oxfmt passed', + TSC_COMMAND[name], + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + '✔ staged the fixes to src/index.ts', + ]) + expect(inIndex(project, `${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') + expect(project.read(`${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') + expect(status(project)).toBe(`M ${app}src/index.ts\n`) + }) + + it('stages the fixes even when a check still fails', async () => { + const project = stage(create(), { + [`${app}src/index.ts`]: 'export const answer: number = "42"\n', + }) + + const { exitCode, stdout } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([ + '✘ 1 of 2 checks failed: tsc', + '✔ staged the fixes to src/index.ts', + ]) + expect(inIndex(project, `${app}src/index.ts`)).toBe('export const answer: number = "42";\n') + }) + + it('stages nothing when the fixes change nothing', async () => { + const project = stage(create(), { [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(status(project)).toBe(`A ${app}src/extra.ts\n`) + }) + + it('fails when the fixes undo every staged change, unless empty commits are allowed', async () => { + const project = create({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + const unformatted = { [`${app}src/extra.ts`]: 'export const extra = 1\n' } + + stage(project, unformatted) + + const empty = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(empty.stderr).toBe(failure(EMPTY_COMMIT_ERROR)) + expect(empty.exitCode).toBe(1) + expect(report(empty.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(status(project)).toBe('') + + stage(project, unformatted) + + const allowed = await project.uncheck(['staged', '--fix', '--allow-empty', '--only=oxfmt'], { + cwd: folder, + }) + + expect(allowed.stderr).toBe('') + expect(allowed.exitCode).toBe(0) + expect(report(allowed.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(status(project)).toBe('') + }) + + it('reports why git refused to stage the fixes', async () => { + const project = stage(create(), { [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + + project.write({ '.git/index.lock': '' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder, env: UNTRANSLATED }, + ) + + expect(normalized(project, stderr)).toMatch( + /^\nERROR\n {2}git update-index \[1 paths\] failed: fatal: Unable to create '\/\.git\/index\.lock': File exists\.\n/, + ) + expect(exitCode).toBe(1) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1\n') + }) + + it('stages the fixes of the first commit, which has no HEAD to compare with', async () => { + const project = create( + { [`${app}src/extra.ts`]: 'export const extra = 1\n' }, + { git: 'init' }, + ) + + project.git('add', '--all') + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.git('diff', '--name-only')).toBe('') + }) + + it('keeps the executable mode of a staged file it fixes', async () => { + const project = create() + const file = `${app}src/run.ts` + + project.write({ [file]: 'export const run = 1\n' }) + chmodSync(project.path(file), 0o755) + project.git('add', '--', file) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/run.ts') + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/run.ts') + expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) + expect(inIndex(project, file)).toBe('export const run = 1;\n') + expect(mode(project, file)).toBe(0o755) + expect(status(project)).toBe(`A ${file}\n`) + }) + + it('checks regular files only, never a staged symlink or deletion', async () => { + const project = create({ [`${app}src/gone.ts`]: 'export const gone = 1;\n' }) + + project.link(`${app}src/link.ts`, 'index.ts').commit('link') + project.git('rm', '--quiet', '--', `${app}src/gone.ts`) + project.write({ [`${app}src/messy.ts`]: 'export const messy = 1\n' }) + project.link(`${app}src/alias.ts`, 'messy.ts') + project.git('add', '--', `${app}src/alias.ts`) + + const nothing = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(nothing.exitCode).toBe(0) + expect(report(nothing.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, no staged files', + ]) + + project.write({ [`${app}src/link.ts`]: null }) + stage(project, { [`${app}src/link.ts`]: 'export const link = 1\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts') + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/link.ts') + expect(inIndex(project, `${app}src/link.ts`)).toBe('export const link = 1;\n') + expect(project.read(`${app}src/messy.ts`)).toBe('export const messy = 1\n') + expect(status(project)).toBe( + `A ${app}src/alias.ts\nD ${app}src/gone.ts\nT ${app}src/link.ts\n`, + ) + }) + + it('fixes and stages exactly the staged files, whatever their names look like', async () => { + const project = create({ [`${app}routes/i/page.ts`]: 'export const i = 1;\n' }) + + project.write({ [`${app}routes/i/page.ts`]: 'export const i = 2\n' }) + stage(project, { + [`${app}!x.ts`]: 'export const bang = 1\n', + [`${app}-x.ts`]: 'export const dash = 1\n', + [`${app}routes/[id]/page.ts`]: 'export const id = 1\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], + { cwd: folder, env: { GIT_GLOB_PATHSPECS: '1', GIT_ICASE_PATHSPECS: '1' } }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern !x.ts -x.ts routes/[id]/page.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern !x.ts -x.ts routes/[id]/page.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + '✔ staged the fixes to !x.ts -x.ts routes/[id]/page.ts', + ]) + expect(inIndex(project, `${app}!x.ts`)).toBe('export const bang = 1;\n') + expect(inIndex(project, `${app}-x.ts`)).toBe('export const dash = 1;\n') + expect(inIndex(project, `${app}routes/[id]/page.ts`)).toBe('export const id = 1;\n') + expect(status(project)).toBe( + `A ${app}!x.ts\nA ${app}-x.ts\nA ${app}routes/[id]/page.ts\n M ${app}routes/i/page.ts\n`, + ) + }) + + it('never stages the commit a submodule has checked out but not staged', async () => { + const project = create() + const submodule = project.path(`${app}vendor`) + const commit = (message: string) => + git(submodule, ['commit', '--quiet', '--allow-empty', `--message=${message}`]) + + mkdirSync(submodule) + git(submodule, ['init', '--quiet']) + commit('one') + project.git('add', '--', `${app}vendor`) + project.commit('submodule') + commit('two') + project.git('add', '--', `${app}vendor`) + stage(project, { [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + commit('three') + + const before = project.git('rev-parse', `:${app}vendor`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(project.git('rev-parse', `:${app}vendor`)).toBe(before) + }) +}) diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts new file mode 100644 index 0000000..a47b8d0 --- /dev/null +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -0,0 +1,59 @@ +import { writeFileSync } from 'node:fs' + +import { LAYOUTS, report } from '../utils/project' +import { folderOf, inIndex, stagePartially, startUncheck, status, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + + it('puts the unstaged changes back when a closed terminal hangs up during a slow check', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n") + + const started = startUncheck(project, ['staged', '--only=oxlint'], { cwd: folder }) + + await started.printed('waiting') + started.child.kill('SIGHUP') + started.child.kill('SIGHUP') + + const { exitCode, stdout } = await started.exited + + expect(exitCode).toBe(130) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('puts the unstaged changes back when nothing reads its output any more', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + const go = project.path('.git/go') + + project.fake( + 'oxlint', + `const { existsSync } = require('node:fs')\nconsole.log('waiting')\nconst wait = () => (existsSync(${JSON.stringify(go)}) ? console.log('done') : setTimeout(wait, 10))\nwait()\nsetTimeout(() => process.exit(1), 60_000).unref()\n`, + ) + + const started = startUncheck(project, ['staged', '--fix', '--only=oxlint'], { cwd: folder }) + + await started.printed('waiting') + started.child.stdout.destroy() + started.child.stderr.destroy() + writeFileSync(go, '') + + const { exitCode } = await started.exited + + expect(exitCode).toBe(0) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(status(project)).toBe(`MM ${file}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/staged/line-endings.test.ts b/packages/uncheck/tests/staged/line-endings.test.ts new file mode 100644 index 0000000..2316742 --- /dev/null +++ b/packages/uncheck/tests/staged/line-endings.test.ts @@ -0,0 +1,94 @@ +import { LAYOUTS } from '../utils/project' +import { conflictError, failure, folderOf, inIndex, stage, status } from './utils' + +const crlf = (text: string) => text.replaceAll('\n', '\r\n') + +const LINES = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' + +describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/crlf.ts` + + it('merges what git stores under core.autocrlf, so rewritten line endings are no change', async () => { + const project = create({ [file]: `export const answer: number = 42;\n${LINES}` }) + + project.git('config', 'core.autocrlf', 'true') + stage(project, { [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.write({ + [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), + }) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(`export const answer: number = 43;\n${LINES}`) + expect(project.read(file)).toBe( + crlf(`export const answer: number = 43;\n${LINES}export const d = 4;\n`), + ) + }) + + it('puts unstaged changes back with the line endings the package sets', async () => { + const project = create({ + [`${app}.gitattributes`]: '*.ts text eol=crlf\n', + [file]: crlf(`export const answer: number = 42;\n${LINES}`), + }) + + stage(project, { [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.write({ + [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), + }) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(`export const answer: number = 43;\n${LINES}`) + expect(project.read(file)).toBe( + crlf(`export const answer: number = 43;\n${LINES}export const d = 4;\n`), + ) + }) + + describe('that git keeps with CRLF under text=auto', () => { + const kept = 'export const a = 1;\r\nexport const b = 2;\r\n' + + function legacy() { + const project = create({ [file]: kept }) + + return project.write({ '.gitattributes': '* text=auto\n' }).commit('attributes') + } + + it('sees no change where nothing changed it', async () => { + const project = legacy() + const unstaged = `${kept}export const c = 3;\r\nexport const d = 4;\r\n` + + project.git('config', 'core.safecrlf', 'true') + stage(project, { [file]: `${kept}export const c = 3;\r\n` }) + project.write({ [file]: unstaged }) + + const { exitCode, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(project.read(file)).toBe(unstaged) + expect(status(project)).toBe(`MM ${file}\n`) + }) + + it('undoes its fixes rather than turn its line endings to LF', async () => { + const project = legacy() + const unstaged = `var c = 3;\r\n${kept}export { c };\r\nexport const d = 4;\r\n` + + stage(project, { [file]: `var c = 3;\r\n${kept}export { c };\r\n` }) + project.write({ [file]: unstaged }) + + const { exitCode, stderr } = await project.uncheck(['staged', '--fix', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe(failure(conflictError('src/crlf.ts'))) + expect(exitCode).toBe(1) + expect(project.read(file)).toBe(unstaged) + expect(inIndex(project, file)).toBe(`var c = 3;\r\n${kept}export { c };\r\n`) + }) + }) +}) diff --git a/packages/uncheck/tests/staged/many-files.test.ts b/packages/uncheck/tests/staged/many-files.test.ts new file mode 100644 index 0000000..9cc6d49 --- /dev/null +++ b/packages/uncheck/tests/staged/many-files.test.ts @@ -0,0 +1,60 @@ +import { LAYOUTS, report } from '../utils/project' +import { folderOf, inIndex, stage, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, app }) => { + const folder = folderOf(app) + + it('splits git and tool calls whose paths pass 30,000 characters', async () => { + const named = (group: string) => + Array.from( + { length: 150 }, + (_, index) => `${app}src/${group}${String(index).padStart(3, '0')}${'x'.repeat(195)}.ts`, + ) + const fixed = named('f') + const partial = named('p') + const withVersion = (files: ReadonlyArray, version: string) => + Object.fromEntries(files.map((file) => [file, version])) + const project = create(withVersion([...fixed, ...partial], VERSIONS.committed)) + + stage(project, { + ...withVersion(fixed, VERSIONS.staged), + ...withVersion(partial, VERSIONS.fixed), + }) + project.write(withVersion(partial, VERSIONS.merged)) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of [150 files] set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern [143 files]', + '▶ oxlint --fix --no-error-on-unmatched-pattern [143 files]', + '▶ oxlint --fix --no-error-on-unmatched-pattern [14 files]', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern [143 files]', + '▶ oxfmt --no-error-on-unmatched-pattern [143 files]', + '▶ oxfmt --no-error-on-unmatched-pattern [14 files]', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + '✔ staged the fixes to [150 files]', + '○ unstaged changes of [150 files] restored', + ]) + + for (const file of [fixed[0]!, fixed.at(-1)!]) { + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + } + + for (const file of [partial[0]!, partial.at(-1)!]) { + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.merged) + } + }) +}) diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts new file mode 100644 index 0000000..3571e05 --- /dev/null +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -0,0 +1,106 @@ +import { LAYOUTS, report, run } from '../utils/project' +import { + commitOnSide, + failure, + folderOf, + inIndex, + stage, + stagePartially, + UNTRANSLATED, + VERSIONS, +} from './utils' + +describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { + const folder = folderOf(app) + const other = `${app}src/other.ts` + + it('checks only the staged files that differ from the branch being merged in', async () => { + const project = commitOnSide(create({ [other]: 'export const other = 2;\n' }), { + [`${app}src/theirs.ts`]: 'export const theirs = 1\n', + [other]: 'export const other = 3;\n', + }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + + const theirs = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(theirs.exitCode).toBe(0) + expect(report(theirs.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, every staged file comes from the branch being merged in', + ]) + + stage(project, { [other]: 'export const other = 4\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts') + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expect(inIndex(project, other)).toBe('export const other = 4;\n') + expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') + }) + + it('never takes a merge whose fixes bring back the tree of HEAD for an empty commit', async () => { + const project = commitOnSide(create({ [other]: 'export const other = 2;\n' }), { + [other]: 'export const other = 3;\n', + }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + stage(project, { [other]: 'export const other = 2\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expect(project.git('diff', '--cached', '--name-only')).toBe('') + + project.git('commit', '--quiet', '--no-edit', '--no-verify') + + expect(project.git('rev-parse', 'HEAD^2')).toBe(project.git('rev-parse', 'side')) + }) + + it('reports what git refuses while the merge still has conflicts', async () => { + const file = `${app}src/extra.ts` + const project = commitOnSide( + create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }), + { + [other]: 'export const other = 2;\n', + }, + ) + + project.write({ [other]: 'export const other = 3;\n' }).commit('main') + + const merge = await run(['git', 'merge', 'side'], { cwd: project.path('.') }) + + expect(merge.exitCode).toBe(1) + + stagePartially(project, file) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxfmt'], { + cwd: folder, + env: UNTRANSLATED, + }) + + const unmerged = project + .git('ls-files', '--unmerged', '--', other) + .trimEnd() + .split('\n') + .map((entry) => `${other}: unmerged (${entry.split(' ')[1]})\n`) + .join('') + + expect(stderr).toBe( + failure(`git write-tree failed: ${unmerged}fatal: git-write-tree: error building trees`), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) +}) diff --git a/packages/uncheck/tests/staged/precedence.test.ts b/packages/uncheck/tests/staged/precedence.test.ts new file mode 100644 index 0000000..91bc161 --- /dev/null +++ b/packages/uncheck/tests/staged/precedence.test.ts @@ -0,0 +1,96 @@ +import { LAYOUTS, report } from '../utils/project' +import { + conflictError, + EMPTY_COMMIT_ERROR, + failure, + folderOf, + inIndex, + normalized, + stage, + stagePartially, + status, + stranded, + strandedError, + VERSIONS, +} from './utils' + +describe.each(LAYOUTS)( + 'uncheck staged with a failed check and more in a $name', + ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + + it('reports the fixes that conflict with unstaged changes over the failed check', async () => { + const project = create({ [file]: 'export const extra: number = 1;\n' }) + + stage(project, { [file]: 'export const extra: number = "42"\n' }) + project.write({ [file]: 'export const extra: number = "43"\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-3)).toEqual([ + '✘ tsc failed', + '✘ 1 of 2 checks failed: tsc', + '✔ staged the fixes to src/extra.ts', + ]) + expect(inIndex(project, file)).toBe('export const extra: number = "42"\n') + expect(project.read(file)).toBe('export const extra: number = "43"\n') + }) + + it('reports the unstaged changes it could not put back over the failed check', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "require('node:fs').chmodSync('src/extra.ts', 0o444)\nprocess.exitCode = 1\n", + ) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(normalized(project, stdout)).slice(-6)).toEqual([ + '✘ oxlint failed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + stranded( + 'src/extra.ts', + `PlatformError: PermissionDenied: FileSystem.copyFile (/.git/uncheck-unstaged/${file})`, + ), + ]) + expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) + }) + + it('reports a commit the fixes leave empty over the failed check', async () => { + const project = create({ + [file]: 'export const extra = 1;\n', + [`${app}src/broken.ts`]: 'export const broken: number = "1";\n', + }) + + stage(project, { [file]: 'export const extra = 1\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(failure(EMPTY_COMMIT_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-3)).toEqual([ + '✘ tsc failed', + '✘ 1 of 2 checks failed: tsc', + '✔ staged the fixes to src/extra.ts', + ]) + expect(status(project)).toBe('') + }) + }, +) diff --git a/packages/uncheck/tests/staged/sparse-checkout.test.ts b/packages/uncheck/tests/staged/sparse-checkout.test.ts new file mode 100644 index 0000000..654c0c8 --- /dev/null +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -0,0 +1,64 @@ +import { LAYOUTS, report, run } from '../utils/project' +import { commitOnSide, conflictError, failure, folderOf, inIndex, stage, status } from './utils' + +describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + const outside = `${app}lib/lib.ts` + + it('undoes or stages only what the fixes changed, leaving a staged file outside the cone alone', async () => { + const project = commitOnSide( + create({ [file]: 'export const extra = 1;\n', [outside]: 'export const lib = 1;\n' }), + { [outside]: 'export const lib = 2;\n' }, + ) + + project.git('sparse-checkout', 'set', `${app}src`) + project.git('merge', '--quiet', '--squash', 'side') + stage(project, { [file]: 'export const extra = 42\n' }) + project.write({ [file]: 'export const extra = 43\n' }) + + const conflicted = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(conflicted.stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(conflicted.exitCode).toBe(1) + expect(status(project)).toBe(`M ${outside}\nMM ${file}\n`) + expect(project.read(file)).toBe('export const extra = 43\n') + + project.git('add', '--', file) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts') + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(project.exists(`${app}lib`)).toBe(false) + expect(inIndex(project, outside)).toBe('export const lib = 2;\n') + expect(inIndex(project, file)).toBe('export const extra = 43;\n') + }) + + it('stages the fixes to a conflict a merge left outside the cone', async () => { + const project = commitOnSide(create({ [outside]: 'export const lib = 1;\n' }), { + [outside]: 'export const lib = 2;\n', + }) + + project.write({ [outside]: 'export const lib = 3;\n' }).commit('main') + project.git('sparse-checkout', 'set', `${app}src`) + + const merge = await run(['git', 'merge', '--quiet', 'side'], { cwd: project.path('.') }) + + expect(merge.exitCode).toBe(1) + + project.write({ [outside]: 'export const lib = 4\n' }) + project.git('add', '--sparse', '--', outside) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to lib/lib.ts') + expect(inIndex(project, outside)).toBe('export const lib = 4;\n') + }) +}) diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts new file mode 100644 index 0000000..32eae2d --- /dev/null +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -0,0 +1,472 @@ +import { chmodSync, lstatSync, readdirSync } from 'node:fs' + +import { LAYOUTS, report, run } from '../utils/project' +import { + conflictError, + failure, + folderOf, + inIndex, + LEFTOVER_ERROR, + mode, + normalized, + stage, + stagePartially, + status, + stranded, + strandedError, + UNTRANSLATED, + VERSIONS, + wrappedGit, +} from './utils' + +describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + + it('checks the staged version of a partially staged file and puts the unstaged one back', async () => { + const project = create({ [file]: VERSIONS.committed }) + + stage(project, { [file]: VERSIONS.fixed }) + project.write({ [file]: `${VERSIONS.fixed}var unstaged = 1\n` }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--only=oxlint', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(project.read(file)).toBe(`${VERSIONS.fixed}var unstaged = 1\n`) + expect(status(project)).toBe(`MM ${file}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('puts unstaged lines back where they were after the fixes move lines around them', async () => { + const split = 'export const list = [\n 1,\n 2,\n];\n' + const joined = 'export const list = [1, 2];\n' + const added = + "\nexport function f() {\n return 1;\n}\n\nexport function g() {\n return 'g'\n}\n" + const edit = (text: string) => + `${text.replace(' return 1;', ' // in f\n return 1;')}\nexport const z = 1;\n` + const fixed = added.replace("'g'", '"g";') + const project = create({ [file]: joined, [`${app}src/other.ts`]: 'export const other = 1;\n' }) + + stage(project, { [file]: split + added }) + project.write({ + [file]: split + edit(added), + [`${app}src/other.ts`]: 'export const other = 2\n', + [`${app}src/fresh.ts`]: 'export const fresh = 3\n', + }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(inIndex(project, file)).toBe(joined + fixed) + expect(project.read(file)).toBe(joined + edit(fixed)) + expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.read(`${app}src/fresh.ts`)).toBe('export const fresh = 3\n') + expect(status(project)).toBe(`MM ${file}\n M ${app}src/other.ts\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('undoes every fix when one conflicts with unstaged changes, so nothing is lost', async () => { + const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) + const stagedFar = ['export const f0 = 0;\n', ...far.slice(1)].join('') + const unstagedFar = [ + 'export const f0 = 0;\n', + ...far.slice(1, -1), + 'export const f29 = 30;\n', + ].join('') + const project = create({ + [file]: 'export const extra = 1;\n', + [`${app}src/far.ts`]: far.join(''), + }) + + stage(project, { + [file]: 'export const extra = 42\n', + [`${app}src/far.ts`]: stagedFar, + [`${app}src/other.ts`]: 'export const other = 2\n', + }) + project.write({ + [file]: 'export const extra = 43\n', + [`${app}src/far.ts`]: unstagedFar, + }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/far.ts src/other.ts') + expect(inIndex(project, file)).toBe('export const extra = 42\n') + expect(inIndex(project, `${app}src/far.ts`)).toBe(stagedFar) + expect(inIndex(project, `${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.read(file)).toBe('export const extra = 43\n') + expect(project.read(`${app}src/far.ts`)).toBe(unstagedFar) + expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('refuses unstaged changes that are not edits to a file', async () => { + const project = create({ + [`${app}src/a.ts`]: 'export const a = 1;\n', + [`${app}src/b.ts`]: 'export const b = 1;\n', + }) + + stage(project, { + [`${app}src/a.ts`]: 'export const a = 2;\n', + [`${app}src/b.ts`]: 'export const b = 2;\n', + }) + project.write({ [`${app}src/a.ts`]: null, [`${app}src/b.ts`]: null }) + project.link(`${app}src/b.ts`, 'index.ts') + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(stderr).toBe( + failure( + 'The unstaged changes of src/a.ts src/b.ts are not edits to a file and cannot be set aside. Stage or stash them, then commit again.', + ), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.exists(`${app}src/a.ts`)).toBe(false) + expect(lstatSync(project.path(`${app}src/b.ts`)).isSymbolicLink()).toBe(true) + expect(status(project)).toBe(`MD ${app}src/a.ts\nMT ${app}src/b.ts\n`) + }) + + it('stops rather than overwrite the unstaged changes an earlier run left behind', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.write({ [`.git/uncheck-unstaged/${file}`]: 'left behind' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe('left behind') + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('stops on the unstaged changes an earlier run left behind even with none to set aside now', async () => { + const project = stage(create(), { [file]: 'export const extra = 1\n' }) + + project.write({ '.git/uncheck-unstaged/src/index.ts': 'left behind' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read('.git/uncheck-unstaged/src/index.ts')).toBe('left behind') + expect(inIndex(project, file)).toBe('export const extra = 1\n') + expect(status(project)).toBe(`A ${file}\n`) + }) + + it('stops when a parallel run claims the folder for unstaged changes first', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { + cwd: folder, + env: wrappedGit( + 'if [ "$1" = write-tree ]; then mkdir "$("$GIT" rev-parse --git-path uncheck-unstaged)"; fi', + ), + }) + + expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(readdirSync(project.path('.git/uncheck-unstaged'))).toEqual([]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('keeps the unstaged mode of a file, whether or not the checks change it', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + chmodSync(project.path(file), 0o755) + + const checked = await project.uncheck(['staged', '--only=oxlint'], { cwd: folder }) + + expect(checked.exitCode).toBe(0) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(mode(project, file)).toBe(0o755) + + const fixed = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(fixed.exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100644 /) + expect(project.read(file)).toBe(VERSIONS.merged) + expect(mode(project, file)).toBe(0o755) + }) + + it('keeps an edit a tool saves to a partially staged file while the checks run', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "const fs = require('node:fs')\nfs.writeFileSync('src/extra.ts', fs.readFileSync('src/extra.ts', 'utf8').replace('c = 1', 'c = 3'))\n", + ) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('○ unstaged changes of src/extra.ts restored') + expect(project.read(file)).toBe(VERSIONS.unstaged.replace('c = 1', 'c = 3')) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('merges as plain text whatever merge driver the repository sets', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.write({ '.git/info/attributes': '*.ts merge=ours\n' }) + project.git('config', 'merge.ours.driver', 'true') + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(project.read(file)).toBe(VERSIONS.merged) + }) + + it('reads every unstaged change right, a rename among them', async () => { + const project = stagePartially( + create({ [file]: VERSIONS.committed, [`${app}src/aaa.ts`]: 'export const aaa = 1;\n' }), + file, + ) + + project.git('mv', '--', `${app}src/aaa.ts`, `${app}src/moved.ts`) + project.git('reset', '--quiet', '--', `${app}src/aaa.ts`, `${app}src/moved.ts`) + project.git('add', '--intent-to-add', '--', `${app}src/moved.ts`) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.merged) + expect(status(project)).toBe(`MM ${file}\n R ${app}src/aaa.ts -> ${app}src/moved.ts\n`) + }) + + it('sets unstaged changes aside and back without running the post-checkout hook', async () => { + const project = create({ [file]: 'export const extra = 1;\n' }) + + project.write({ + '.git/hooks/post-checkout': '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n', + }) + chmodSync(project.path('.git/hooks/post-checkout'), 0o755) + stage(project, { [file]: 'export const extra = 42\n' }) + project.write({ [file]: 'export const extra = 43\n' }) + + const { exitCode, stderr } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(project.exists('.git/post-checkout-ran')).toBe(false) + expect(inIndex(project, file)).toBe('export const extra = 42\n') + expect(project.read(file)).toBe('export const extra = 43\n') + }) + + it('puts unstaged changes back from a folder whose name has a newline', async () => { + const nested = `${app}new\nline/extra.ts` + const project = stagePartially(create({ [nested]: VERSIONS.committed }), nested) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: `${app}new\nline`, + }) + + expect(exitCode).toBe(0) + expect(inIndex(project, nested)).toBe(VERSIONS.fixed) + expect(project.read(nested)).toBe(VERSIONS.merged) + }) +}) + +describe.each(LAYOUTS)( + 'uncheck staged failing to handle unstaged changes in a $name', + ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + const saved = `.git/uncheck-unstaged/${file}` + const whereKept = + ' their unstaged versions are in /.git/uncheck-unstaged, at their paths from the top of the repository: copy back what your files are missing and delete the folder\n' + + it('keeps the unstaged changes aside and stops when a check leaves the file unwritable', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', "require('node:fs').chmodSync('src/extra.ts', 0o444)\n") + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(normalized(project, stdout)).toContain( + `${stranded('src/extra.ts', `PlatformError: PermissionDenied: FileSystem.copyFile (/${saved})`)}\n${whereKept}`, + ) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + expect(project.read(file)).toBe(VERSIONS.staged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('keeps the unstaged changes aside and stops when a check replaces the file with a folder', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "const fs = require('node:fs')\nfs.rmSync('src/extra.ts')\nfs.mkdirSync('src/extra.ts')\n", + ) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + env: UNTRANSLATED, + }) + + expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(normalized(project, stdout)).toContain( + `${stranded('src/extra.ts', `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`)}\n${whereKept}`, + ) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('keeps the fixes staged and the unstaged changes aside when git cannot write the merge back', async () => { + const project = create({ [file]: VERSIONS.committed }) + + project.write({ '.git/info/attributes': `${file} filter=once\n` }) + project.git('config', 'filter.once.clean', 'cat') + project.git( + 'config', + 'filter.once.smudge', + 'if [ -e .git/smudged ]; then exit 1; fi; touch .git/smudged; cat', + ) + project.git('config', 'filter.once.required', 'true') + stagePartially(project, file) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder, env: UNTRANSLATED }, + ) + + const merged = await run(['git', 'hash-object', '--no-filters', '--stdin'], { + cwd: project.path('.'), + input: VERSIONS.merged, + }) + + expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + stranded( + 'src/extra.ts', + `git cat-file --filters --path=${file} ${merged.stdout.trim()} failed: error: external filter 'if [ -e .git/smudged ]; then exit 1; fi; touch .git/smudged; cat' failed 1`, + ), + ]) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + }) + + it('keeps the fixes staged and the unstaged changes aside when git merge-file breaks', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { + cwd: folder, + env: wrappedGit( + 'if [ "$1" = merge-file ]; then echo "merge-file broke" >&2; exit 129; fi', + ), + }, + ) + + expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).at(-2)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).at(-1)).toMatch( + /^✘ could not put back the unstaged changes of src\/extra\.ts: git merge-file --quiet \S+\/result \S+\/base \S+\/fixed failed: merge-file broke$/, + ) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + }) + + it('stops before touching a partially staged file it cannot read', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + chmodSync(project.path(file), 0o000) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(normalized(project, stderr)).toBe( + failure(`PermissionDenied: FileSystem.copyFile (/${file})`), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + chmodSync(project.path(file), 0o644) + expect(project.read(file)).toBe(VERSIONS.unstaged) + }) + + it('puts back what it set aside when git fails to check out the staged versions', async () => { + const other = `${app}src/other.ts` + const project = create({ [file]: VERSIONS.committed, [other]: VERSIONS.committed }) + + project.write({ '.git/info/attributes': `${other} filter=flaky\n` }) + project.git('config', 'filter.flaky.clean', 'cat') + project.git( + 'config', + 'filter.flaky.smudge', + 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi', + ) + project.git('config', 'filter.flaky.required', 'true') + stagePartially(project, file) + stagePartially(project, other) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { + cwd: folder, + env: UNTRANSLATED, + }) + + const filter = + "external filter 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi' failed" + + expect(stderr).toBe( + failure( + `git checkout-index -f [2 paths] failed: error: ${filter} 1\nerror: ${filter}\nfatal: ${other}: smudge filter flaky failed`, + ), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.read(other)).toBe(VERSIONS.unstaged) + expect(status(project)).toBe(`MM ${file}\nMM ${other}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + }, +) diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts new file mode 100644 index 0000000..7e0e9d5 --- /dev/null +++ b/packages/uncheck/tests/staged/utils.ts @@ -0,0 +1,181 @@ +import { execFileSync, spawn } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import { statSync, writeFileSync } from 'node:fs' +import { delimiter, join } from 'node:path' + +import { CLI, environment, Project, temporaryDirectory } from '../utils/project' +import type { Env, Files, Run } from '../utils/project' + +/** The folder of a layout's package, relative to the top of the repository. */ +export function folderOf(app: string): string { + return app.replace(/\/$/, '') || '.' +} + +// git translates its messages to the locale of the machine, so tests asserting them need this. +export const UNTRANSLATED: Env = { LC_ALL: 'C' } + +/** An environment whose `git` first runs the shell `script`, which finds the real git in `$GIT`. */ +export function wrappedGit(script: string): Env { + const real = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() + const shims = temporaryDirectory() + + writeFileSync(join(shims, 'git'), `#!/bin/sh\nGIT='${real}'\n${script}\nexec "$GIT" "$@"\n`, { + mode: 0o755, + }) + + return { PATH: `${shims}${delimiter}${environment().PATH}` } +} + +export const LEFTOVER_ERROR = + 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' + +export const EMPTY_COMMIT_ERROR = + 'The fixes undid every staged change, so the commit would be empty. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.' + +export function conflictError(files: string): string { + return `The fixes conflict with the unstaged changes of ${files} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.` +} + +export function strandedError(files: string): string { + return `The unstaged changes of ${files} could not be put back, see above.` +} + +/** The line explaining why the unstaged changes of `files` stayed in the saved folder. */ +export function stranded(files: string, reason: string): string { + return `✘ could not put back the unstaged changes of ${files}: ${reason}` +} + +/** How tsc checks the package of each layout. */ +export const TSC_COMMAND: Readonly> = { + 'single repo': '▶ tsc -p tsconfig.json --noEmit', + 'monorepo': '▶ tsc -b tsconfig.json', +} + +const MIDDLE = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\n' + +/** + * The versions of a file whose staged change to its first line needs formatting, while an unstaged + * change edits its last line, far enough apart to merge. + */ +export const VERSIONS = { + committed: `export const a = 1;\n${MIDDLE}export const e = 1;\n`, + staged: `export const a = 2\n${MIDDLE}export const e = 1;\n`, + unstaged: `export const a = 2\n${MIDDLE}export const e = 2;\n`, + fixed: `export const a = 2;\n${MIDDLE}export const e = 1;\n`, + merged: `export const a = 2;\n${MIDDLE}export const e = 2;\n`, +} + +/** Stages the `staged` version of `file`, then leaves its `unstaged` version in the working tree. */ +export function stagePartially(project: Project, file: string): Project { + stage(project, { [file]: VERSIONS.staged }) + + return project.write({ [file]: VERSIONS.unstaged }) +} + +/** Writes `files` and stages them as they are. */ +export function stage(project: Project, files: Files): Project { + project.write(files) + project.git('--literal-pathspecs', 'add', '--all', '--', ...Object.keys(files)) + + return project +} + +/** Commits `files` on a new `side` branch, then goes back to `main`. */ +export function commitOnSide(project: Project, files: Files): Project { + project.git('checkout', '--quiet', '-b', 'side') + project.write(files).commit('side') + project.git('checkout', '--quiet', 'main') + + return project +} + +/** A checkout of `project` made with `git worktree add`, with the links an install would make. */ +export function linkedWorktree(project: Project, app: string): Project { + const worktree = new Project(join(temporaryDirectory(), 'linked')) + + project.git('worktree', 'add', '--quiet', worktree.dir) + worktree.link('node_modules', project.path('node_modules')) + + if (app !== '') { + worktree.link(`${app}node_modules/@repo/core`, '../../../core') + } + + return worktree +} + +/** Tracked changes only, since the monorepo's tsc leaves build info files behind. */ +export function status(project: Project): string { + return project.git('status', '--porcelain', '--untracked-files=no') +} + +export function inIndex(project: Project, file: string): string { + return project.git('show', `:${file}`) +} + +export function mode(project: Project, file: string): number { + return statSync(project.path(file)).mode & 0o777 +} + +/** How the CLI prints the error that stops a run. */ +export function failure(message: string): string { + return `\nERROR\n ${message}\n` +} + +/** `text` with the folder of `project`, which differs between runs, as ``. */ +export function normalized(project: Project, text: string): string { + return text.replaceAll(project.dir, '') +} + +export interface Started { + readonly child: ChildProcessWithoutNullStreams + /** Resolves once stdout has shown `marker`. */ + readonly printed: (marker: string) => Promise + readonly exited: Promise +} + +/** Starts the CLI without waiting for it, for tests that act on the process while it runs. */ +export function startUncheck( + project: Project, + args: ReadonlyArray, + { cwd = '.', env }: { readonly cwd?: string; readonly env?: Env } = {}, +): Started { + const child = spawn(CLI[0], [...CLI.slice(1), ...args], { + cwd: project.path(cwd), + env: environment(env), + }) + const waiters: Array<{ readonly marker: string; readonly resolve: () => void }> = [] + let stdout = '' + let stderr = '' + + onTestFinished(() => { + child.kill('SIGKILL') + }) + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + + for (const waiter of waiters.filter(({ marker }) => stdout.includes(marker))) { + waiters.splice(waiters.indexOf(waiter), 1) + waiter.resolve() + } + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + + return { + child, + printed: (marker) => + new Promise((resolve) => { + if (stdout.includes(marker)) { + resolve() + } else { + waiters.push({ marker, resolve }) + } + }), + exited: new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + }), + } +} diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts new file mode 100644 index 0000000..ff084a9 --- /dev/null +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -0,0 +1,111 @@ +import { readFileSync } from 'node:fs' + +import { CLI, LAYOUTS, report, run, singleRepo, temporaryDirectory } from '../utils/project' + +const { version } = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +) as { version: string } + +function onlyOxlint(dir: string): string[] { + return [ + `uncheck in ${dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ] +} + +describe.each(LAYOUTS)('uncheck --cwd in a $name', ({ create, app }) => { + const legacyOutsideSrc = { [`${app}legacy.ts`]: 'var count = 1;\nexport { count };\n' } + + it('checks the directory given relative to the current one', async () => { + const project = create(legacyOutsideSrc) + + const { exitCode, stdout } = await project.uncheck(['--cwd', `${app}src`, '--only=oxlint']) + + expect(report(stdout)).toEqual(onlyOxlint(project.path(app, 'src'))) + expect(exitCode).toBe(0) + }) + + it('checks the directory given as an absolute path', async () => { + const project = create(legacyOutsideSrc) + + const { exitCode, stdout } = await run( + [...CLI, `--cwd=${project.path(app, 'src')}`, '--only=oxlint'], + { cwd: temporaryDirectory() }, + ) + + expect(report(stdout)).toEqual(onlyOxlint(project.path(app, 'src'))) + expect(exitCode).toBe(0) + }) + + it.each([ + { given: 'a directory that does not exist', cwd: 'missing', problem: 'Path does not exist' }, + { given: 'a file', cwd: 'src/index.ts', problem: 'Path is not a directory' }, + ])('refuses $given', async ({ cwd, problem }) => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['--cwd', `${app}${cwd}`]) + + expect(stdout).toContain('USAGE') + expect(stderr).toBe( + `\nERROR\n Invalid value for flag --cwd: "${app}${cwd}". Expected: ${problem}: ${project.path(app, cwd)}\n`, + ) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck command line', () => { + it('prints its version', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--version']) + + expect(stdout).toBe(`uncheck v${version}\n`) + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('prints its usage', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--help']) + + expect(stdout).toContain('USAGE\n uncheck [flags] []\n') + + for (const flag of [ + '--cwd', + '--fix', + '--no-error-on-unmatched-pattern', + '--only', + '--require', + '--skip', + ]) { + expect(stdout).toMatch(new RegExp(`^ {2}${flag} `, 'm')) + } + + for (const subcommand of ['staged', 'prepare', 'hooks']) { + expect(stdout).toMatch(new RegExp(`^ {2}${subcommand} `, 'm')) + } + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('prints a shell completion script', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--completions', 'bash']) + + expect(stdout).toMatch(/^###-begin-uncheck-completions-###\n/) + expect(stdout).toContain("compgen -W 'staged prepare hooks'") + expect(stdout).toMatch(/\ncomplete -F _uncheck uncheck\n###-end-uncheck-completions-###\n$/) + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it.each(['--wizard', '--log-level'])('refuses the global flag %s it leaves out', async (flag) => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck([flag]) + + expect(stdout).toContain('USAGE') + expect(stderr).toBe(`\nERROR\n Unrecognized flag: ${flag} in command uncheck\n`) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/files.test.ts b/packages/uncheck/tests/uncheck/files.test.ts new file mode 100644 index 0000000..8405264 --- /dev/null +++ b/packages/uncheck/tests/uncheck/files.test.ts @@ -0,0 +1,207 @@ +import { LAYOUTS, report } from '../utils/project' +import { layoutChecks } from './utils' + +const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] + +describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { + const { tsc } = layoutChecks(app) + + it('checks only the given files with every tool', async () => { + const project = create({ [`${app}src/legacy.ts`]: 'var count = 1;\nexport { count };\n' }) + + const { exitCode, stdout } = await project.uncheck([`${app}src/index.ts`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + }) + + it('fixes only the given files', async () => { + const broken = 'var point = {x:1,\n y:2}\nexport { point }\n' + const project = create({ [`${app}src/given.ts`]: broken, [`${app}src/other.ts`]: broken }) + + const { exitCode, stdout } = await project.uncheck(['--fix', `${app}src/given.ts`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --fix --no-error-on-unmatched-pattern ${app}src/given.ts`, + '✔ oxlint passed', + `▶ oxfmt --no-error-on-unmatched-pattern ${app}src/given.ts`, + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + expect(project.read(`${app}src/given.ts`)).toBe( + 'const point = { x: 1, y: 2 };\nexport { point };\n', + ) + expect(project.read(`${app}src/other.ts`)).toBe(broken) + }) + + it('passes files that no tool handles to the linter and formatter without failing', async () => { + const project = create({ [`${app}notes.txt`]: 'var draft\n', [`${app}README.md`]: '# App\n' }) + + const { exitCode, stdout } = await project.uncheck([`${app}notes.txt`, `${app}README.md`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --no-error-on-unmatched-pattern ${app}README.md ${app}notes.txt`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}README.md ${app}notes.txt`, + '✔ oxfmt passed', + '○ tsc skipped, no tsconfig.json covers the given files', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('fails on paths that match no file', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck([ + `${app}src/index.ts`, + `${app}missing.ts`, + `${app}lib/**`, + ]) + + expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) + expect(stderr).toBe( + `\nERROR\n No files match ${app}missing.ts, ${app}lib/**. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + ) + expect(exitCode).toBe(1) + }) + + it('runs with whatever matched when unmatched patterns are allowed', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + ...ONLY_FILE_CHECKS, + `${app}src/index.ts`, + `${app}missing.ts`, + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('passes when nothing matches and unmatched patterns are allowed', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + `${app}missing.ts`, + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `○ nothing to check, no files match ${app}missing.ts`, + ]) + expect(exitCode).toBe(0) + }) + + it('lists up to three files on the command line and counts more', async () => { + const project = create({ + [`${app}lib/a.ts`]: 'export const a = 1;\n', + [`${app}lib/b.ts`]: 'export const b = 2;\n', + [`${app}lib/c.ts`]: 'export const c = 3;\n', + [`${app}lib/d.ts`]: 'export const d = 4;\n', + }) + + const three = await project.uncheck([ + '--only=oxlint', + `${app}lib/c.ts`, + `${app}lib/a.ts`, + `${app}lib/b.ts`, + ]) + + expect(report(three.stdout)).toContain( + `▶ oxlint --no-error-on-unmatched-pattern ${app}lib/a.ts ${app}lib/b.ts ${app}lib/c.ts`, + ) + expect(three.exitCode).toBe(0) + + const four = await project.uncheck(['--only=oxlint', `${app}lib`]) + + expect(report(four.stdout)).toContain('▶ oxlint --no-error-on-unmatched-pattern [4 files]') + expect(four.exitCode).toBe(0) + }) + + it('splits a file list too long for one command line into several runs', async () => { + const name = (index: number) => + `${app}generated/${'x'.repeat(80 - app.length)}${String(index).padStart(4, '0')}.ts` + const files = Object.fromEntries( + Array.from({ length: 301 }, (_, index) => [name(index), 'export const value = 1;\n']), + ) + const project = create({ ...files, [name(300)]: 'var value = 1;\nexport { value };\n' }) + + const { exitCode, stdout } = await project.uncheck([...ONLY_FILE_CHECKS, `${app}generated`]) + + expect(stdout).toContain('eslint(no-var)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern [300 files]', + `▶ oxlint --no-error-on-unmatched-pattern ${name(300)}`, + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern [300 files]', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${name(300)}`, + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('hands tools file names starting with - or ! as files', async () => { + const project = create({ + [`${app}-draft.ts`]: 'var draft = 1;\nexport { draft };\n', + [`${app}!notes.ts`]: 'export const notes = 1\n', + }) + + const check = await project.uncheck([...ONLY_FILE_CHECKS, '*.ts'], { cwd: app }) + + expect(check.stdout).toContain('-draft.ts') + expect(check.stdout).toContain('eslint(no-var)') + expect(check.stdout).toContain('!notes.ts') + expect(report(check.stdout)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern !notes.ts -draft.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern !notes.ts -draft.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(check.exitCode).toBe(1) + + const fix = await project.uncheck(['--fix', ...ONLY_FILE_CHECKS, '*.ts'], { cwd: app }) + + expect(fix.exitCode).toBe(0) + expect(project.read(`${app}-draft.ts`)).toBe('const draft = 1;\nexport { draft };\n') + expect(project.read(`${app}!notes.ts`)).toBe('export const notes = 1;\n') + }) +}) diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts new file mode 100644 index 0000000..7a95f67 --- /dev/null +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -0,0 +1,220 @@ +import { LAYOUTS, monorepo, report } from '../utils/project' +import { layoutChecks, updateJson } from './utils' + +const LEGACY = 'var count = 1;\nexport { count };\n' +const UGLY = 'export const ugly = {a:1,\n b:2}\n' + +describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { + const { sherif, tsc, checks } = layoutChecks(app) + + it('applies lint fixes and rewrites formatting, then passes', async () => { + const project = create({ [`${app}src/legacy.ts`]: LEGACY, [`${app}src/ugly.ts`]: UGLY }) + + const { exitCode, stdout } = await project.uncheck(['--fix']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...layoutChecks(app, { fix: true }).sherif, + '▶ oxlint --fix', + '✔ oxlint passed', + '▶ oxfmt', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.join(', ')})`, + ]) + expect(exitCode).toBe(0) + expect(project.read(`${app}src/legacy.ts`)).toBe('const count = 1;\nexport { count };\n') + expect(project.read(`${app}src/ugly.ts`)).toBe('export const ugly = { a: 1, b: 2 };\n') + }) + + it('offers the fixes of every fixable check that failed', async () => { + const project = create({ [`${app}src/legacy.ts`]: LEGACY, [`${app}src/ugly.ts`]: UGLY }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=oxfmt']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('leaves type errors out of the fixes it offers', async () => { + const project = create({ + [`${app}src/legacy.ts`]: LEGACY, + [`${app}src/broken.ts`]: 'export const broken: number = "42";\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 2 of 2 checks failed: oxlint, tsc', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('leaves a required check that cannot run out of the fixes it offers', async () => { + const project = create( + { [`${app}src/legacy.ts`]: LEGACY }, + { tools: ['sherif', 'oxlint', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc', '--require=oxfmt']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '✘ oxfmt not installed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 2 of ${checks.length - 1} checks failed: oxlint, oxfmt`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('offers no fixes once --fix ran and a problem remains', async () => { + const project = create({ + '.oxlintrc.json': { rules: { 'no-console': 'error' } }, + [`${app}src/log.ts`]: 'console.log("hello");\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=oxlint']) + + expect(stdout).toContain('eslint(no-console)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix', + '✘ oxlint failed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxlint', + ]) + expect(exitCode).toBe(1) + expect(project.read(`${app}src/log.ts`)).toBe('console.log("hello");\n') + }) +}) + +function monorepoWithMismatchedVersions(sherif: object = {}) { + const project = monorepo() + + updateJson(project, 'package.json', (manifest) => ({ + ...manifest, + sherif: { noInstall: true, ...sherif }, + })) + updateJson(project, 'packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + updateJson(project, 'packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) + + return project +} + +function zodVersions(project: ReturnType) { + return ['core', 'app'].map( + (name) => + ( + JSON.parse(project.read(`packages/${name}/package.json`)) as { + dependencies: Record + } + ).dependencies.zod, + ) +} + +describe('uncheck --fix in a monorepo', () => { + it('aligns mismatched versions on the highest one', async () => { + const project = monorepoWithMismatchedVersions() + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif --fix --select=highest', + '✔ sherif passed', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + expect(zodVersions(project)).toEqual(['^3.1.0', '^3.1.0']) + expect((await project.uncheck(['--only=sherif'])).exitCode).toBe(0) + }) + + it('leaves the version choice to a sherif config that makes one', async () => { + const project = monorepoWithMismatchedVersions({ select: 'lowest' }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif']) + + expect(report(stdout)).toContain('▶ sherif --fix') + expect(exitCode).toBe(0) + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.0.0']) + }) + + it('only reports what sherif finds in CI, where sherif refuses to fix', async () => { + const project = monorepoWithMismatchedVersions() + project.write({ 'packages/app/src/legacy.ts': LEGACY }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--skip=tsc'], { + env: { CI: 'true' }, + }) + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix', + '✔ oxlint passed', + '▶ oxfmt', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 1 of 3 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + expect(project.read('packages/app/src/legacy.ts')).toBe('const count = 1;\nexport { count };\n') + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.1.0']) + }) + + it.each(['0', ''])('takes CI=%j for CI too', async (ci) => { + const project = monorepoWithMismatchedVersions() + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif'], { + env: { CI: ci }, + }) + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.1.0']) + }) + + it('offers the fixes of three checks as a list', async () => { + const project = monorepoWithMismatchedVersions() + project.write({ 'packages/app/src/legacy.ts': LEGACY, 'packages/app/src/ugly.ts': UGLY }) + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 3 of 3 checks failed: sherif, oxlint, oxfmt', + ' rerun with `--fix` to apply sherif, oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/install.test.ts b/packages/uncheck/tests/uncheck/install.test.ts new file mode 100644 index 0000000..abaca2e --- /dev/null +++ b/packages/uncheck/tests/uncheck/install.test.ts @@ -0,0 +1,158 @@ +import { LAYOUTS, monorepo, report } from '../utils/project' +import { layoutChecks } from './utils' + +const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] + +const YARN_PNP_RESOLVER = `const Module = require('node:module'); +const path = require('node:path'); + +process.versions.pnp = '3'; + +const resolveFilename = Module._resolveFilename; + +Module._resolveFilename = function (request, ...rest) { + return request === 'oxlint/package.json' + ? path.join(__dirname, '.yarn/unplugged/oxlint/package.json') + : resolveFilename.call(this, request, ...rest); +}; +` + +describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app }) => { + const { sherif, tsc, checks } = layoutChecks(app) + + it('skips oxlint and oxfmt when they are not installed', async () => { + const project = create({}, { tools: ['sherif', 'typescript'] }) + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.filter((name) => name === 'sherif' || name === 'tsc').join(', ')})`, + ]) + expect(exitCode).toBe(0) + }) + + it('runs a tool whose manifest names its only bin with a string', async () => { + const project = create( + { + 'node_modules/oxfmt/package.json': { name: 'oxfmt', bin: 'cli.js' }, + 'node_modules/oxfmt/cli.js': + "console.log('oxfmt ran with ' + process.argv.slice(2).join(' '));\n", + }, + { tools: ['sherif', 'oxlint', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(['--only=oxfmt']) + + expect(stdout).toContain('▶ oxfmt --check\noxfmt ran with --check\n') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it.each([ + ['declares a bin for another command', { name: 'oxlint', bin: { oxc: 'bin.js' } }], + ['declares no bin', { name: 'oxlint' }], + ['is not JSON', '{ "name": "oxlint",\n'], + ['is not an object', '"oxlint"\n'], + ])('takes a tool whose manifest %s for not installed', async (_, manifest) => { + const project = create( + { 'node_modules/oxlint/package.json': manifest, 'node_modules/oxfmt/package.json': manifest }, + { tools: ['sherif', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, not selected by --only', + '✘ nothing to check: sherif not selected by --only, oxlint not installed, oxfmt not installed, tsc not selected by --only', + ]) + expect(exitCode).toBe(1) + }) + + it('finds the tools installed above the folder it checks', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS, { cwd: `${app}src` }) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('finds a tool through the resolver of Yarn PnP', async () => { + const project = create( + { + '.gitignore': 'node_modules\ndist\n.yarn\n.pnp.cjs\n', + '.pnp.cjs': YARN_PNP_RESOLVER, + '.yarn/unplugged/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, + '.yarn/unplugged/oxlint/bin.js': "console.log('oxlint from the Yarn cache');\n", + }, + { tools: ['oxfmt', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck([], { + env: { NODE_OPTIONS: `--require ${project.path('.pnp.cjs')}` }, + }) + + expect(stdout).toContain('▶ oxlint\noxlint from the Yarn cache\n') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + }) +}) + +describe('uncheck finding tools in a monorepo', () => { + it('runs the tool installed nearest to the folder it checks', async () => { + const project = monorepo({ + 'packages/app/node_modules/oxlint/package.json': { + name: 'oxlint', + bin: { oxlint: 'bin.js' }, + }, + 'packages/app/node_modules/oxlint/bin.js': "console.log('oxlint of packages/app');\n", + }) + + const inPackage = await project.uncheck(['--only=oxlint'], { cwd: 'packages/app' }) + + expect(inPackage.stdout).toContain('▶ oxlint\noxlint of packages/app\n') + expect(inPackage.exitCode).toBe(0) + + const atRoot = await project.uncheck(['--only=oxlint']) + + expect(atRoot.stdout).not.toContain('oxlint of packages/app') + expect(atRoot.exitCode).toBe(0) + }) +}) diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts new file mode 100644 index 0000000..24613e4 --- /dev/null +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -0,0 +1,237 @@ +import { availableParallelism } from 'node:os' +import { stripVTControlCharacters, styleText } from 'node:util' + +import { LAYOUTS, report, singleRepo } from '../utils/project' +import { layoutChecks } from './utils' + +const STANDALONE_TSCONFIG = { + compilerOptions: { + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + noEmit: true, + }, +} + +const PRINT_FORCE_COLOR = "console.log('the tool sees FORCE_COLOR=' + process.env.FORCE_COLOR);\n" + +const STANDALONE_PROJECTS = [1, 2, 3, 4, 5].map((index) => `project-${index}`) + +function countConcurrentRuns(cap: number, total: number): string { + return `const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); + +const config = process.argv[process.argv.indexOf('-p') + 1]; +const marker = config.replaceAll('/', '_'); +const count = (dir) => readdirSync(dir).length; +const giveUp = Date.now() + 10_000; +let most = 0; + +mkdirSync('running', { recursive: true }); +mkdirSync('finished', { recursive: true }); +writeFileSync(\`running/\${marker}\`, ''); + +(function waitForOthers() { + const running = count('running'); + most = Math.max(most, running); + + if (running === ${cap} || running + count('finished') === ${total}) { + console.log(\`\${config} saw \${most} running\`); + renameSync(\`running/\${marker}\`, \`finished/\${marker}\`); + } else if (Date.now() > giveUp) { + console.log(\`\${config} gave up with \${running} running\`); + } else { + setTimeout(waitForOthers, 10); + } +})(); +` +} + +function paint(style: Parameters[0], text: string): string { + return styleText(style, text, { validateStream: false }) +} + +describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app }) => { + const { sherif, checks } = layoutChecks(app) + + it('fails a check whose tool is killed by a signal and still runs the others', async () => { + const project = create().fake('oxlint', "process.kill(process.pid, 'SIGKILL');\n") + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) + + expect(stdout).toMatch( + /^▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, + ) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 1 of ${checks.length - 1} checks failed: oxlint`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('prints the output of standalone projects one project after the other', async () => { + const project = create({ + [`${app}scripts/tsconfig.json`]: STANDALONE_TSCONFIG, + [`${app}scripts/check.ts`]: 'export const scripts: number = "1";\n', + [`${app}tools/tsconfig.json`]: STANDALONE_TSCONFIG, + [`${app}tools/check.ts`]: 'export const tools: number = "2";\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + const lines = stdout + .split('\n') + .filter((line) => line.startsWith('▶ ') || line.includes('error TS')) + .map((line) => line.replace(/\(\d+,\d+\): error (TS\d+).*$/, ' $1')) + + expect(lines).toEqual([ + ...(app === '' ? [] : ['▶ tsc -b tsconfig.json']), + `▶ tsc -p ${app}scripts/tsconfig.json --noEmit`, + `${app}scripts/check.ts TS2322`, + `▶ tsc -p ${app}tools/tsconfig.json --noEmit`, + `${app}tools/check.ts TS2322`, + ...(app === '' ? ['▶ tsc -p tsconfig.json --noEmit'] : []), + ]) + expect(report(stdout).at(-1)).toBe('✘ 1 of 1 checks failed: tsc') + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck output', () => { + it.skipIf(availableParallelism() < 2)( + 'checks standalone projects side by side without mixing their output', + async () => { + const project = singleRepo({ + 'scripts/tsconfig.json': STANDALONE_TSCONFIG, + 'scripts/check.ts': 'export const scripts = 1;\n', + }).fake( + 'typescript', + `const { existsSync, writeFileSync } = require('node:fs'); + +const config = process.argv[process.argv.indexOf('-p') + 1]; +const other = config === 'tsconfig.json' ? 'scripts/tsconfig.json' : 'tsconfig.json'; +const giveUp = Date.now() + 10_000; + +console.log(\`\${config} started\`); +writeFileSync(\`\${config}.started\`, ''); + +(function waitForOther() { + if (existsSync(\`\${other}.started\`)) { + console.log(\`\${config} saw \${other} running\`); + } else if (Date.now() > giveUp) { + console.log(\`\${config} ran alone\`); + } else { + setTimeout(waitForOther, 10); + } +})(); +`, + ) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + expect(stdout).toMatch( + /^▶ tsc -p scripts\/tsconfig\.json --noEmit\nscripts\/tsconfig\.json started\nscripts\/tsconfig\.json saw tsconfig\.json running\n▶ tsc -p tsconfig\.json --noEmit\ntsconfig\.json started\ntsconfig\.json saw scripts\/tsconfig\.json running\n✔ tsc passed /m, + ) + expect(exitCode).toBe(0) + }, + ) + + it('runs at most four type checkers at once and prints their output in order', async () => { + const cap = Math.min(4, availableParallelism()) + const project = singleRepo( + Object.fromEntries( + STANDALONE_PROJECTS.flatMap((name) => [ + [`${name}/tsconfig.json`, STANDALONE_TSCONFIG], + [`${name}/check.ts`, 'export const value = 1;\n'], + ]), + ), + ).fake('typescript', countConcurrentRuns(cap, STANDALONE_PROJECTS.length + 1)) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + const runs = [...stdout.matchAll(/^▶ tsc -p (\S+) --noEmit\n\1 saw (\d+) running$/gm)] + + expect(runs.map(([, config]) => config)).toEqual([ + ...STANDALONE_PROJECTS.map((name) => `${name}/tsconfig.json`), + 'tsconfig.json', + ]) + expect(Math.max(...runs.map(([, , most]) => Number(most)))).toBe(cap) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (tsc)') + expect(exitCode).toBe(0) + }) + + it('shows how long a slow check took in seconds', async () => { + const project = singleRepo().fake('oxlint', 'setTimeout(() => {}, 1100);\n') + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) + + expect(stdout).toMatch(/^✔ oxlint passed \d+\.\ds$/m) + expect(exitCode).toBe(0) + }) + + it('styles its lines and asks tools for colors when FORCE_COLOR is set', async () => { + const project = singleRepo().fake('oxfmt', PRINT_FORCE_COLOR) + + const { exitCode, stdout } = await project.uncheck(['--only=oxfmt'], { + env: { FORCE_COLOR: '1' }, + }) + + expect(stdout).toContain( + `${paint('dim', '○')} ${paint('bold', 'sherif')} ${paint('dim', 'skipped, not selected by --only')}\n`, + ) + expect(stdout).toContain( + `${paint('dim', '▶')} ${paint('bold', 'oxfmt')} ${paint('dim', '--check')}\nthe tool sees FORCE_COLOR=1\n`, + ) + expect(stdout).toContain( + `${paint('green', '✔')} ${paint('bold', 'oxfmt')} ${paint('green', 'passed')} `, + ) + expect(stdout).toContain(`${paint('green', '✔')} all checks passed (oxfmt)\n`) + expect(report(stripVTControlCharacters(stdout)).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(exitCode).toBe(0) + }) + + it('styles failures in red', async () => { + const project = singleRepo({ 'src/legacy.ts': 'var count = 1;\nexport { count };\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--require=oxlint'], { + env: { FORCE_COLOR: '1' }, + }) + + expect(stdout).toContain( + `${paint('red', '✘')} ${paint('bold', 'oxlint')} ${paint('red', 'failed')} `, + ) + expect(stdout).toContain(`${paint('red', '✘')} 1 of 1 checks failed: oxlint\n`) + expect(stdout).toContain(paint('dim', ' rerun with `--fix` to apply oxlint fixes')) + expect(exitCode).toBe(1) + }) + + it('asks tools for colors when it prints to a terminal', async () => { + const project = singleRepo().fake('oxlint', PRINT_FORCE_COLOR) + + // Node paints nothing in a terminal whose TERM is unset, which it is in some shells. + const { exitCode, stdout } = await project.uncheckInTerminal(['--only=oxlint'], { + env: { TERM: 'xterm-256color' }, + }) + + expect(stdout).toContain('\nthe tool sees FORCE_COLOR=1\n') + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxlint)') + expect(exitCode).toBe(0) + }) + + it('leaves tools without colors when it prints to a pipe', async () => { + const project = singleRepo().fake('oxlint', PRINT_FORCE_COLOR) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) + + expect(stdout).toContain('▶ oxlint\nthe tool sees FORCE_COLOR=undefined\n') + expect(stdout).not.toContain('\u001B[') + expect(exitCode).toBe(0) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts new file mode 100644 index 0000000..66f3b33 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts @@ -0,0 +1,133 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, report } from '../utils/project' +import { checkedFiles, listingProject } from './paths-utils' + +const ROUTES = [ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + 'nested/deep.ts', + 'nested/more/deeper.ts', + 'settings.json', +] + +describe.each(LAYOUTS)('uncheck with exclusions in a $name', ({ create, app }) => { + const cwd = `${app}src/routes` + + function routesProject(): Project { + return listingProject(create, Object.fromEntries(ROUTES.map((file) => [`${cwd}/${file}`, '']))) + } + + async function check(project: Project, ...patterns: ReadonlyArray): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + const without = (...excluded: ReadonlyArray) => + ROUTES.filter((file) => !excluded.includes(file)) + + it('leaves out a file', async () => { + const project = routesProject() + + expect(await check(project, '.', '!home.ts', '!./about.ts')).toEqual( + without('home.ts', 'about.ts'), + ) + }) + + it('leaves out a file whose name looks like a glob along with every file that glob matches', async () => { + const project = routesProject() + + expect(await check(project, '.', '![id].ts')).toEqual(without('[id].ts', 'd.ts', 'i.ts')) + }) + + it('leaves out everything below a directory', async () => { + const project = routesProject() + + expect(await check(project, '.', '!nested/more')).toEqual(without('nested/more/deeper.ts')) + expect(await check(project, '.', '!nested/')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + }) + + it('leaves out the files a glob matches, dot files included', async () => { + const project = routesProject() + + expect(await check(project, '.', '!**/*.json', '!.*')).toEqual( + without('settings.json', '.env.ts'), + ) + expect(await check(project, '.', '!nested/**/deep*.ts')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + }) + + it('reads (...) literally and [!...] as any other character in an exclusion', async () => { + const project = routesProject() + + expect(await check(project, '.', '!(group)/**')).toEqual(without('(group)/page.ts')) + expect(await check(project, '.', '![!ah]*.ts')).toEqual( + without('#draft.ts', '.env.ts', '[id].ts', 'd.ts', 'i.ts'), + ) + }) + + it('reads an exclusion starting with "#" as a glob rather than a comment', async () => { + const project = routesProject() + + expect(await check(project, '.', '!#*.ts')).toEqual(without('#draft.ts')) + }) + + it('reads "!!name" as leaving out a file whose name starts with "!"', async () => { + const project = routesProject().write({ [`${cwd}/!draft.ts`]: '' }) + + expect(await check(project, '.')).toEqual(['./!draft.ts', ...ROUTES]) + expect(await check(project, '.', '!!draft.ts')).toEqual(ROUTES) + }) + + it('applies exclusions on their own to everything', async () => { + const project = routesProject() + + expect(await check(project, '!nested', '!*.json')).toEqual([ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + ]) + }) + + it('applies exclusions wherever they stand and ignores ones that match nothing', async () => { + const project = routesProject() + + expect(await check(project, '!home.ts', 'home.ts', 'about.ts', '!missing')).toEqual([ + 'about.ts', + ]) + }) + + it('leaves nothing to check after "!."', async () => { + const project = routesProject() + + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', '.', '!.'], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path(cwd)}`, + '○ nothing to check, no files match . !.', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts new file mode 100644 index 0000000..0e543c3 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -0,0 +1,132 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { LAYOUTS, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './paths-utils' + +describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ create, app }) => { + const routes = `${app}src/routes` + + it('leaves files git ignores out of a directory or glob but checks one named', async () => { + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/dist/bundle.ts`]: CODE_WITH_VAR, + }) + + const directory = await project.uncheck(['--only=oxlint', routes]) + const glob = await project.uncheck(['--only=oxlint', `${routes}/**/*.ts`]) + const ignoredDirectory = await project.uncheck(['--only=oxlint', `${routes}/dist`]) + const named = await project.uncheck(['--only=oxlint', `${routes}/dist/bundle.ts`]) + + for (const { exitCode, stdout } of [directory, glob]) { + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + } + expect(ignoredDirectory.exitCode).toBe(1) + expect(ignoredDirectory.stderr).toBe( + `\nERROR\n No files match ${routes}/dist. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + ) + expect(named.exitCode).toBe(1) + expect(selectedReport(named.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/dist/bundle.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks untracked files', async () => { + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }) + project.write({ [`${routes}/fresh.ts`]: CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(1) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/fresh.ts ${routes}/home.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks the files of a repository without commits', async () => { + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }, { git: 'init' }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('drops a tracked file deleted from the working tree before it is staged', async () => { + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/gone.ts`]: CLEAN_CODE, + }) + project.write({ [`${routes}/gone.ts`]: null }) + + const directory = await project.uncheck(['--only=oxlint', routes]) + const glob = await project.uncheck(['--only=oxlint', `${routes}/*.ts`]) + + for (const { exitCode, stdout } of [directory, glob]) { + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + } + }) + + it('hands the tools a linked folder as the file git lists, so oxlint checks its files twice', async () => { + const project = create({ [`${routes}/shared/util.ts`]: CODE_WITH_VAR }) + .link(`${routes}/linked`, 'shared') + .commit() + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(1) + expect(stdout).toContain(`${routes}/linked/util.ts:1:1: error eslint(no-var)`) + expect(stdout).toContain(`${routes}/shared/util.ts:1:1: error eslint(no-var)`) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/linked ${routes}/shared/util.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'index.ts'), CODE_WITH_VAR) + const project = create({ + '.gitignore': 'node_modules/\ndist/\n', + [`${routes}/home.ts`]: CLEAN_CODE, + }).link(`${routes}/node_modules`, store) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-globs.test.ts b/packages/uncheck/tests/uncheck/paths-globs.test.ts new file mode 100644 index 0000000..5389189 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-globs.test.ts @@ -0,0 +1,130 @@ +import type { Project } from '../utils/project' +import { LAYOUTS } from '../utils/project' +import { checkedFiles, listingProject } from './paths-utils' + +const ROUTES = [ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + '[slug]/page.ts', + 'about.ts', + 'd.ts', + 'group/page.ts', + 'home.ts', + 'i.ts', + 'nested/.draft.ts', + 'nested/deep.ts', + 'notes.md', +] + +describe.each(LAYOUTS)('uncheck with globs in a $name', ({ create, app }) => { + const routes = (...files: ReadonlyArray) => + files.map((file) => `${app}src/routes/${file}`) + + function routesProject() { + return listingProject(create, { + ...Object.fromEntries(routes(...ROUTES).map((file) => [file, ''])), + '.github/workflows/ci.yml': 'on: push\n', + }) + } + + async function check( + project: Project, + patterns: ReadonlyArray, + cwd?: string, + ): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + it('matches * within one folder, dot files included', async () => { + const project = routesProject() + + expect(await check(project, routes('*.ts'))).toEqual( + routes('#draft.ts', '.env.ts', '[id].ts', 'about.ts', 'd.ts', 'home.ts', 'i.ts'), + ) + }) + + it('matches ** across folders, dot files and dot folders included', async () => { + const project = routesProject() + + expect(await check(project, routes('**/*.ts'))).toEqual( + routes(...ROUTES.filter((file) => file.endsWith('.ts'))), + ) + expect(await check(project, ['**/*.yml'])).toEqual(['.github/workflows/ci.yml']) + }) + + it('matches ? as one character', async () => { + const project = routesProject() + + expect(await check(project, routes('?.ts'))).toEqual(routes('d.ts', 'i.ts')) + }) + + it('matches [...] as one of the characters and [!...] as any other', async () => { + const project = routesProject() + + expect(await check(project, routes('[hd]*.ts'))).toEqual(routes('d.ts', 'home.ts')) + expect(await check(project, routes('[!ah]*.ts'))).toEqual( + routes('#draft.ts', '.env.ts', '[id].ts', 'd.ts', 'i.ts'), + ) + }) + + it('matches {a,b} as either, and a "." among them as the folder itself', async () => { + const project = routesProject() + + expect(await check(project, routes('{home,about}.ts'))).toEqual(routes('about.ts', 'home.ts')) + expect(await check(project, routes('{.,nested}/*.ts'))).toEqual( + routes( + '#draft.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + 'nested/.draft.ts', + 'nested/deep.ts', + ), + ) + }) + + it('reads (...) literally, as in route groups, and @(a|b) as either', async () => { + const project = routesProject() + + expect(await check(project, routes('(group)/**'))).toEqual(routes('(group)/page.ts')) + expect(await check(project, routes('@(home|about).ts'))).toEqual(routes('about.ts', 'home.ts')) + }) + + it('reads a glob starting with "#" as a glob rather than a comment', async () => { + const project = routesProject() + + expect(await check(project, ['#*.ts'], `${app}src/routes`)).toEqual(['#draft.ts']) + }) + + it('takes an existing file whose name looks like a glob as that file', async () => { + const project = routesProject() + + expect(await check(project, routes('[id].ts', '(group)/page.ts'))).toEqual( + routes('(group)/page.ts', '[id].ts'), + ) + + project.write({ [routes('[id].ts')[0]!]: null }) + + expect(await check(project, routes('[id].ts'))).toEqual(routes('d.ts', 'i.ts')) + }) + + it('takes an existing folder whose name looks like a glob as that folder', async () => { + const project = routesProject() + + expect(await check(project, routes('(group)', '[slug]'))).toEqual( + routes('(group)/page.ts', '[slug]/page.ts'), + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-selection.test.ts b/packages/uncheck/tests/uncheck/paths-selection.test.ts new file mode 100644 index 0000000..1afacdd --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-selection.test.ts @@ -0,0 +1,260 @@ +import { writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './paths-utils' + +const TSC_OF_LAYOUT: Readonly> = { + 'single repo': '▶ tsc -p tsconfig.json --noEmit', + 'monorepo': '▶ tsc -b tsconfig.json', +} + +describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ name, create, app }) => { + it('checks only the file it is given', async () => { + const project = create({ [`${app}src/legacy.ts`]: CODE_WITH_VAR }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + `${app}src/index.ts`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('checks every file below a directory it is given', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/nested/legacy.ts`]: CODE_WITH_VAR, + [`${app}scripts/legacy.ts`]: CODE_WITH_VAR, + }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', `${app}src/routes/`]) + + expect(exitCode).toBe(1) + expect(stdout).toContain(`${app}src/routes/nested/legacy.ts:1:1: error eslint(no-var)`) + expect(stdout).not.toContain('scripts/legacy.ts') + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/routes/home.ts ${app}src/routes/nested/legacy.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('hands oxlint and oxfmt the same files and runs tsc on the config covering them', async () => { + const project = create({ [`${app}src/extra.ts`]: CLEAN_CODE }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + `${app}src/index.ts`, + `${app}src/extra.ts`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, + '✔ oxfmt passed', + TSC_OF_LAYOUT[name], + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + }) + + it('lists every file for "." but leaves the choice to each tool without paths', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/legacy.ts`]: CODE_WITH_VAR, + }) + + const dot = await project.uncheck(['--only=oxlint', '--only=oxfmt', '.'], { + cwd: `${app}src/routes`, + }) + const everything = await project.uncheck(['--only=oxlint', '--only=oxfmt'], { + cwd: `${app}src/routes`, + }) + + expect(dot.exitCode).toBe(1) + expect(selectedReport(dot.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint --no-error-on-unmatched-pattern home.ts legacy.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern home.ts legacy.ts', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(everything.exitCode).toBe(1) + expect(selectedReport(everything.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks a file named by several paths once', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/nested/about.ts`]: CLEAN_CODE, + }) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `${app}src/routes/nested`, + `${app}src/routes/*.ts`, + `${app}src/routes/home.ts`, + `./${app}src/routes/nested/../home.ts`, + ]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/routes/home.ts ${app}src/routes/nested/about.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('resolves paths against --cwd rather than the directory it starts in', async () => { + const project = create({ 'index.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `--cwd=${app}src`, + 'index.ts', + ]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '▶ oxlint --no-error-on-unmatched-pattern index.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('resolves paths against the subdirectory it starts in', async () => { + const project = create({ 'index.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', 'index.ts'], { + cwd: `${app}src`, + }) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '▶ oxlint --no-error-on-unmatched-pattern index.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('follows "../" back into the directory it runs in', async () => { + const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE }) + + const { exitCode, stdout } = await project.uncheck( + ['--only=oxlint', '../routes/home.ts', '../routes'], + { cwd: `${app}src/routes` }, + ) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint --no-error-on-unmatched-pattern home.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('matches nothing with a directory or glob above the directory it runs in', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + }) + const outside = temporaryDirectory() + writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['--only=oxlint', '..', '../*.ts', outside], + { cwd: `${app}src/routes` }, + ) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.path(app, 'src/routes')}`]) + expect(stderr).toBe( + `\nERROR\n No files match .., ../*.ts, ${outside}. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + ) + }) + + it('hands oxlint and oxfmt a file above the directory it runs in as a "../" path they reject', async () => { + const project = create() + const outside = temporaryDirectory() + writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) + const handed = relative(project.dir, join(outside, 'shared.ts')) + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + '--only=oxfmt', + join(outside, 'shared.ts'), + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${handed}`, + '✘ oxlint failed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${handed}`, + '✘ oxfmt failed', + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect( + stdout + .split('\n') + .filter((line) => line === `Error: \`${handed}\`: PATH must not contain ".."`), + ).toHaveLength(2) + }) +}) + +describe('uncheck selecting files by path in a monorepo package', () => { + it('resolves paths from the package directory and checks that package alone', async () => { + const project = monorepo({ + 'packages/core/src/legacy.ts': CODE_WITH_VAR, + 'packages/app/src/extra.ts': CLEAN_CODE, + }) + + const { exitCode, stdout, stderr } = await project.uncheck(['src', 'package.json'], { + cwd: 'packages/app', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path('packages/app')}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint --no-error-on-unmatched-pattern package.json src/extra.ts src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern package.json src/extra.ts src/index.ts', + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts new file mode 100644 index 0000000..976a08f --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts @@ -0,0 +1,64 @@ +import { mkdirSync } from 'node:fs' + +import { LAYOUTS, report } from '../utils/project' +import { selectedReport } from './paths-utils' + +describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app }) => { + it('fails before checking anything and names every path that matched no file', async () => { + const project = create() + mkdirSync(project.path(app, 'src/empty')) + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + `${app}src/index.ts`, + `${app}missing.ts`, + `${app}src/**/*.tsx`, + `${app}lib/`, + `${app}src/empty`, + ]) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) + expect(stderr).toBe( + `\nERROR\n No files match ${app}missing.ts, ${app}src/**/*.tsx, ${app}lib/, ${app}src/empty. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + ) + }) + + it('checks whatever matched with --no-error-on-unmatched-pattern', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + '--no-error-on-unmatched-pattern', + `${app}missing.ts`, + `${app}src/index.ts`, + `${app}src/**/*.tsx`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('passes with nothing to check when no path matched with --no-error-on-unmatched-pattern', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + `${app}missing.ts`, + `${app}src/**/*.tsx`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `○ nothing to check, no files match ${app}missing.ts ${app}src/**/*.tsx`, + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-utils.ts b/packages/uncheck/tests/uncheck/paths-utils.ts new file mode 100644 index 0000000..1c75551 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-utils.ts @@ -0,0 +1,36 @@ +import type { Files, Layout, Project, ProjectOptions } from '../utils/project' +import { report } from '../utils/project' + +export const CLEAN_CODE = 'export const home = "home";\n' + +/** Fails the `no-var` rule the projects enable in oxlint. */ +export const CODE_WITH_VAR = 'var count = 1;\nexport { count };\n' + +const LISTING_OXLINT = [ + 'for (const arg of process.argv.slice(2)) {', + " if (!arg.startsWith('--')) console.log('checked ' + arg)", + '}', + '', +].join('\n') + +/** A project whose oxlint passes and prints each file it gets, to see every file of a long list. */ +export function listingProject( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, +): Project { + return create(files, { ...options, tools: [] }).fake('oxlint', LISTING_OXLINT) +} + +/** The files the oxlint of `listingProject` printed, in the order it got them. */ +export function checkedFiles(stdout: string): string[] { + return stdout + .split('\n') + .filter((line) => line.startsWith('checked ')) + .map((line) => line.slice('checked '.length)) +} + +/** The report without the checks that `--only` leaves out. */ +export function selectedReport(stdout: string): string[] { + return report(stdout).filter((line) => !line.endsWith(' skipped, not selected by --only')) +} diff --git a/packages/uncheck/tests/uncheck/paths-walk.test.ts b/packages/uncheck/tests/uncheck/paths-walk.test.ts new file mode 100644 index 0000000..d50c093 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-walk.test.ts @@ -0,0 +1,91 @@ +import { chmodSync } from 'node:fs' +import process from 'node:process' + +import type { Project } from '../utils/project' +import { LAYOUTS } from '../utils/project' +import { checkedFiles, listingProject } from './paths-utils' + +describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, app }) => { + const routes = `${app}src/routes` + + function walkedProject(...files: ReadonlyArray): Project { + return listingProject( + create, + Object.fromEntries(files.map((file) => [`${routes}/${file}`, ''])), + { git: 'none' }, + ) + } + + async function check(project: Project, ...patterns: ReadonlyArray): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + it('lists the files below a directory, sorted, without dot files or installed packages', async () => { + const project = walkedProject( + 'home.ts', + 'Zone.ts', + 'nested/deep.ts', + '.env.ts', + '.cache/entry.ts', + 'node_modules/dep/index.ts', + 'bower_components/dep/index.ts', + 'jspm_packages/dep/index.ts', + ) + const listed = [`${routes}/Zone.ts`, `${routes}/home.ts`, `${routes}/nested/deep.ts`] + + expect(await check(project, routes)).toEqual(listed) + expect(await check(project, `${routes}/**/*.ts`)).toEqual(listed) + }) + + it('lists the files .gitignore would leave out', async () => { + const project = walkedProject('home.ts', 'dist/bundle.ts').write({ + '.gitignore': 'dist/\n', + [`${app}dist/out.js`]: '', + }) + + expect(await check(project, routes, `${app}dist/**`)).toEqual([ + `${app}dist/out.js`, + `${routes}/dist/bundle.ts`, + `${routes}/home.ts`, + ]) + }) + + it('checks a file the walk skips when it is named', async () => { + const project = walkedProject('.env.ts', 'node_modules/dep/index.ts') + const named = [`${routes}/.env.ts`, `${routes}/node_modules/dep/index.ts`] + + expect(await check(project, ...named)).toEqual(named) + }) + + it('lists a linked file but never enters a linked folder or follows a broken link', async () => { + const project = walkedProject('home.ts', 'shared/util.ts') + .link(`${routes}/alias.ts`, 'home.ts') + .link(`${routes}/broken.ts`, 'missing.ts') + .link(`${routes}/self.ts`, 'self.ts') + .link(`${routes}/up`, '..') + .link(`${routes}/linked`, 'shared') + + expect(await check(project, routes)).toEqual([ + `${routes}/alias.ts`, + `${routes}/home.ts`, + `${routes}/shared/util.ts`, + ]) + }) + + // Root reads a folder whatever its mode. + it.skipIf(process.getuid?.() === 0)('walks past a folder it cannot read', async () => { + const project = walkedProject('home.ts', 'locked/secret.ts') + chmodSync(project.path(routes, 'locked'), 0) + + try { + expect(await check(project, routes)).toEqual([`${routes}/home.ts`]) + } finally { + chmodSync(project.path(routes, 'locked'), 0o755) + } + }) +}) diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts new file mode 100644 index 0000000..b967b98 --- /dev/null +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -0,0 +1,179 @@ +import { LAYOUTS, report } from '../utils/project' +import { layoutChecks } from './utils' + +const PRESET_CONFIGS = { + '.oxlintrc.json': null, + 'oxlint.config.ts': `import { defineConfig } from 'oxlint' +import { middleapi } from 'uncheck/oxlint' + +export default defineConfig({ + extends: [middleapi], +}) +`, + 'oxfmt.config.ts': `import { defineConfig } from 'oxfmt' +import { middleapi } from 'uncheck/oxfmt' + +export default defineConfig({ + ...middleapi, +}) +`, +} + +const BUG_RULES = [ + 'eslint(preserve-caught-error)', + 'oxc(misrefactored-assign-op)', + 'oxc(no-accumulating-spread)', + 'typescript(no-confusing-non-null-assertion)', + 'unicorn(no-accessor-recursion)', + 'unicorn(no-array-fill-with-reference-type)', + 'unicorn(no-negation-in-equality-check)', + 'eslint(no-constructor-return)', +] + +const USE_BEFORE_FIX = `import { c } from "./other.ts"; +import { a } from "./lib.ts"; +import { b } from "./lib.ts"; +console.log(a, b, c) +export const enum Level { Low } +` + +function presetProject(create: (typeof LAYOUTS)[number]['create'], app: string) { + return create({ + ...PRESET_CONFIGS, + [`${app}tsconfig.json`]: + app === '' + ? { extends: 'uncheck/tsconfig/middleapi', include: ['src'] } + : { + extends: 'uncheck/tsconfig/middleapi/lib', + references: [{ path: '../core' }], + include: ['src'], + }, + [`${app}src/legacy.ts`]: 'var count = 1\nif (count == 1) {\n count = 2\n}\nexport { count }\n', + [`${app}src/strict.ts`]: `export function first(items: string[]): string { + return items[0] +} + +export function identity(value) { + return value +} +`, + [`${app}src/bugs.ts`]: `export function parse() { + try { JSON.parse("x") } + catch (error) { throw new Error("parse failed") } +} +export function assign(a: number, b: number) { a -= a - b; return a } +export function collect(xs: number[]) { let out: number[] = []; for (const x of xs) { out = [...out, x] } return out } +export function confuse(a: string | null, b: string) { return a! == b } +export class Recurse { get v(): number { return this.v } } +export function fill() { return new Array(3).fill([]) } +export function negate(a: boolean, b: boolean) { return !a === b } +export class Construct { constructor() { return { x: 1 } } } +`, + [`${app}src/lib.ts`]: 'export const a = 1\nexport const b = 2\n', + [`${app}src/other.ts`]: 'export const c = 3\n', + [`${app}src/use.ts`]: USE_BEFORE_FIX, + [`${app}src/warn.ts`]: `import { a } from './lib.ts' +import { b } from './lib.ts' + +export function pause() { + debugger + return a + b +} +`, + [`${app}src/log.ts`]: "console.warn('careful')\nconsole.error('failed')\n", + }) +} + +describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ create, app }) => { + const { sherif, tsc, checks } = layoutChecks(app) + + it('reports the problems the presets catch', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck() + + for (const rule of [ + 'eslint(no-var)', + 'eslint(eqeqeq)', + 'eslint(no-console)', + 'oxc(no-const-enum)', + 'import(no-duplicates)', + ...BUG_RULES, + ]) { + expect(stdout).toContain(rule) + } + + expect(stdout).toContain(`${app}src/index.ts`) + expect(stdout).toContain('error TS2322') + expect(stdout).toContain('error TS7006') + expect(stdout).not.toContain('TS5097') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tsc, + '✘ tsc failed', + `✘ 3 of ${checks.length} checks failed: oxlint, oxfmt, tsc`, + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('reports warnings without failing the run', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `${app}src/warn.ts`, + `${app}src/log.ts`, + ]) + + expect(stdout).toContain('eslint(no-debugger)') + expect(stdout).toContain('import(no-duplicates)') + expect(stdout).not.toContain('no-console') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/log.ts ${app}src/warn.ts`, + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(exitCode).toBe(0) + }) + + it('fixes what the presets can fix', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--skip=tsc']) + + expect(stdout).toContain('eslint(no-console)') + expect(stdout).toContain('eslint(eqeqeq)') + expect(stdout).not.toContain('import(no-duplicates)') + expect(stdout).not.toContain('oxc(no-const-enum)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...layoutChecks(app, { fix: true }).sherif, + '▶ oxlint --fix', + '✘ oxlint failed', + '▶ oxfmt', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 1 of ${checks.length - 1} checks failed: oxlint`, + ]) + expect(exitCode).toBe(1) + expect(project.read(`${app}src/use.ts`)).toBe( + "import { a, b } from './lib.ts'\nimport { c } from './other.ts'\nconsole.log(a, b, c)\nexport enum Level {\n Low,\n}\n", + ) + expect(project.read(`${app}src/index.ts`)).toMatch( + /^import \{ double \} from '[^']+'\n\nexport const answer: number = double\(21\)\n$/, + ) + expect(project.read(`${app}src/legacy.ts`)).toBe( + 'let count = 1\nif (count == 1) {\n count = 2\n}\nexport { count }\n', + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts index b9587e8..c5f741d 100644 --- a/packages/uncheck/tests/uncheck/project.test.ts +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -1,13 +1,142 @@ -import { LAYOUTS, report } from '../utils/project' +import { LAYOUTS, monorepo, report } from '../utils/project' +import { layoutChecks, updateJson } from './utils' + +describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { + const { sherif, tsc, checks } = layoutChecks(app) -describe.each(LAYOUTS)('uncheck in a $name', ({ create }) => { it('passes a clean project', async () => { const project = create() const { exitCode, stdout, stderr } = await project.uncheck() expect(stderr).toBe('') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.join(', ')})`, + ]) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toMatch(/^✔ all checks passed/) + }) + + it('fails on a lint error and still runs the other checks', async () => { + const project = create({ [`${app}src/legacy.ts`]: 'var count = 1;\nexport { count };\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck() + + expect(stderr).toBe('') + expect(stdout).toContain('eslint(no-var)') + expect(stdout).toContain(`${app}src/legacy.ts`) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✘ 1 of ${checks.length} checks failed: oxlint`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('fails on a formatting issue', async () => { + const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = {a:1,\n b:2}\n' }) + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain(`${app}src/ugly.ts`) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tsc, + '✔ tsc passed', + `✘ 1 of ${checks.length} checks failed: oxfmt`, + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('fails on a type error without offering fixes', async () => { + const project = create({ [`${app}src/broken.ts`]: 'export const broken: number = "42";\n' }) + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain(`${app}src/broken.ts`) + expect(stdout).toContain('TS2322') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✘ tsc failed', + `✘ 1 of ${checks.length} checks failed: tsc`, + ]) + expect(exitCode).toBe(1) + }) + + it('fails when a tsconfig.json exists but typescript is not installed', async () => { + const project = create({}, { tools: ['sherif', 'oxlint', 'oxfmt'] }) + const tsconfigs = app === '' ? 1 : 3 + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + `✘ tsc found ${tsconfigs} tsconfig.json but typescript is not installed`, + `✘ 1 of ${checks.length} checks failed: tsc`, + ]) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck in a monorepo', () => { + it('fails on a workspace issue sherif finds', async () => { + const project = monorepo() + updateJson(project, 'packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + updateJson(project, 'packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✘ 1 of 4 checks failed: sherif', + ' rerun with `--fix` to apply sherif fixes', + ]) + expect(exitCode).toBe(1) }) }) diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts new file mode 100644 index 0000000..c53ffb6 --- /dev/null +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -0,0 +1,191 @@ +import { LAYOUTS, project as bareProject, report, singleRepo } from '../utils/project' +import { layoutChecks } from './utils' + +describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) => { + const { sherif, tsc, checks } = layoutChecks(app) + + it('runs only the checks named with --only', async () => { + const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = 1\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, tsc)', + ]) + expect(exitCode).toBe(0) + }) + + it('skips the checks named with --skip', async () => { + const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = 1\n' }) + + const { exitCode, stdout } = await project.uncheck(['--skip=oxfmt', '--skip=tsc']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, disabled with --skip=oxfmt', + '○ tsc skipped, disabled with --skip=tsc', + `✔ all checks passed (${checks.filter((name) => name !== 'oxfmt' && name !== 'tsc').join(', ')})`, + ]) + expect(exitCode).toBe(0) + }) + + it('skips a check whose tool is not installed, unless it is required', async () => { + const project = create({}, { tools: ['sherif', 'oxlint', 'typescript'] }) + + const skipped = await project.uncheck(['--only=oxlint', '--only=oxfmt']) + + expect(report(skipped.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(skipped.exitCode).toBe(0) + + const required = await project.uncheck(['--only=oxlint', '--only=oxfmt', '--require=oxfmt']) + + expect(report(required.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '✘ oxfmt not installed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 2 checks failed: oxfmt', + ]) + expect(required.exitCode).toBe(1) + }) + + it('runs a required check that can run as usual', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + '--only=oxfmt', + '--require=oxlint', + ]) + + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxlint, oxfmt)') + expect(exitCode).toBe(0) + }) + + it('fails when every check is skipped', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--skip=sherif', + '--skip=oxlint', + '--skip=oxfmt', + '--skip=tsc', + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, disabled with --skip=sherif', + '○ oxlint skipped, disabled with --skip=oxlint', + '○ oxfmt skipped, disabled with --skip=oxfmt', + '○ tsc skipped, disabled with --skip=tsc', + '✘ nothing to check: sherif disabled with --skip=sherif, oxlint disabled with --skip=oxlint, oxfmt disabled with --skip=oxfmt, tsc disabled with --skip=tsc', + ]) + expect(exitCode).toBe(1) + }) + + it('fails when no check has anything to do with the given files', async () => { + const project = create({ [`${app}README.md`]: '# App\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', `${app}README.md`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, no tsconfig.json covers the given files', + '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', + ]) + expect(exitCode).toBe(1) + }) + + it('passes when no check has anything to do with the given files and unmatched patterns are allowed', async () => { + const project = create({ [`${app}README.md`]: '# App\n' }) + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + '--only=tsc', + `${app}README.md`, + ]) + + expect(report(stdout).at(-1)).toBe( + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', + ) + expect(exitCode).toBe(0) + }) +}) + +describe('uncheck check selection', () => { + it('fails in a project that uses none of the tools', async () => { + const project = bareProject({ 'package.json': { name: 'bare', private: true } }, { tools: [] }) + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', + ]) + expect(exitCode).toBe(1) + }) + + it.each([ + { + contradiction: 'a required check it skips', + flags: ['--require=tsc', '--skip=tsc'], + message: '--require=tsc and --skip=tsc contradict each other.', + }, + { + contradiction: 'a selected check it skips', + flags: ['--only=oxlint', '--skip=oxlint'], + message: '--only=oxlint and --skip=oxlint contradict each other.', + }, + { + contradiction: 'a required check it does not select', + flags: ['--only=oxlint', '--only=oxfmt', '--require=tsc'], + message: '--require=tsc and --only=oxlint --only=oxfmt contradict each other.', + }, + ])('refuses $contradiction', async ({ flags, message }) => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck(flags) + + expect(stdout).toBe('') + expect(stderr).toBe(`\nERROR\n ${message}\n`) + expect(exitCode).toBe(1) + }) + + it('refuses a check name it does not know', async () => { + const project = singleRepo() + + const { exitCode, stderr } = await project.uncheck(['--only=eslint']) + + expect(stderr).toBe( + '\nERROR\n Invalid value for flag --only: "eslint". Expected: "sherif" | "oxlint" | "oxfmt" | "tsc"\n', + ) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts new file mode 100644 index 0000000..0c7cca6 --- /dev/null +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -0,0 +1,152 @@ +import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { updateJson } from './utils' + +const OTHERS_NOT_SELECTED = [ + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', +] + +function sherifSkipped(dir: string, reason: string): string[] { + return [ + `uncheck in ${dir}`, + `○ sherif skipped, ${reason}`, + ...OTHERS_NOT_SELECTED, + `✘ nothing to check: sherif ${reason}, oxlint not selected by --only, oxfmt not selected by --only, tsc not selected by --only`, + ] +} + +describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { + it('skips sherif when it is not installed', async () => { + const project = create({}, { tools: ['oxlint', 'oxfmt', 'typescript'] }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', '--only=oxlint']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(exitCode).toBe(0) + }) + + it('skips sherif in a folder without a package.json', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif'], { cwd: `${app}src` }) + + expect(report(stdout)).toEqual(sherifSkipped(project.path(app, 'src'), 'no package.json found')) + expect(exitCode).toBe(1) + }) + + it.each([ + ['is not JSON', '{ "name": \n'], + ['is not an object', 'null\n'], + ])('skips sherif when the package.json %s', async (_, manifest) => { + const project = create({ 'package.json': manifest }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'no package.json found')) + expect(exitCode).toBe(1) + }) + + it('skips sherif in a package that is not a workspace root', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif'], { cwd: app }) + + expect(report(stdout)).toEqual(sherifSkipped(project.path(app, '.'), 'not a workspace root')) + expect(exitCode).toBe(1) + }) + + it('skips sherif when no package.json is among the given files', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', `${app}src/index.ts`]) + + expect(report(stdout)).toEqual( + sherifSkipped(project.dir, 'no package.json among the given files'), + ) + expect(exitCode).toBe(1) + }) + + it('takes a pnpm-workspace.yaml link that loops for no workspace', async () => { + const project = create({ 'pnpm-workspace.yaml': null }).link( + 'pnpm-workspace.yaml', + 'pnpm-workspace.yaml', + ) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'not a workspace root')) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck sherif in a single repo', () => { + it('fails when sherif is required outside a workspace', async () => { + const project = singleRepo() + + const { exitCode, stdout } = await project.uncheck(['--require=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '✘ sherif not a workspace root', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✘ 1 of 4 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck sherif in a monorepo', () => { + it.each(['packages/core/package.json', 'pnpm-workspace.yaml'])( + 'checks the whole workspace when %s is among the given files', + async (file) => { + const project = monorepo() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', file]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✔ sherif passed', + ...OTHERS_NOT_SELECTED, + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + }, + ) + + it('finds a workspace declared in the workspaces field of package.json', async () => { + const project = monorepo({ 'pnpm-workspace.yaml': null }) + updateJson(project, 'package.json', (manifest) => ({ ...manifest, workspaces: ['packages/*'] })) + updateJson(project, 'packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { 'zod': '^3.0.0', '@repo/core': 'workspace:*' }, + })) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(stdout).toContain('unordered-dependencies') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + ...OTHERS_NOT_SELECTED, + '✘ 1 of 1 checks failed: sherif', + ' rerun with `--fix` to apply sherif fixes', + ]) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts new file mode 100644 index 0000000..3a9be12 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -0,0 +1,250 @@ +import { LAYOUTS, monorepo } from '../utils/project' +import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './tsc.utils' + +const NO_JS = { compilerOptions: { allowJs: false } } + +describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { + it('resolves a relative or absolute extends, with or without .json', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: ALLOW_JS, + [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { extends: '../tsconfig.base', include: ['src'] }, + [`${app}docs/tsconfig.json`]: { extends: '..\\tsconfig.base.json', include: ['src'] }, + [`${app}cli/tsconfig.json`]: { extends: './tsconfig.shared.json', include: ['src'] }, + [`${app}cli/tsconfig.shared.json`]: ALLOW_JS, + [`${app}legacy/tsconfig.json`]: { extends: '../tsconfig.legacy', include: ['src'] }, + }) + + project.write({ + [`${app}bin/tsconfig.json`]: { + extends: project.path(app, 'tsconfig.base.json'), + include: ['src'], + }, + }) + + const scripts = ['bin', 'cli', 'docs', 'legacy', 'server', 'web'].map( + (dir) => `${dir}/src/a.js`, + ) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p bin/tsconfig.json --noEmit', + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p docs/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends to its tsconfig.json, its tsconfig field or a file in it', async () => { + const project = withFakeTsc(create, { + 'node_modules/@tsconfig/node22/package.json': { name: '@tsconfig/node22', version: '1.0.0' }, + 'node_modules/@tsconfig/node22/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-preset/package.json': { + name: 'tsconfig-preset', + version: '1.0.0', + tsconfig: './preset.json', + }, + 'node_modules/tsconfig-preset/preset.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/package.json': { name: '@acme/tsconfig', version: '1.0.0' }, + 'node_modules/@acme/tsconfig/node.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/web/tsconfig.json': ALLOW_JS, + [`${app}node/tsconfig.json`]: { extends: '@tsconfig/node22', include: ['src'] }, + [`${app}preset/tsconfig.json`]: { extends: 'tsconfig-preset', include: ['src'] }, + [`${app}file/tsconfig.json`]: { extends: '@acme/tsconfig/node.json', include: ['src'] }, + [`${app}name/tsconfig.json`]: { extends: '@acme/tsconfig/node', include: ['src'] }, + [`${app}folder/tsconfig.json`]: { extends: '@acme/tsconfig/web', include: ['src'] }, + [`${app}missing/tsconfig.json`]: { extends: '@acme/missing', include: ['src'] }, + }) + + const scripts = ['file', 'folder', 'missing', 'name', 'node', 'preset'].map( + (dir) => `${dir}/src/a.js`, + ) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p file/tsconfig.json --noEmit', + '▶ tsc -p folder/tsconfig.json --noEmit', + '▶ tsc -p name/tsconfig.json --noEmit', + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p preset/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through the subpaths and conditions of its exports only', async () => { + const project = withFakeTsc(create, { + 'node_modules/@acme/tsconfig/package.json': { + name: '@acme/tsconfig', + version: '1.0.0', + exports: { + '.': './tsconfig.json', + './lib': { import: './esm.json', require: './lib.json' }, + './node': { node: { types: './node.json' } }, + './web': [{ import: './esm.json' }, './web.json'], + './legacy': null, + }, + }, + 'node_modules/@acme/tsconfig/tsconfig.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/esm.json': NO_JS, + 'node_modules/@acme/tsconfig/lib.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/node.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/web.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/legacy.json': ALLOW_JS, + [`${app}main/tsconfig.json`]: { extends: '@acme/tsconfig', include: ['src'] }, + [`${app}lib/tsconfig.json`]: { extends: '@acme/tsconfig/lib', include: ['src'] }, + [`${app}node/tsconfig.json`]: { extends: '@acme/tsconfig/node', include: ['src'] }, + [`${app}web/tsconfig.json`]: { extends: '@acme/tsconfig/web', include: ['src'] }, + [`${app}legacy/tsconfig.json`]: { extends: '@acme/tsconfig/legacy', include: ['src'] }, + [`${app}file/tsconfig.json`]: { extends: '@acme/tsconfig/lib.json', include: ['src'] }, + }) + + const scripts = ['file', 'legacy', 'lib', 'main', 'node', 'web'].map((dir) => `${dir}/src/a.js`) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p lib/tsconfig.json --noEmit', + '▶ tsc -p main/tsconfig.json --noEmit', + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through the * pattern of its exports with the longest prefix', async () => { + const project = withFakeTsc(create, { + 'node_modules/@acme/presets/package.json': { + name: '@acme/presets', + version: '1.0.0', + exports: { + './*': './loose/*.json', + './strict/*': './strict/*.json', + './s*': './loose/s*.json', + }, + }, + 'node_modules/@acme/presets/loose/web.json': ALLOW_JS, + 'node_modules/@acme/presets/loose/strict/node.json': NO_JS, + 'node_modules/@acme/presets/strict/node.json': ALLOW_JS, + [`${app}web/tsconfig.json`]: { extends: '@acme/presets/web', include: ['src'] }, + [`${app}node/tsconfig.json`]: { extends: '@acme/presets/strict/node', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['node/src/a.js', 'web/src/a.js'])).toEqual([ + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('never matches a * pattern of its exports longer than the subpath', async () => { + const project = withFakeTsc(create, { + 'node_modules/tsconfig-pattern/package.json': { + name: 'tsconfig-pattern', + version: '1.0.0', + exports: { './a*a': './tsconfig.json' }, + }, + 'node_modules/tsconfig-pattern/tsconfig.json': ALLOW_JS, + [`${app}short/tsconfig.json`]: { extends: 'tsconfig-pattern/a', include: ['src'] }, + [`${app}long/tsconfig.json`]: { extends: 'tsconfig-pattern/aba', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['long/src/a.js', 'short/src/a.js'])).toEqual([ + '▶ tsc -p long/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through exports given as a path, an array or conditions', async () => { + const project = withFakeTsc(create, { + 'node_modules/tsconfig-path/package.json': { + name: 'tsconfig-path', + version: '1.0.0', + exports: './tsconfig.json', + }, + 'node_modules/tsconfig-path/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-array/package.json': { + name: 'tsconfig-array', + version: '1.0.0', + exports: ['./tsconfig.json'], + }, + 'node_modules/tsconfig-array/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-conditions/package.json': { + name: 'tsconfig-conditions', + version: '1.0.0', + exports: { import: './esm.json', default: './tsconfig.json' }, + }, + 'node_modules/tsconfig-conditions/esm.json': NO_JS, + 'node_modules/tsconfig-conditions/tsconfig.json': ALLOW_JS, + [`${app}path/tsconfig.json`]: { extends: 'tsconfig-path', include: ['src'] }, + [`${app}array/tsconfig.json`]: { extends: 'tsconfig-array', include: ['src'] }, + [`${app}conditions/tsconfig.json`]: { extends: 'tsconfig-conditions', include: ['src'] }, + }) + + const scripts = ['array', 'conditions', 'path'].map((dir) => `${dir}/src/a.js`) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p array/tsconfig.json --noEmit', + '▶ tsc -p conditions/tsconfig.json --noEmit', + '▶ tsc -p path/tsconfig.json --noEmit', + ]) + }) + + it('applies the configs of an extends array in order and ignores an invalid extends', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.js.json`]: ALLOW_JS, + [`${app}tsconfig.ts.json`]: NO_JS, + [`${app}web/tsconfig.json`]: { extends: ['../tsconfig.ts.json', '../tsconfig.js.json'] }, + [`${app}server/tsconfig.json`]: { extends: ['../tsconfig.js.json', '../tsconfig.ts.json'] }, + [`${app}cli/tsconfig.json`]: { extends: ['../tsconfig.js.json', 42] }, + }) + + expect(await tscPlan(project, app, ['cli/a.js', 'server/a.js', 'web/a.js'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('stops at circular extends', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { extends: './tsconfig.src.json' }, + [`${app}web/tsconfig.src.json`]: { extends: './tsconfig.json', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('applies a base that two extends branches share in both, like tsc', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: { include: [`${CONFIG_DIR}/lib`] }, + [`${app}tsconfig.src.json`]: { + extends: './tsconfig.base.json', + include: [`${CONFIG_DIR}/src`], + }, + [`${app}tsconfig.strict.json`]: { + extends: './tsconfig.base.json', + compilerOptions: { strict: true }, + }, + [`${app}web/tsconfig.json`]: { extends: ['../tsconfig.src.json', '../tsconfig.strict.json'] }, + }) + + expect(await tscPlan(project, app, ['web/lib/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) + }) +}) + +describe('tsc extends across the packages of a monorepo', () => { + it('resolves a package from the node_modules closest to each config', async () => { + const project = withFakeTsc(monorepo, { + 'node_modules/@acme/tsconfig/package.json': { name: '@acme/tsconfig', version: '1.0.0' }, + 'node_modules/@acme/tsconfig/tsconfig.json': ALLOW_JS, + 'packages/app/node_modules/@acme/tsconfig/package.json': { + name: '@acme/tsconfig', + version: '2.0.0', + }, + 'packages/app/node_modules/@acme/tsconfig/tsconfig.json': NO_JS, + 'packages/app/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, + 'packages/core/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/a.js'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/app/src/a.js'])).toEqual([NOT_COVERED]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts new file mode 100644 index 0000000..596075e --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -0,0 +1,225 @@ +import { writeFileSync } from 'node:fs' +import { delimiter, join, resolve } from 'node:path' + +import { environment, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { + ALLOW_JS, + CONFIG_DIR, + NOT_COVERED, + SKIPPED_BY_ONLY, + tscPlan, + withFakeTsc, +} from './tsc.utils' + +describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { + it('takes files, include and exclude from the last config setting them, relative to it', async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.base.json`]: { include: ['../lib'], exclude: ['../lib/vendor'] }, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + [`${app}server/tsconfig.json`]: { + extends: '../config/tsconfig.base.json', + include: ['src'], + }, + [`${app}cli/tsconfig.json`]: { + extends: ['../config/tsconfig.base.json', './tsconfig.entry.json'], + }, + [`${app}cli/tsconfig.entry.json`]: { files: ['main.ts'] }, + [`${app}docs/tsconfig.json`]: { files: ['index.ts'] }, + }) + + expect(await tscPlan(project, app, ['lib/index.ts'])).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['cli/main.ts', 'docs/index.ts', 'server/src/index.ts']), + ).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p docs/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['docs/guide.ts', 'lib/vendor/index.ts', 'web/lib/index.ts']), + ).toEqual([NOT_COVERED]) + }) + + it(`reads ${CONFIG_DIR} as the folder of the config that extends the shared one`, async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.base.json`]: { + files: [`${CONFIG_DIR}/env.d.ts`], + include: [`${CONFIG_DIR}/src`], + exclude: [`${CONFIG_DIR}/src/generated`], + }, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + [`${app}server/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + }) + + expect(await tscPlan(project, app, ['server/env.d.ts', 'web/src/index.ts'])).toEqual([ + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['config/src/index.ts', 'web/src/generated/api.ts']), + ).toEqual([NOT_COVERED]) + }) + + it('leaves out outDir and declarationDir unless exclude is set', async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.build.json`]: { + compilerOptions: { outDir: `${CONFIG_DIR}/build` }, + }, + [`${app}web/tsconfig.json`]: { compilerOptions: { outDir: 'dist', declarationDir: 'types' } }, + [`${app}server/tsconfig.json`]: { + compilerOptions: { outDir: 'dist' }, + exclude: ['**/*.test.ts'], + }, + [`${app}cli/tsconfig.json`]: { extends: '../config/tsconfig.build.json' }, + }) + + expect( + await tscPlan(project, app, ['cli/src/index.ts', 'server/dist/index.ts', 'web/src/index.ts']), + ).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'cli/build/index.d.ts', + 'server/src/index.test.ts', + 'web/dist/index.d.ts', + 'web/types/index.d.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('selects JavaScript files only when allowJs or checkJs is on', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.js.json`]: ALLOW_JS, + [`${app}js/tsconfig.json`]: { extends: '../tsconfig.js.json' }, + [`${app}ts/tsconfig.json`]: { + extends: '../tsconfig.js.json', + compilerOptions: { allowJs: false }, + }, + [`${app}check/tsconfig.json`]: { compilerOptions: { checkJs: true } }, + [`${app}both/tsconfig.json`]: { compilerOptions: { allowJs: false, checkJs: true } }, + [`${app}plain/tsconfig.json`]: { compilerOptions: { strict: true } }, + }) + + expect( + await tscPlan(project, app, [ + 'both/index.js', + 'check/index.mjs', + 'js/index.js', + 'plain/index.cjs', + 'ts/index.jsx', + ]), + ).toEqual([ + '▶ tsc -p both/tsconfig.json --noEmit', + '▶ tsc -p check/tsconfig.json --noEmit', + '▶ tsc -p js/tsconfig.json --noEmit', + ]) + }) + + it('selects a JavaScript file named in the files list even without allowJs', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { files: ['legacy.js'], include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['web/legacy.js'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/legacy.js'])).toEqual([NOT_COVERED]) + }) + + it('selects a JSON file only through the files list or an include ending in .json', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src', 'config/*.json'] }, + [`${app}server/tsconfig.json`]: { files: ['data.json'], include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['server/data.json', 'web/config/app.json'])).toEqual([ + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['server/src/data.json', 'web/src/data.json'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('selects a changed config and every config that extends it', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: { compilerOptions: { strict: true } }, + [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}cli/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['cli/tsconfig.json', 'tsconfig.base.json'])).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['package.json'])).toEqual([NOT_COVERED]) + }) + + it('reads a tsconfig.json with comments and trailing commas', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: '{\n // Only the library.\n "include": ["lib",],\n}\n', + }) + + expect(await tscPlan(project, app, ['web/lib/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) + }) + + it('still plans the other files when a given JSON file is deleted while uncheck runs', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}web/src/index.ts`]: '', + [`${app}web/data.json`]: '{}\n', + }) + const gitDeletingDataJson = temporaryDirectory() + + writeFileSync( + join(gitDeletingDataJson, 'git'), + `#!/bin/sh\n[ "$1" = ls-files ] && rm -f web/data.json\nPATH=\${PATH#*:} exec git "$@"\n`, + { mode: 0o755 }, + ) + + const { exitCode, stdout } = await project.uncheck( + ['--only=tsc', 'web/src/index.ts', 'web/data.json'], + { cwd: app, env: { PATH: `${gitDeletingDataJson}${delimiter}${environment().PATH}` } }, + ) + + expect(project.exists(`${app}web/data.json`)).toBe(false) + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${resolve(project.dir, app)}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p web/tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + }) +}) + +describe('tsc inputs across the packages of a monorepo', () => { + it('selects the configs that extend a changed workspace config package by its real path', async () => { + const project = withFakeTsc(monorepo, { + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + }, + 'packages/tsconfig/base.json': { compilerOptions: { strict: true } }, + 'packages/app/tsconfig.json': { extends: '@repo/tsconfig/base.json', include: ['src'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + expect(await tscPlan(project, '', ['packages/tsconfig/base.json'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/tsconfig/package.json'])).toEqual([NOT_COVERED]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts new file mode 100644 index 0000000..1998074 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts @@ -0,0 +1,156 @@ +import { LAYOUTS } from '../utils/project' +import { ALLOW_JS, NOT_COVERED, tscPlan, withFakeTsc } from './tsc.utils' + +describe.each(LAYOUTS)('tsc include and exclude patterns in a $name', ({ create, app }) => { + it('reads a pattern without extension or wildcard as a folder', async () => { + const project = withFakeTsc(create, { + [`${app}folder/tsconfig.json`]: { include: ['lib'] }, + [`${app}slash/tsconfig.json`]: { include: ['.\\lib/'] }, + [`${app}dotted/tsconfig.json`]: { include: ['lib/v1.0'] }, + }) + + expect(await tscPlan(project, app, ['folder/lib/deep/index.ts', 'slash/lib/index.ts'])).toEqual( + ['▶ tsc -p folder/tsconfig.json --noEmit', '▶ tsc -p slash/tsconfig.json --noEmit'], + ) + expect(await tscPlan(project, app, ['dotted/lib/v1.0/index.ts', 'folder/lib.ts'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('ignores an include that ends in **', async () => { + const project = withFakeTsc(create, { + [`${app}bare/tsconfig.json`]: { include: ['src/**'] }, + [`${app}files/tsconfig.json`]: { include: ['src/**/*.ts'] }, + }) + + expect(await tscPlan(project, app, ['bare/src/index.ts'])).toEqual([NOT_COVERED]) + expect(await tscPlan(project, app, ['files/src/deep/index.ts'])).toEqual([ + '▶ tsc -p files/tsconfig.json --noEmit', + ]) + }) + + it('never lets * or ? cross a folder', async () => { + const project = withFakeTsc(create, { + [`${app}star/tsconfig.json`]: { include: ['scripts/*.ts'] }, + [`${app}question/tsconfig.json`]: { include: ['v?/*.ts'] }, + }) + + expect(await tscPlan(project, app, ['question/v1/index.ts', 'star/scripts/build.ts'])).toEqual([ + '▶ tsc -p question/tsconfig.json --noEmit', + '▶ tsc -p star/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['question/v10/index.ts', 'star/scripts/deep/build.ts']), + ).toEqual([NOT_COVERED]) + }) + + it('never matches a dot file with a leading wildcard', async () => { + const project = withFakeTsc(create, { + [`${app}star/tsconfig.json`]: { include: ['*.ts'] }, + [`${app}question/tsconfig.json`]: { include: ['?rc.ts'] }, + [`${app}named/tsconfig.json`]: { include: ['.config/*.ts'] }, + }) + + expect( + await tscPlan(project, app, ['named/.config/index.ts', 'question/arc.ts', 'star/index.ts']), + ).toEqual([ + '▶ tsc -p named/tsconfig.json --noEmit', + '▶ tsc -p question/tsconfig.json --noEmit', + '▶ tsc -p star/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['question/.rc.ts', 'star/.eslintrc.ts'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('skips node_modules and dot folders unless the pattern names them', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}vendor/tsconfig.json`]: { include: ['node_modules/lib'] }, + [`${app}generated/tsconfig.json`]: { include: ['.generated/**/*'] }, + }) + + expect( + await tscPlan(project, app, [ + 'generated/.generated/index.ts', + 'vendor/node_modules/lib/index.ts', + ]), + ).toEqual([ + '▶ tsc -p generated/tsconfig.json --noEmit', + '▶ tsc -p vendor/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'web/src/.cache/index.ts', + 'web/src/bower_components/index.ts', + 'web/src/node_modules/lib/index.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('never matches a name ending in .min.js with *', async () => { + const project = withFakeTsc(create, { + [`${app}vendor/tsconfig.json`]: { ...ALLOW_JS, include: ['*'] }, + [`${app}plugins/tsconfig.json`]: { ...ALLOW_JS, include: ['jquery*'] }, + }) + + expect(await tscPlan(project, app, ['plugins/jquery.ui.js', 'vendor/jquery.min.jsx'])).toEqual([ + '▶ tsc -p plugins/tsconfig.json --noEmit', + '▶ tsc -p vendor/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['plugins/jquery.min.js', 'vendor/jquery.min.js'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('ignores case in include but not in exclude', async () => { + const project = withFakeTsc(create, { + [`${app}include/tsconfig.json`]: { include: ['SRC'] }, + [`${app}exclude/tsconfig.json`]: { include: ['src'], exclude: ['src/Legacy'] }, + }) + + expect( + await tscPlan(project, app, ['exclude/src/legacy/index.ts', 'include/src/index.ts']), + ).toEqual([ + '▶ tsc -p exclude/tsconfig.json --noEmit', + '▶ tsc -p include/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['exclude/src/Legacy/index.ts'])).toEqual([NOT_COVERED]) + }) + + it('excludes everything below an excluded folder or pattern', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { + include: ['src'], + exclude: ['src/legacy', 'src/**/*.test.ts', '**/__mocks__/**', 'src/v?'], + }, + }) + + expect(await tscPlan(project, app, ['web/src/deep/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'web/src/__mocks__/api/index.ts', + 'web/src/deep/index.test.ts', + 'web/src/index.test.ts', + 'web/src/legacy/deep/index.ts', + 'web/src/v1/index.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('matches the other characters of a pattern as they are', async () => { + const project = withFakeTsc(create, { + [`${app}group/tsconfig.json`]: { include: ['app/(marketing)'] }, + [`${app}route/tsconfig.json`]: { include: ['app/[id]+.ts'] }, + }) + + expect( + await tscPlan(project, app, ['group/app/(marketing)/page.tsx', 'route/app/[id]+.ts']), + ).toEqual(['▶ tsc -p group/tsconfig.json --noEmit', '▶ tsc -p route/tsconfig.json --noEmit']) + expect(await tscPlan(project, app, ['group/app/marketing/page.tsx', 'route/app/i.ts'])).toEqual( + [NOT_COVERED], + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts new file mode 100644 index 0000000..cd648e4 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -0,0 +1,206 @@ +import { LAYOUTS, monorepo, report } from '../utils/project' +import { CONFIG_DIR, NOT_COVERED, SKIPPED_BY_ONLY, tscPlan, withFakeTsc } from './tsc.utils' + +describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => { + it('builds a solution-style tsconfig.json whose references have other names', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], + }, + [`${app}tsconfig.app.json`]: { include: ['src'] }, + [`${app}tsconfig.node.json`]: { include: ['vite.config.ts'] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['vite.config.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([NOT_COVERED]) + }) + + it('follows references to a folder, to a .json file and with backslashes', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: 'web' }, { path: '.\\server\\tsconfig.build.json' }], + }, + [`${app}web/tsconfig.json`]: { + include: ['src'], + references: [{ path: '../ui/tsconfig.json' }], + }, + [`${app}ui/tsconfig.json`]: { include: ['src'] }, + [`${app}server/tsconfig.build.json`]: { + include: ['src'], + references: [{ path: '..\\shared' }], + }, + [`${app}shared/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['server/src/main.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + expect( + await tscPlan(project, app, ['shared/src/index.ts', 'ui/src/index.ts', 'web/src/index.ts']), + ).toEqual(['▶ tsc -b tsconfig.json']) + }) + + it(`follows a reference path with ${CONFIG_DIR} as written, like tsc`, async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { references: [{ path: `${CONFIG_DIR}/../lib` }] }, + [`${app}web/lib/tsconfig.json`]: { include: ['src'] }, + [`${app}lib/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b web/tsconfig.json', + '▶ tsc -p lib/tsconfig.json --noEmit', + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('ignores references that are not objects with a string path', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: ['./scripts', { path: 42 }, null, { prepend: true }, { path: './lib' }], + }, + [`${app}lib/tsconfig.json`]: { include: ['src'], references: './scripts' }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p scripts/tsconfig.json --noEmit', + ]) + }) + + it('builds the roots of the reference graph that depend on the given files', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['scripts'] }, + [`${app}shared/tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, + [`${app}server/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, + [`${app}cli/tsconfig.json`]: { include: ['src'], references: [{ path: '../server' }] }, + [`${app}e2e/tsconfig.json`]: { + include: ['src'], + references: [{ path: '../web' }, { path: '../server' }], + }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b cli/tsconfig.json e2e/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ + '▶ tsc -b cli/tsconfig.json e2e/tsconfig.json', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([ + '▶ tsc -b e2e/tsconfig.json', + ]) + expect(await tscPlan(project, app, ['cli/src/index.ts', 'scripts/release.ts'])).toEqual([ + '▶ tsc -b cli/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('checks each standalone tsconfig.json with -p --noEmit', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'] }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + [`${app}test/tsconfig.json`]: { include: ['.'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -p scripts/tsconfig.json --noEmit', + '▶ tsc -p test/tsconfig.json --noEmit', + '▶ tsc -p tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['test/index.test.ts', 'scripts/release.ts'])).toEqual([ + '▶ tsc -p scripts/tsconfig.json --noEmit', + '▶ tsc -p test/tsconfig.json --noEmit', + ]) + }) + + it('fails on circular references, naming only the configs on the cycle', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['scripts'] }, + [`${app}api/tsconfig.json`]: { references: [{ path: '../lib' }] }, + [`${app}lib/tsconfig.json`]: { references: [{ path: '../utils' }] }, + [`${app}utils/tsconfig.json`]: { references: [{ path: '../lib' }] }, + }) + const cycle = '✘ tsc circular project references between lib/tsconfig.json, utils/tsconfig.json' + + expect(await tscPlan(project, app)).toEqual([cycle]) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([cycle]) + }) + + it('leaves a reference to a missing config to tsc -b', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './legacy' }] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['legacy/index.ts'])).toEqual([NOT_COVERED]) + }) + + it('leaves a reference to a folder named like a config file to tsc -b', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './lib.json' }] }, + [`${app}lib.json/index.ts`]: '', + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + }) +}) + +describe('tsc project references across the packages of a monorepo', () => { + it('builds every package that references a changed package, and no other', async () => { + const project = withFakeTsc(monorepo, { + 'tsconfig.json': { include: ['scripts'] }, + 'packages/web/package.json': { name: '@repo/web', version: '1.0.0', private: true }, + 'packages/web/tsconfig.json': { include: ['src'], references: [{ path: '../core' }] }, + 'packages/cli/package.json': { name: '@repo/cli', version: '1.0.0', private: true }, + 'packages/cli/tsconfig.json': { include: ['src'] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -b packages/app/tsconfig.json packages/web/tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/app/src/index.ts'])).toEqual([ + '▶ tsc -b packages/app/tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/cli/src/index.ts', 'scripts/release.ts'])).toEqual( + ['▶ tsc -p packages/cli/tsconfig.json --noEmit', '▶ tsc -p tsconfig.json --noEmit'], + ) + }) + + it('builds the solution tsconfig.json at the top for a change in any package', async () => { + const project = withFakeTsc(monorepo) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + }) + + it('builds a package from its folder for a change in a package it references', async () => { + const project = withFakeTsc(monorepo) + + expect(await tscPlan(project, 'packages/app', ['../core/src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + }) + + it('checks a package on its own from its folder', async () => { + const project = withFakeTsc(monorepo) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: 'packages/core' }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path('packages/core')}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts new file mode 100644 index 0000000..642c40d --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -0,0 +1,196 @@ +import { chmodSync } from 'node:fs' +import { resolve } from 'node:path' + +import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { ALLOW_JS, NOT_COVERED, SKIPPED_BY_ONLY, tscPlan, withFakeTsc } from './tsc.utils' + +describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { + it('skips the check when the project has no tsconfig.json', async () => { + const project = create({ [`${app}tsconfig.json`]: null }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${resolve(project.dir, app)}`, + ...SKIPPED_BY_ONLY, + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json found', + ]) + }) + + it('skips a tracked tsconfig.json deleted from the working tree', async () => { + const project = withFakeTsc(create).write({ [`${app}tsconfig.json`]: null }) + + expect(project.git('ls-files', `${app}tsconfig.json`)).toBe(`${app}tsconfig.json\n`) + expect(await tscPlan(project, app)).toEqual(['○ tsc skipped, no tsconfig.json found']) + }) + + it('skips the check when no tsconfig.json covers the given files', async () => { + const project = withFakeTsc(create, { [`${app}README.md`]: '# App\n' }) + + expect(await tscPlan(project, app, ['README.md', 'src/LEGACY.TS'])).toEqual([NOT_COVERED]) + expect(await tscPlan(project, app, ['README.md', 'scripts/release.ts'])).toEqual([NOT_COVERED]) + }) + + it('fails when typescript is not installed, counting the configs it would check', async () => { + const project = create({ [`${app}scripts/tsconfig.json`]: { include: ['.'] } }, { tools: [] }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${resolve(project.dir, app)}`, + ...SKIPPED_BY_ONLY, + '✘ tsc found 2 tsconfig.json but typescript is not installed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([ + '✘ tsc found 1 tsconfig.json but typescript is not installed', + ]) + }) + + it('hands a malformed tsconfig.json to tsc, which reports it', async () => { + const project = create({ [`${app}tsconfig.json`]: 'oops\n', [`${app}scripts/release.ts`]: '' }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'scripts/release.ts'], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${resolve(project.dir, app)}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain("tsconfig.json(1,1): error TS1005: '{' expected.") + }) + + // chmod cannot stop root from reading a file. + it.skipIf(process.getuid?.() === 0)( + 'hands an unreadable tsconfig.json to tsc, which reports it', + async () => { + const project = create() + + chmodSync(project.path(app, 'tsconfig.json'), 0) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'src/index.ts'], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${resolve(project.dir, app)}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain( + `error TS5083: Cannot read file '${project.path(app, 'tsconfig.json')}'.`, + ) + }, + ) + + it('finds the tsconfig.json files outside git by walking the folders, never into node_modules', async () => { + const project = withFakeTsc( + create, + { + [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }] }, + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + [`${app}node_modules/@tsconfig/node22/tsconfig.json`]: ALLOW_JS, + [`${app}web/node_modules/lib/tsconfig.json`]: {}, + }, + { git: 'none' }, + ) + + expect(project.exists('.git')).toBe(false) + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p scripts/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + }) +}) + +describe('tsc with the real compiler in a single repo', () => { + it('typechecks a tsconfig.json with -p --noEmit, emitting nothing', async () => { + const project = singleRepo({ + 'tsconfig.json': { + compilerOptions: { strict: true, module: 'esnext', moduleResolution: 'bundler', types: [] }, + include: ['src'], + }, + }) + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(project.exists('src/index.js')).toBe(false) + + project.write({ + 'src/utils.ts': + 'export function double(value: number): number {\n return String(value);\n}\n', + }) + + const failed = await project.uncheck(['--only=tsc']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(failed.stdout).toContain( + "src/utils.ts(2,3): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) +}) + +describe('tsc with the real compiler in a monorepo', () => { + it('builds the solution tsconfig.json with -b and reports errors in the package having them', async () => { + const project = monorepo() + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(project.exists('packages/core/dist/index.d.ts')).toBe(true) + + project.write({ + 'packages/core/src/index.ts': + 'export function double(value: number): number {\n return String(value);\n}\n', + }) + + const failed = await project.uncheck(['--only=tsc', 'packages/core/src/index.ts']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BY_ONLY, + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(failed.stdout).toContain( + "packages/core/src/index.ts(2,3): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc.utils.ts b/packages/uncheck/tests/uncheck/tsc.utils.ts new file mode 100644 index 0000000..c4e55a5 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc.utils.ts @@ -0,0 +1,42 @@ +import { join } from 'node:path' + +import type { Files, Layout, Project, ProjectOptions } from '../utils/project' +import { report } from '../utils/project' + +export const NOT_COVERED = '○ tsc skipped, no tsconfig.json covers the given files' + +export const SKIPPED_BY_ONLY = [ + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', +] + +// oxlint-disable-next-line no-template-curly-in-string +export const CONFIG_DIR = '${configDir}' + +export const ALLOW_JS = { compilerOptions: { allowJs: true } } + +export function withFakeTsc( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, +): Project { + return create(files, { ...options, tools: [] }).fake('typescript', '') +} + +export async function tscPlan( + project: Project, + cwd: string, + paths: ReadonlyArray = [], +): Promise { + const missing = paths.map((file) => join(cwd, file)).filter((file) => !project.exists(file)) + + project.write(Object.fromEntries(missing.map((file) => [file, '']))) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', ...paths], { cwd }) + const plan = report(stdout).filter((line) => /^[▶○✘] tsc /.test(line)) + + expect(exitCode).toBe(plan.length > 0 && plan.every((line) => line.startsWith('▶')) ? 0 : 1) + + return plan +} diff --git a/packages/uncheck/tests/uncheck/utils.ts b/packages/uncheck/tests/uncheck/utils.ts new file mode 100644 index 0000000..a4aa882 --- /dev/null +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -0,0 +1,31 @@ +import type { Project } from '../utils/project' + +export interface LayoutChecks { + readonly sherif: ReadonlyArray + readonly tsc: string + readonly checks: ReadonlyArray +} + +export function layoutChecks(app: string, { fix = false } = {}): LayoutChecks { + return app === '' + ? { + sherif: ['○ sherif skipped, not a workspace root'], + tsc: '▶ tsc -p tsconfig.json --noEmit', + checks: ['oxlint', 'oxfmt', 'tsc'], + } + : { + sherif: [fix ? '▶ sherif --fix --select=highest' : '▶ sherif', '✔ sherif passed'], + tsc: '▶ tsc -b tsconfig.json', + checks: ['sherif', 'oxlint', 'oxfmt', 'tsc'], + } +} + +export function updateJson( + project: Project, + file: string, + update: (value: Record) => object, +): Project { + return project.write({ + [file]: update(JSON.parse(project.read(file)) as Record), + }) +} From e188a7427ef37941883095cd13c26c8ff05f8d0a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Wed, 30 Sep 2026 21:31:25 +0700 Subject: [PATCH 4/9] wip: harden the e2e harness and share helpers --- packages/uncheck/tests/utils/project.ts | 133 ++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 7 deletions(-) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index b31d8d1..42eb77f 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -4,9 +4,11 @@ import { existsSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, realpathSync, rmSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs' @@ -55,9 +57,37 @@ const PACKAGE = fileURLToPath(new URL('../..', import.meta.url)) const REGISTER = fileURLToPath(new URL('register.ts', import.meta.url)) export const CLI = [process.execPath, '--import', REGISTER, join(PACKAGE, 'src/bin.ts')] as const +/** What the CLI looks for in every folder above a project, so one above the tests leaks into them all. */ +const PROJECT_MARKERS = [ + 'package.json', + 'node_modules', + 'tsconfig.json', + '.pnp.cjs', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'package-lock.json', +] + +for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { + const marker = PROJECT_MARKERS.find((name) => existsSync(join(dir, name))) + + if (marker !== undefined) { + throw new Error( + `${join(dir, marker)} would leak into every test project, point TMPDIR elsewhere`, + ) + } + + if (dir === dirname(dir)) { + break + } +} + const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'uncheck-e2e-'))) const GIT_CONFIG = join(ROOT, 'gitconfig') const SHIMS = join(ROOT, 'bin') +const REAL_GIT = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() writeFileSync(GIT_CONFIG, '') mkdirSync(SHIMS) @@ -76,10 +106,25 @@ writeFileSync( { mode: 0o755 }, ) +// A folder a test left unreadable or read-only would stop the removal of the temporary files. +function restorePermissions(dir: string): void { + chmodSync(dir, 0o755) + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + restorePermissions(join(dir, entry.name)) + } + } +} + afterAll(() => { + restorePermissions(ROOT) rmSync(ROOT, { recursive: true, force: true }) }) +// Root reads and writes files whatever their mode, so chmod cannot make anything fail for it. +export const PERMISSIONS_ENFORCED = process.getuid?.() !== 0 + // A stray path would run git or the CLI in the repository these tests live in. function inside(path: string): string { if (!path.startsWith(`${ROOT}${sep}`)) { @@ -96,12 +141,17 @@ export function temporaryDirectory(): string { /** The environment of every process the tests start, free of whatever the machine or a git hook set. */ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { const inherited = Object.entries(process.env).filter( - ([key]) => !/^(?:GIT_\w+|CI|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS)$/.test(key), + ([key]) => !/^(?:GIT_\w+|CI|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS|NODE_OPTIONS)$/.test(key), ) return { ...Object.fromEntries(inherited), PATH: `${SHIMS}${delimiter}${process.env.PATH}`, + // git translates the messages tests assert to the language of the machine. + LC_ALL: 'C', + // Node paints nothing in a terminal of unknown type. + TERM: 'xterm-256color', + GIT_CEILING_DIRECTORIES: ROOT, GIT_CONFIG_GLOBAL: GIT_CONFIG, GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'uncheck', @@ -109,11 +159,31 @@ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { GIT_COMMITTER_NAME: 'uncheck', GIT_COMMITTER_EMAIL: 'uncheck@example.com', // Functions deserialized from the compile cache report imprecise coverage. - NODE_DISABLE_COMPILE_CACHE: '1', + ...(process.env.NODE_V8_COVERAGE === undefined ? {} : { NODE_DISABLE_COMPILE_CACHE: '1' }), ...overrides, } } +/** An environment whose `git` first runs the shell `script`, which finds the real git in `$GIT`. */ +export function wrappedGit(script: string): Env { + const shims = temporaryDirectory() + + writeFileSync(join(shims, 'git'), `#!/bin/sh\nGIT='${REAL_GIT}'\n${script}\nexec "$GIT" "$@"\n`, { + mode: 0o755, + }) + + return { PATH: `${shims}${delimiter}${environment().PATH}` } +} + +/** A git config file holding `content`, for GIT_CONFIG_GLOBAL or GIT_CONFIG_SYSTEM. */ +export function gitConfig(content: string): string { + const file = join(temporaryDirectory(), 'gitconfig') + + writeFileSync(file, content) + + return file +} + export function git(cwd: string, args: ReadonlyArray, env?: Env): string { return execFileSync('git', args, { cwd: inside(cwd), @@ -186,14 +256,19 @@ export function runInTerminal( }) } -/** The lines uncheck itself prints, without the timings that differ between runs. */ +/** The lines uncheck itself prints, without colors and the timings that differ between runs. */ export function report(output: string): string[] { - return output + return stripVTControlCharacters(output) .split('\n') .filter((line) => /^(?:uncheck (?:staged )?in |[▶✔✘○] | {2}rerun )/.test(line)) .map((line) => line.replace(/ (?:\d+ms|\d+\.\ds)$/, '')) } +/** How the CLI prints the error that stops a run. */ +export function cliError(message: string): string { + return `\nERROR\n ${message}\n` +} + /** JSON the way oxfmt writes it, so the files a test adds pass the format check. */ function json(value: object, file: string): string { const text = JSON.stringify(value, null, 2) @@ -233,10 +308,30 @@ export class Project { return this } + /** Rewrites the JSON `file` with what `change` makes of it. */ + update(file: string, change: (value: Record) => object): this { + return this.write({ [file]: change(JSON.parse(this.read(file)) as Record) }) + } + read(file: string): string { return readFileSync(this.path(file), 'utf8') } + mode(file: string): number { + return statSync(this.path(file)).mode & 0o7777 + } + + chmod(file: string, mode: number): this { + chmodSync(this.path(file), mode) + + return this + } + + /** `text` with the folder of the project, which differs between runs, as ``. */ + normalize(text: string): string { + return text.replaceAll(this.dir, '') + } + exists(file: string): boolean { return existsSync(this.path(file)) } @@ -263,6 +358,14 @@ export class Project { return git(this.dir, args) } + /** Writes `files` and stages them as they are, even names that look like globs. */ + stage(files: Files): this { + this.write(files) + this.git('--literal-pathspecs', 'add', '--all', '--', ...Object.keys(files)) + + return this + } + commit(message = 'change'): this { this.git('add', '--all') this.git('commit', '--quiet', '--no-verify', `--message=${message}`) @@ -315,7 +418,7 @@ export function project(files: Files = {}, { tools = TOOLS, git = 'commit' }: Pr const created = new Project(temporaryDirectory()) install(created, tools) - created.write({ '.gitignore': 'node_modules\ndist\n', ...files }) + created.write({ '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n', ...files }) if (git !== 'none') { created.git('init', '--quiet', '--initial-branch=main') @@ -438,14 +541,30 @@ export function monorepo(files: Files = {}, options?: ProjectOptions): Project { return created } +/** A checkout of `project` made with `git worktree add`, with the links an install would make. */ +export function linkedWorktree(project: Project, app: string): Project { + const worktree = new Project(join(temporaryDirectory(), 'linked')) + + project.git('worktree', 'add', '--quiet', worktree.dir) + worktree.link('node_modules', project.path('node_modules')) + + if (app !== '') { + worktree.link(`${app}node_modules/@repo/core`, '../../../core') + } + + return worktree +} + export interface Layout { readonly name: string readonly create: (files?: Files, options?: ProjectOptions) => Project /** Where the code of the package the tests work on lives: `''` or a folder ending in `/`. */ readonly app: string + /** The line of the tsc run that checks the whole project. */ + readonly tsc: string } export const LAYOUTS: ReadonlyArray = [ - { name: 'single repo', create: singleRepo, app: '' }, - { name: 'monorepo', create: monorepo, app: 'packages/app/' }, + { name: 'single repo', create: singleRepo, app: '', tsc: '▶ tsc -p tsconfig.json --noEmit' }, + { name: 'monorepo', create: monorepo, app: 'packages/app/', tsc: '▶ tsc -b tsconfig.json' }, ] From fbd0cd53388841b47f8bbdebba3c763a3f033ce2 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 06:58:47 +0700 Subject: [PATCH 5/9] wip: consolidate e2e suites onto the shared harness --- .../tests/hooks/install/agents.test.ts | 24 ++- .../tests/hooks/install/existing.test.ts | 167 +++++++++------- .../tests/hooks/install/location.test.ts | 57 ++++-- .../hooks/install/package-manager.test.ts | 1 - .../tests/hooks/install/prompt.test.ts | 24 ++- packages/uncheck/tests/hooks/install/utils.ts | 27 --- .../uncheck/tests/hooks/run/changes.test.ts | 36 ++-- .../uncheck/tests/hooks/run/families.test.ts | 144 ++++++++------ .../uncheck/tests/hooks/run/installed.test.ts | 105 ---------- .../uncheck/tests/hooks/run/location.test.ts | 64 +++--- .../uncheck/tests/hooks/run/payload.test.ts | 22 ++- .../uncheck/tests/hooks/run/selection.test.ts | 19 +- packages/uncheck/tests/hooks/run/utils.ts | 18 +- packages/uncheck/tests/prepare/commit.test.ts | 43 ++-- .../tests/prepare/existing-hook.test.ts | 4 +- .../uncheck/tests/prepare/failures.test.ts | 25 +-- packages/uncheck/tests/prepare/hook.test.ts | 58 +++--- .../tests/prepare/linked-checkouts.test.ts | 36 ++-- .../uncheck/tests/prepare/location.test.ts | 47 ++--- .../tests/prepare/package-manager.test.ts | 23 ++- packages/uncheck/tests/prepare/utils.ts | 61 +----- packages/uncheck/tests/staged/basics.test.ts | 29 +-- packages/uncheck/tests/staged/commit.test.ts | 39 ++-- packages/uncheck/tests/staged/fix.test.ts | 85 ++++---- .../uncheck/tests/staged/interrupt.test.ts | 4 +- .../uncheck/tests/staged/line-endings.test.ts | 16 +- .../uncheck/tests/staged/many-files.test.ts | 4 +- packages/uncheck/tests/staged/merge.test.ts | 26 +-- .../uncheck/tests/staged/precedence.test.ts | 41 ++-- .../tests/staged/sparse-checkout.test.ts | 12 +- .../uncheck/tests/staged/unstaged.test.ts | 183 +++++++++--------- packages/uncheck/tests/staged/utils.ts | 75 +------ packages/uncheck/tests/uncheck/cli.test.ts | 19 +- packages/uncheck/tests/uncheck/fix.test.ts | 64 +++--- packages/uncheck/tests/uncheck/output.test.ts | 72 ++----- .../tests/uncheck/paths-exclusions.test.ts | 2 +- .../uncheck/tests/uncheck/paths-git.test.ts | 8 +- .../uncheck/tests/uncheck/paths-globs.test.ts | 2 +- .../{files.test.ts => paths-tools.test.ts} | 169 +++++++++------- .../tests/uncheck/paths-unmatched.test.ts | 19 +- packages/uncheck/tests/uncheck/paths-utils.ts | 36 ---- .../uncheck/tests/uncheck/paths-walk.test.ts | 19 +- ...{paths-selection.test.ts => paths.test.ts} | 123 +----------- .../uncheck/tests/uncheck/presets.test.ts | 4 +- .../uncheck/tests/uncheck/project.test.ts | 37 ++-- .../uncheck/tests/uncheck/selection.test.ts | 45 +++-- packages/uncheck/tests/uncheck/sherif.test.ts | 11 +- .../{install.test.ts => tools.test.ts} | 6 +- .../uncheck/tests/uncheck/tsc-extends.test.ts | 2 +- .../uncheck/tests/uncheck/tsc-inputs.test.ts | 22 +-- .../tests/uncheck/tsc-patterns.test.ts | 2 +- .../tests/uncheck/tsc-references.test.ts | 12 +- packages/uncheck/tests/uncheck/tsc.test.ts | 62 ++---- packages/uncheck/tests/uncheck/tsc.utils.ts | 42 ---- packages/uncheck/tests/uncheck/utils.ts | 108 +++++++++-- 55 files changed, 1024 insertions(+), 1381 deletions(-) delete mode 100644 packages/uncheck/tests/hooks/run/installed.test.ts rename packages/uncheck/tests/uncheck/{files.test.ts => paths-tools.test.ts} (60%) delete mode 100644 packages/uncheck/tests/uncheck/paths-utils.ts rename packages/uncheck/tests/uncheck/{paths-selection.test.ts => paths.test.ts} (53%) rename packages/uncheck/tests/uncheck/{install.test.ts => tools.test.ts} (97%) delete mode 100644 packages/uncheck/tests/uncheck/tsc.utils.ts diff --git a/packages/uncheck/tests/hooks/install/agents.test.ts b/packages/uncheck/tests/hooks/install/agents.test.ts index b678d37..ec83836 100644 --- a/packages/uncheck/tests/hooks/install/agents.test.ts +++ b/packages/uncheck/tests/hooks/install/agents.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, singleRepo } from '../../utils/project' +import { LAYOUTS, cliError, singleRepo } from '../../utils/project' import { asWritten, claudeSettings, @@ -6,7 +6,6 @@ import { cursorHooks, hookCommand, installOutput, - readConfig, } from './utils' describe.each(LAYOUTS)('hooks install writes the agent configs in a $name', ({ create, app }) => { @@ -68,14 +67,15 @@ describe.each(LAYOUTS)('hooks install writes the agent configs in a $name', ({ c cwd: app, }) + expect(first.exitCode).toBe(0) expect(first.stdout).toBe( installOutput( ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], fast, ), ) - expect(readConfig(project, `${app}.claude/settings.json`)).toEqual(claudeSettings(fast)) - expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual(cursorHooks(fast)) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(fast))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(fast))) const command = hookCommand(app) const second = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) @@ -127,18 +127,26 @@ describe('hooks install arguments', () => { expect(exitCode).toBe(1) expect(stdout).toBe('') - expect(stderr).toBe(`\nERROR\n ${message}\n`) + expect(stderr).toBe(cliError(message)) expect(project.exists('.claude')).toBe(false) }) - it('refuses an agent it does not know', async () => { + it('refuses an agent it does not know, showing the usage', async () => { const project = singleRepo() - const { exitCode, stderr } = await project.uncheck(['hooks', 'install', 'claude', 'emacs']) + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + 'emacs', + ]) expect(exitCode).toBe(1) + expect(stdout).toContain('USAGE\n uncheck hooks install [flags] []\n') expect(stderr).toBe( - '\nERROR\n Invalid value for argument : "emacs". Expected: "claude" | "codebuddy" | "cursor" | "copilot"\n', + cliError( + 'Invalid value for argument : "emacs". Expected: "claude" | "codebuddy" | "cursor" | "copilot"', + ), ) expect(project.exists('.claude')).toBe(false) }) diff --git a/packages/uncheck/tests/hooks/install/existing.test.ts b/packages/uncheck/tests/hooks/install/existing.test.ts index c880ab3..7152635 100644 --- a/packages/uncheck/tests/hooks/install/existing.test.ts +++ b/packages/uncheck/tests/hooks/install/existing.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, singleRepo } from '../../utils/project' +import { LAYOUTS, cliError, singleRepo } from '../../utils/project' import { TIMEOUT, asWritten, @@ -6,7 +6,6 @@ import { cursorHooks, hookCommand, installOutput, - readConfig, } from './utils' const MENTIONS = [ @@ -45,23 +44,27 @@ describe.each(LAYOUTS)( command, ), ) - expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ - permissions: { allow: ['Bash(pnpm test)'] }, - hooks: { - PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], - Stop: [ - { hooks: [{ type: 'command', command: 'notify-send done' }] }, - { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }, - ], - }, - }) - expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ - version: 1, - hooks: { - afterFileEdit: [{ command: 'echo edited' }], - stop: [{ command, timeout: TIMEOUT }], - }, - }) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + permissions: { allow: ['Bash(pnpm test)'] }, + hooks: { + PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }, + ], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + hooks: { + afterFileEdit: [{ command: 'echo edited' }], + stop: [{ command, timeout: TIMEOUT }], + }, + version: 1, + }), + ) }) it('replaces its own stop entry where it is, keeping what the user set on it', async () => { @@ -107,26 +110,31 @@ describe.each(LAYOUTS)( command, ), ) - expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ - hooks: { - PostToolUse: postToolUse, - Stop: [ - { hooks: [{ type: 'command', command: 'notify-send done' }] }, - { - matcher: '', - hooks: [{ type: 'command', command, statusMessage: 'Checking', timeout: 1800 }], - }, - { hooks: [{ type: 'command', command: 'echo last' }] }, - ], - }, - }) - expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ - version: 2, - hooks: { stop: [{ command, timeout: TIMEOUT }, { command: 'echo last' }] }, - }) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + hooks: { + PostToolUse: postToolUse, + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { + matcher: '', + hooks: [{ type: 'command', command, statusMessage: 'Checking', timeout: 1800 }], + }, + { hooks: [{ type: 'command', command: 'echo last' }] }, + ], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + version: 2, + hooks: { stop: [{ command, timeout: TIMEOUT }, { command: 'echo last' }] }, + }), + ) const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + expect(again.exitCode).toBe(0) expect(again.stdout).toBe( installOutput( ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], @@ -166,18 +174,23 @@ describe.each(LAYOUTS)( command, ), ) - expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ - hooks: { - Stop: [...claudeStop, { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }], - }, - }) - expect(readConfig(project, `${app}.cursor/hooks.json`)).toEqual({ - version: 1, - hooks: { stop: [...cursorStop, { command, timeout: TIMEOUT }] }, - }) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + hooks: { + Stop: [...claudeStop, { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + hooks: { stop: [...cursorStop, { command, timeout: TIMEOUT }] }, + version: 1, + }), + ) const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + expect(again.exitCode).toBe(0) expect(again.stdout).toBe( installOutput( ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], @@ -232,10 +245,9 @@ describe.each(LAYOUTS)( command, ), ) - expect(readConfig(project, `${app}.claude/settings.json`)).toEqual({ - model: 'opus', - ...claudeSettings(command), - }) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ model: 'opus', ...claudeSettings(command) }), + ) expect(project.read(`${app}.codebuddy/settings.json`)).toBe( asWritten(claudeSettings(command)), ) @@ -255,19 +267,20 @@ describe.each(LAYOUTS)( expect(cursor.exitCode).toBe(1) expect(cursor.stdout).toBe('') expect(cursor.stderr).toBe( - '\nERROR\n .cursor/hooks.json has InvalidSymbol on line 3, fix it and run again\n', + cliError('.cursor/hooks.json has InvalidSymbol on line 3, fix it and run again'), ) for (const [agent, file] of [ ['claude', '.claude/settings.json'], ['codebuddy', '.codebuddy/settings.json'], ] as const) { - const { exitCode, stderr } = await project.uncheck(['hooks', 'install', agent], { + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', agent], { cwd: app, }) expect(exitCode).toBe(1) - expect(stderr).toBe(`\nERROR\n ${file} is not a JSON object, fix it and run again\n`) + expect(stdout).toBe('') + expect(stderr).toBe(cliError(`${file} is not a JSON object, fix it and run again`)) } expect(project.read(`${app}.cursor/hooks.json`)).toBe(broken) @@ -278,13 +291,15 @@ describe.each(LAYOUTS)( it('writes no config when another one it was asked for is broken', async () => { const project = create({ [`${app}.cursor/hooks.json`]: '{ "version": 1' }) - const { exitCode, stderr } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { - cwd: app, - }) + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'claude', 'cursor'], + { cwd: app }, + ) expect(exitCode).toBe(1) + expect(stdout).toBe('') expect(stderr).toBe( - '\nERROR\n .cursor/hooks.json has CloseBraceExpected on line 1, fix it and run again\n', + cliError('.cursor/hooks.json has CloseBraceExpected on line 1, fix it and run again'), ) expect(project.exists(`${app}.claude`)).toBe(false) }) @@ -312,15 +327,17 @@ describe('hooks install merges into an existing GitHub Copilot config', () => { expect(stdout).toBe( installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), ) - expect(readConfig(project, '.github/hooks/uncheck.json')).toEqual({ - version: 1, - hooks: { - agentStop: [ - { type: 'command', bash: command, powershell: command, timeout: 1800 }, - { type: 'command', bash: command, powershell: command, timeoutSec: 1200 }, - ], - }, - }) + expect(project.read('.github/hooks/uncheck.json')).toBe( + asWritten({ + version: 1, + hooks: { + agentStop: [ + { type: 'command', bash: command, timeout: 1800, powershell: command }, + { type: 'command', powershell: command, timeoutSec: 1200, bash: command }, + ], + }, + }), + ) }) it('leaves Copilot hooks that only mention it alone, adding its own next to them', async () => { @@ -338,14 +355,16 @@ describe('hooks install merges into an existing GitHub Copilot config', () => { expect(stdout).toBe( installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), ) - expect(readConfig(project, '.github/hooks/uncheck.json')).toEqual({ - version: 1, - hooks: { - agentStop: [ - ...agentStop, - { type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }, - ], - }, - }) + expect(project.read('.github/hooks/uncheck.json')).toBe( + asWritten({ + hooks: { + agentStop: [ + ...agentStop, + { type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }, + ], + }, + version: 1, + }), + ) }) }) diff --git a/packages/uncheck/tests/hooks/install/location.test.ts b/packages/uncheck/tests/hooks/install/location.test.ts index 6297702..d5eb50b 100644 --- a/packages/uncheck/tests/hooks/install/location.test.ts +++ b/packages/uncheck/tests/hooks/install/location.test.ts @@ -1,19 +1,19 @@ -import { CLI, LAYOUTS, monorepo, run, temporaryDirectory } from '../../utils/project' import { + CLI, + LAYOUTS, PERMISSIONS_ENFORCED, - asWritten, - claudeSettings, - copilotHooks, - hookCommand, - installOutput, - withMode, -} from './utils' + cliError, + monorepo, + run, + temporaryDirectory, +} from '../../utils/project' +import { asWritten, claudeSettings, copilotHooks, hookCommand, installOutput } from './utils' describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, app }) => { it('names a folder below the top of the repository in the hook command', async () => { const folder = `${app}tools/@scope_v=1.2+x-y` const project = create({ [`${folder}/index.ts`]: 'export const tool = 1;\n' }) - const command = `pnpm exec uncheck hooks run --fix --dir=${folder}` + const command = hookCommand(`${folder}/`) const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude'], { cwd: folder, @@ -37,7 +37,9 @@ describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - `\nERROR\n The hook command cannot name ${folder}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-\n`, + cliError( + `The hook command cannot name ${folder}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`, + ), ) expect(project.exists(`${folder}/.claude`)).toBe(false) }, @@ -89,24 +91,30 @@ describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - `\nERROR\n BadResource: FileSystem.readFile (${project.path(`${app}.claude/settings.json`)})\n`, + cliError(`BadResource: FileSystem.readFile (${project.path(`${app}.claude/settings.json`)})`), ) }) it.runIf(PERMISSIONS_ENFORCED)( 'reports a config it cannot write, after writing the ones before it', async () => { - const project = create({ [`${app}.cursor/rules.md`]: '# Rules\n' }) + const project = create({ [`${app}.cursor/rules.md`]: '# Rules\n' }).chmod( + `${app}.cursor`, + 0o555, + ) const command = hookCommand(app) - const { exitCode, stdout, stderr } = await withMode(project, `${app}.cursor`, 0o555, () => - project.uncheck(['hooks', 'install', 'cursor', 'claude'], { cwd: app }), + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'cursor', 'claude'], + { cwd: app }, ) expect(exitCode).toBe(1) expect(stdout).toBe('✔ Claude Code .claude/settings.json created\n') expect(stderr).toBe( - `\nERROR\n PermissionDenied: FileSystem.writeFile (${project.path(`${app}.cursor/hooks.json`)})\n`, + cliError( + `PermissionDenied: FileSystem.writeFile (${project.path(`${app}.cursor/hooks.json`)})`, + ), ) expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) expect(project.exists(`${app}.cursor/hooks.json`)).toBe(false) @@ -114,16 +122,21 @@ describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, ) it.runIf(PERMISSIONS_ENFORCED)('reports a folder it cannot look into', async () => { - const project = create({ [`${app}locked/notes.md`]: '# Notes\n' }) + const project = create({ [`${app}locked/notes.md`]: '# Notes\n' }).chmod(`${app}locked`, 0o644) - const { exitCode, stdout, stderr } = await withMode(project, `${app}locked`, 0o644, () => - project.uncheck(['hooks', 'install', 'claude', `--cwd=${project.path(`${app}locked`)}`]), - ) + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + `--cwd=${project.path(`${app}locked`)}`, + ]) expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - `\nERROR\n PermissionDenied: FileSystem.readFile (${project.path(`${app}locked/.claude/settings.json`)})\n`, + cliError( + `PermissionDenied: FileSystem.readFile (${project.path(`${app}locked/.claude/settings.json`)})`, + ), ) }) }) @@ -140,7 +153,9 @@ describe('hooks install in a monorepo package', () => { expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - '\nERROR\n Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there\n', + cliError( + 'Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there', + ), ) expect(project.exists('packages/app/.claude')).toBe(false) expect(project.exists('packages/app/.github')).toBe(false) diff --git a/packages/uncheck/tests/hooks/install/package-manager.test.ts b/packages/uncheck/tests/hooks/install/package-manager.test.ts index 0861bb9..ecbcbc2 100644 --- a/packages/uncheck/tests/hooks/install/package-manager.test.ts +++ b/packages/uncheck/tests/hooks/install/package-manager.test.ts @@ -7,7 +7,6 @@ function manifest(packageManager: string) { describe('hooks install runs uncheck through the package manager of the project', () => { it.each([ - ['a pnpm lockfile', {}, 'pnpm exec'], ['a Yarn lockfile', { 'pnpm-lock.yaml': null, 'yarn.lock': '' }, 'yarn run --silent'], ['a Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lock': '{}\n' }, 'bunx --no-install'], ['a binary Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lockb': '' }, 'bunx --no-install'], diff --git a/packages/uncheck/tests/hooks/install/prompt.test.ts b/packages/uncheck/tests/hooks/install/prompt.test.ts index 28c096f..dd3fb92 100644 --- a/packages/uncheck/tests/hooks/install/prompt.test.ts +++ b/packages/uncheck/tests/hooks/install/prompt.test.ts @@ -1,11 +1,16 @@ -import { LAYOUTS, monorepo } from '../../utils/project' +import { LAYOUTS, cliError, monorepo } from '../../utils/project' import { asWritten, claudeSettings, cursorHooks, hookCommand, installOutput } from './utils' const QUESTION = 'Which agents should run uncheck when they finish a turn?' +const CHOICES = `? ${QUESTION} › \n Select All\n Inverse Selection\n ☐ Claude Code \n ☐ CodeBuddy \n ☐ Cursor \n ☐ GitHub Copilot ` const DOWN = '\u001B[B' const SPACE = ' ' const ENTER = '\r' +function fromAnswer(output: string): string { + return output.slice(output.lastIndexOf(`✔ ${QUESTION}`)) +} + describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app }) => { it('writes the agents chosen in a terminal', async () => { const project = create() @@ -18,11 +23,12 @@ describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app }) expect(exitCode).toBe(0) - expect(stdout.split(' Claude Code, Cursor\n').at(-1)).toBe( - installOutput( + expect(stdout.slice(0, CHOICES.length)).toBe(CHOICES) + expect(fromAnswer(stdout)).toBe( + `✔ ${QUESTION} … Claude Code, Cursor\n${installOutput( ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], command, - ), + )}`, ) expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) @@ -37,7 +43,9 @@ describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - '\nERROR\n Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot\n', + cliError( + 'Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot', + ), ) expect(project.exists(`${app}.claude`)).toBe(false) }) @@ -54,8 +62,10 @@ describe('hooks install without agents in a monorepo package', () => { }) expect(exitCode).toBe(1) - expect(stdout).toContain( - '\nERROR\n Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there\n', + expect(fromAnswer(stdout)).toBe( + `✔ ${QUESTION} … Claude Code, CodeBuddy, Cursor, GitHub Copilot\n${cliError( + 'Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there', + )}`, ) expect(project.exists('packages/app/.claude')).toBe(false) expect(project.exists('packages/app/.github')).toBe(false) diff --git a/packages/uncheck/tests/hooks/install/utils.ts b/packages/uncheck/tests/hooks/install/utils.ts index 3cf40db..d192641 100644 --- a/packages/uncheck/tests/hooks/install/utils.ts +++ b/packages/uncheck/tests/hooks/install/utils.ts @@ -1,28 +1,5 @@ -import { chmodSync } from 'node:fs' -import process from 'node:process' - -import type { Project } from '../../utils/project' - export const TIMEOUT = 600 -export const PERMISSIONS_ENFORCED = process.getuid?.() !== 0 - -// The harness cannot remove the project once a folder in it is left unreadable. -export async function withMode( - project: Project, - folder: string, - mode: number, - action: () => Promise, -): Promise { - chmodSync(project.path(folder), mode) - - try { - return await action() - } finally { - chmodSync(project.path(folder), 0o755) - } -} - export function hookCommand(app: string, flags: ReadonlyArray = []): string { const dir = app === '' ? [] : [`--dir=${app.slice(0, -1)}`] @@ -53,7 +30,3 @@ export function copilotHooks(command: string) { export function asWritten(config: object): string { return `${JSON.stringify(config, null, 2)}\n` } - -export function readConfig(project: Project, file: string): unknown { - return JSON.parse(project.read(file)) -} diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index c57e853..14199b6 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -1,13 +1,13 @@ import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS } from '../../utils/project' -import { CLAUDE_CODE_STOP, GITIGNORE, lines, location, stopHook, tscCommand } from './utils' +import { LAYOUTS, report } from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook } from './utils' const UNFORMATTED = 'export const legacy = 1\n' describe.each(LAYOUTS)( 'hooks run checks what changed since the last commit in a $name', - ({ create, app }) => { + ({ create, app, tsc }) => { it('stays silent when nothing changed', async () => { const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }) @@ -20,10 +20,7 @@ describe.each(LAYOUTS)( }) it('fixes the changed and untracked files, leaving ignored and unchanged ones alone', async () => { - const project = create({ - ...GITIGNORE, - [`${app}src/legacy.ts`]: UNFORMATTED, - }) + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }) const index = project.read(`${app}src/index.ts`) project.write({ @@ -39,14 +36,14 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(0) expect(stdout).toBe('') expect(stripVTControlCharacters(stderr)).toBe(stderr) - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, no package.json among the given files', '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts src/index.ts', '✔ oxlint passed', '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', '✔ oxfmt passed', - tscCommand(app), + tsc, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)', ]) @@ -66,8 +63,8 @@ describe.each(LAYOUTS)( expect(deleted.exitCode).toBe(0) expect(deleted.stdout).toBe('') - expect(lines(project, deleted.stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(deleted.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ nothing to check, no files match src/[id].ts', ]) expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) @@ -79,8 +76,9 @@ describe.each(LAYOUTS)( }) expect(alongside.exitCode).toBe(0) - expect(lines(project, alongside.stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(alongside.stdout).toBe('') + expect(report(alongside.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', '▶ oxfmt --no-error-on-unmatched-pattern src/new.ts', @@ -101,14 +99,14 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(2) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not a workspace root', '▶ oxlint', '✔ oxlint passed', '▶ oxfmt --check', '✘ oxfmt failed', - tscCommand(app), + tsc, '✔ tsc passed', '✘ 1 of 3 checks failed: oxfmt', ' rerun with `--fix` to apply oxfmt fixes', @@ -125,8 +123,8 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(2) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', '▶ oxfmt --check', diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index 9d7835f..2c6e5e6 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -1,4 +1,5 @@ -import { LAYOUTS } from '../../utils/project' +import type { Project, Run } from '../../utils/project' +import { LAYOUTS, report, run } from '../../utils/project' import { CLAUDE_CODE_STOP, CLAUDE_CODE_STOP_AGAIN, @@ -6,104 +7,127 @@ import { COPILOT_STOP_IN_CLAUDE_FORMAT, CURSOR_STOP, TYPE_ERROR, - lines, - location, stopHook, - tscCommand, } from './utils' +interface ClaudeSettings { + readonly hooks: { + readonly Stop: ReadonlyArray<{ readonly hooks: [{ readonly command: string }] }> + } +} + +interface CursorHooks { + readonly hooks: { readonly stop: ReadonlyArray<{ readonly command: string }> } +} + +const DOCS = { 'docs/guide.md': '# Guide\n' } + const DIAGNOSTIC = "src/index.ts(1,14): error TS2322: Type 'number' is not assignable to type 'string'." +async function installHook( + project: Project, + app: string, + agent: 'claude' | 'cursor', +): Promise<(payload: object) => Promise> { + const install = await project.uncheck(['hooks', 'install', agent], { cwd: app }) + + expect(install.exitCode).toBe(0) + + const command = + agent === 'claude' + ? (JSON.parse(project.read(`${app}.claude/settings.json`)) as ClaudeSettings).hooks.Stop[0]! + .hooks[0].command + : (JSON.parse(project.read(`${app}.cursor/hooks.json`)) as CursorHooks).hooks.stop[0]!.command + + return (payload) => + run(['sh', '-c', command], { cwd: project.path('docs'), input: JSON.stringify(payload) }) +} + +function sendBackReason(stderr: string): string { + return `uncheck found problems, fix them before finishing:\n\n${stderr.replace(/\n$/, '')}` +} + describe.each(LAYOUTS)( 'hooks run sends each agent back its own way in a $name', - ({ create, app }) => { - const failing = () => - create({}, { tools: ['sherif', 'oxlint', 'oxfmt'] }) - .fake('typescript', `console.log(${JSON.stringify(DIAGNOSTIC)})\nprocess.exitCode = 2\n`) - .write({ [`${app}src/index.ts`]: TYPE_ERROR }) - const failure = [ - `uncheck in ${location(app)}`, + ({ create, app, tsc }) => { + const checks = (project: Project, ...outcome: ReadonlyArray) => [ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, no package.json among the given files', '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', '✔ oxlint passed', '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', '✔ oxfmt passed', - tscCommand(app), - '✘ tsc failed', - '✘ 1 of 3 checks failed: tsc', + tsc, + ...outcome, ] - const reason = (stderr: string) => - `uncheck found problems, fix them before finishing:\n\n${stderr.replace(/\n$/, '')}` + const failure = (project: Project) => + checks(project, '✘ tsc failed', '✘ 1 of 3 checks failed: tsc') - it('blocks Claude Code and CodeBuddy with exit code 2 and the report on stderr', async () => { - const project = failing() + it('blocks Claude Code with exit code 2 until its change passes, through its hook run from outside the change', async () => { + const project = create(DOCS) + const stop = await installHook(project, app, 'claude') - const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP) + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual(failure) - expect(stderr).toContain(`\n${DIAGNOSTIC}\n`) - }) + const blocked = await stop(CLAUDE_CODE_STOP) - it('lets Claude Code stop once it already went back, telling the user what still fails', async () => { - const project = failing() + expect(blocked.exitCode).toBe(2) + expect(blocked.stdout).toBe('') + expect(report(blocked.stderr)).toEqual(failure(project)) + expect(blocked.stderr).toContain(`\n${DIAGNOSTIC}\n`) - const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + const again = await stop(CLAUDE_CODE_STOP_AGAIN) - expect(exitCode).toBe(0) - expect(JSON.parse(stdout)).toEqual({ + expect(again.exitCode).toBe(0) + expect(JSON.parse(again.stdout)).toEqual({ systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', }) - expect(lines(project, stderr)).toEqual(failure) - }) + expect(report(again.stderr)).toEqual(failure(project)) - it('sends Cursor a follow-up message with the report', async () => { - const project = failing() + project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) - const { exitCode, stdout, stderr } = await stopHook(project, app, CURSOR_STOP) + const passed = await stop(CLAUDE_CODE_STOP) - expect(exitCode).toBe(0) - expect(lines(project, stderr)).toEqual(failure) - expect(JSON.parse(stdout)).toEqual({ followup_message: reason(stderr) }) + expect(passed.exitCode).toBe(0) + expect(passed.stdout).toBe('') + expect(report(passed.stderr)).toEqual( + checks(project, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)'), + ) + expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') }) - it('lets Cursor stop once it already followed up', async () => { - const project = failing() + it('sends Cursor back once with a follow-up message, through its hook run from outside the change', async () => { + const project = create(DOCS) + const stop = await installHook(project, app, 'cursor') + + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const followUp = await stop(CURSOR_STOP) - const { exitCode, stdout, stderr } = await stopHook(project, app, { - ...CURSOR_STOP, - loop_count: 1, + expect(followUp.exitCode).toBe(0) + expect(report(followUp.stderr)).toEqual(failure(project)) + expect(JSON.parse(followUp.stdout)).toEqual({ + followup_message: sendBackReason(followUp.stderr), }) - expect(exitCode).toBe(0) - expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual(failure) + const again = await stop({ ...CURSOR_STOP, loop_count: 1 }) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe('') + expect(report(again.stderr)).toEqual(failure(project)) }) it('blocks Copilot with a decision, whichever payload format it sends', async () => { - const project = failing() + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) for (const payload of [COPILOT_AGENT_STOP, COPILOT_STOP_IN_CLAUDE_FORMAT]) { const { exitCode, stdout, stderr } = await stopHook(project, app, payload) expect(exitCode).toBe(0) - expect(lines(project, stderr)).toEqual(failure) - expect(JSON.parse(stdout)).toEqual({ decision: 'block', reason: reason(stderr) }) + expect(report(stderr)).toEqual(failure(project)) + expect(JSON.parse(stdout)).toEqual({ decision: 'block', reason: sendBackReason(stderr) }) } }) - - it('only reports to an agent it does not know', async () => { - const project = failing() - - const { exitCode, stdout, stderr } = await stopHook(project, app, { - hook_event_name: 'SubagentStop', - }) - - expect(exitCode).toBe(0) - expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual(failure) - }) }, ) diff --git a/packages/uncheck/tests/hooks/run/installed.test.ts b/packages/uncheck/tests/hooks/run/installed.test.ts deleted file mode 100644 index 6948509..0000000 --- a/packages/uncheck/tests/hooks/run/installed.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { Project, Run } from '../../utils/project' -import { LAYOUTS, run } from '../../utils/project' -import { - CLAUDE_CODE_STOP, - CLAUDE_CODE_STOP_AGAIN, - CURSOR_STOP, - GITIGNORE, - TYPE_ERROR, - lines, - location, - tscCommand, -} from './utils' - -interface ClaudeSettings { - readonly hooks: { - readonly Stop: ReadonlyArray<{ readonly hooks: [{ readonly command: string }] }> - } -} - -interface CursorHooks { - readonly hooks: { readonly stop: ReadonlyArray<{ readonly command: string }> } -} - -async function installHook( - project: Project, - app: string, - agent: 'claude' | 'cursor', -): Promise<(payload: object) => Promise> { - const install = await project.uncheck(['hooks', 'install', agent], { cwd: app }) - - expect(install.exitCode).toBe(0) - - const command = - agent === 'claude' - ? (JSON.parse(project.read(`${app}.claude/settings.json`)) as ClaudeSettings).hooks.Stop[0]! - .hooks[0].command - : (JSON.parse(project.read(`${app}.cursor/hooks.json`)) as CursorHooks).hooks.stop[0]!.command - - return (payload) => - run(['sh', '-c', command], { cwd: project.path(`${app}src`), input: JSON.stringify(payload) }) -} - -describe.each(LAYOUTS)('the installed stop hook in a $name', ({ create, app }) => { - const failure = [ - `uncheck in ${location(app)}`, - '○ sherif skipped, no package.json among the given files', - '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', - '✔ oxlint passed', - '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', - '✔ oxfmt passed', - tscCommand(app), - '✘ tsc failed', - '✘ 1 of 3 checks failed: tsc', - ] - - it('sends Claude Code back until its change passes, from wherever it moved to', async () => { - const project = create(GITIGNORE) - const stop = await installHook(project, app, 'claude') - - project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) - - const blocked = await stop(CLAUDE_CODE_STOP) - - expect(blocked.exitCode).toBe(2) - expect(blocked.stdout).toBe('') - expect(lines(project, blocked.stderr)).toEqual(failure) - - const again = await stop(CLAUDE_CODE_STOP_AGAIN) - - expect(again.exitCode).toBe(0) - expect(JSON.parse(again.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - - project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) - - const passed = await stop(CLAUDE_CODE_STOP) - - expect(passed.exitCode).toBe(0) - expect(passed.stdout).toBe('') - expect(lines(project, passed.stderr).at(-1)).toBe('✔ all checks passed (oxlint, oxfmt, tsc)') - expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') - }) - - it('sends Cursor back once with a follow-up message, from wherever it moved to', async () => { - const project = create(GITIGNORE) - const stop = await installHook(project, app, 'cursor') - - project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) - - const followUp = await stop(CURSOR_STOP) - - expect(followUp.exitCode).toBe(0) - expect(lines(project, followUp.stderr)).toEqual(failure) - expect(JSON.parse(followUp.stdout)).toEqual({ - followup_message: `uncheck found problems, fix them before finishing:\n\n${followUp.stderr.replace(/\n$/, '')}`, - }) - - const again = await stop({ ...CURSOR_STOP, loop_count: 1 }) - - expect(again.exitCode).toBe(0) - expect(again.stdout).toBe('') - expect(lines(project, again.stderr)).toEqual(failure) - }) -}) diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index e8958a0..9c01548 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -1,5 +1,14 @@ -import { CLI, LAYOUTS, monorepo, run, singleRepo, temporaryDirectory } from '../../utils/project' -import { CLAUDE_CODE_STOP, lines, location, stopHook } from './utils' +import { + CLI, + LAYOUTS, + cliError, + monorepo, + report, + run, + singleRepo, + temporaryDirectory, +} from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook } from './utils' const UNFORMATTED = 'export const extra = 1\n' const FORMATTED = 'export const extra = 1;\n' @@ -10,29 +19,6 @@ const ONLY_OXFMT = [ ] describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app }) => { - it('checks the package it was installed for from wherever the agent moved to', async () => { - const project = create({ 'docs/guide.md': '# Guide\n' }).write({ - [`${app}src/extra.ts`]: UNFORMATTED, - }) - - const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { - args: ['--fix', '--only=oxfmt'], - cwd: 'docs', - }) - - expect(exitCode).toBe(0) - expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, - ...ONLY_OXFMT, - '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', - '✔ oxfmt passed', - '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxfmt)', - ]) - expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) - }) - it('checks the directory given with --cwd, wherever it runs', async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) @@ -43,8 +29,8 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app expect(exitCode).toBe(0) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, ...ONLY_OXFMT, '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', @@ -64,15 +50,17 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app expect(exitCode).toBe(1) expect(stdout).toBe('') - expect(stderr.replaceAll(project.dir, '')).toBe( - `\nERROR\n --dir=${app}gone names nothing in , run \`uncheck hooks install\` again from the project\n`, + expect(stderr).toBe( + cliError( + `--dir=${app}gone names nothing in ${project.dir}, run \`uncheck hooks install\` again from the project`, + ), ) expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) }) }) describe('hooks run in a monorepo', () => { - it('checks only the package in --dir, and the whole repository without it', async () => { + it('checks only the package in --dir from wherever the agent moved to, and the whole repository without it', async () => { const project = monorepo().write({ 'packages/app/src/extra.ts': UNFORMATTED, 'packages/core/src/extra.ts': UNFORMATTED, @@ -84,8 +72,9 @@ describe('hooks run in a monorepo', () => { }) expect(app.exitCode).toBe(2) - expect(lines(project, app.stderr)).toEqual([ - 'uncheck in /packages/app', + expect(app.stdout).toBe('') + expect(report(app.stderr)).toEqual([ + `uncheck in ${project.path('packages/app')}`, ...ONLY_OXFMT, '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', '✘ oxfmt failed', @@ -100,8 +89,9 @@ describe('hooks run in a monorepo', () => { }) expect(top.exitCode).toBe(0) - expect(lines(project, top.stderr)).toEqual([ - 'uncheck in ', + expect(top.stdout).toBe('') + expect(report(top.stderr)).toEqual([ + `uncheck in ${project.dir}`, ...ONLY_OXFMT, '▶ oxfmt --no-error-on-unmatched-pattern packages/app/src/extra.ts packages/core/src/extra.ts', '✔ oxfmt passed', @@ -133,8 +123,8 @@ describe('hooks run in a monorepo', () => { expect(exitCode).toBe(0) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - 'uncheck in /packages/app', + expect(report(stderr)).toEqual([ + `uncheck in ${project.path('packages/app')}`, ...ONLY_OXFMT, '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', @@ -158,7 +148,7 @@ describe('hooks run arguments', () => { expect(exitCode).toBe(1) expect(stdout).toContain('USAGE\n uncheck hooks run [flags]\n') expect(stderr).toBe( - `\nERROR\n Invalid value for flag --cwd: "${gone}". Expected: Path does not exist: ${gone}\n`, + cliError(`Invalid value for flag --cwd: "${gone}". Expected: Path does not exist: ${gone}`), ) expect(project.read('src/extra.ts')).toBe(UNFORMATTED) }) diff --git a/packages/uncheck/tests/hooks/run/payload.test.ts b/packages/uncheck/tests/hooks/run/payload.test.ts index 7f68c16..1981bc7 100644 --- a/packages/uncheck/tests/hooks/run/payload.test.ts +++ b/packages/uncheck/tests/hooks/run/payload.test.ts @@ -1,5 +1,5 @@ -import { LAYOUTS } from '../../utils/project' -import { CURSOR_STOP, dirFlags, lines, location, stopHook } from './utils' +import { LAYOUTS, cliError, report } from '../../utils/project' +import { CURSOR_STOP, dirFlags, stopHook } from './utils' const UNFORMATTED = 'export const answer = 42\n' @@ -15,24 +15,30 @@ describe.each(LAYOUTS)('hooks run reads the payload in a $name', ({ create, app ]) expect(exitCode).toBe(1) - expect(stdout).toContain( - 'ERROR\n `uncheck hooks run` expects the agent hook payload as JSON on stdin\n', + expect(stdout).toBe( + cliError('`uncheck hooks run` expects the agent hook payload as JSON on stdin'), ) expect(project.read(`${app}src/index.ts`)).toBe(UNFORMATTED) }) - it('takes a payload that is empty, not JSON or not an object as one from no agent in particular', async () => { + it('only reports through a payload from an agent it does not know, or one that is empty, not JSON or not an object', async () => { const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) - for (const payload of ['', '{"hook_event_name": "Stop"', 'null', '["Stop"]']) { + for (const payload of [ + { hook_event_name: 'SubagentStop' }, + '', + '{"hook_event_name": "Stop"', + 'null', + '["Stop"]', + ]) { const { exitCode, stdout, stderr } = await stopHook(project, app, payload, { args: ['--only=oxfmt'], }) expect(exitCode).toBe(0) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', diff --git a/packages/uncheck/tests/hooks/run/selection.test.ts b/packages/uncheck/tests/hooks/run/selection.test.ts index aabb055..787b0f7 100644 --- a/packages/uncheck/tests/hooks/run/selection.test.ts +++ b/packages/uncheck/tests/hooks/run/selection.test.ts @@ -1,5 +1,5 @@ -import { LAYOUTS, singleRepo } from '../../utils/project' -import { CLAUDE_CODE_STOP, TYPE_ERROR, lines, location, stopHook } from './utils' +import { LAYOUTS, cliError, report, singleRepo } from '../../utils/project' +import { CLAUDE_CODE_STOP, TYPE_ERROR, stopHook } from './utils' describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ create, app }) => { it('skips a check with --skip', async () => { @@ -14,8 +14,8 @@ describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ expect(exitCode).toBe(0) expect(stdout).toBe('') - expect(lines(project, stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, no package.json among the given files', '○ oxlint skipped, disabled with --skip=oxlint', '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', @@ -35,8 +35,8 @@ describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ expect(optional.exitCode).toBe(0) expect(optional.stdout).toBe('') - expect(lines(project, optional.stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(optional.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', '○ oxfmt skipped, not selected by --only', @@ -50,8 +50,8 @@ describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ expect(required.exitCode).toBe(2) expect(required.stdout).toBe('') - expect(lines(project, required.stderr)).toEqual([ - `uncheck in ${location(app)}`, + expect(report(required.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', '○ oxfmt skipped, not selected by --only', @@ -70,6 +70,7 @@ describe('hooks run check selection', () => { }) expect(unchanged.exitCode).toBe(0) + expect(unchanged.stdout).toBe('') expect(unchanged.stderr).toBe('') project.write({ 'src/extra.ts': 'export const extra = 1;\n' }) @@ -80,6 +81,6 @@ describe('hooks run check selection', () => { expect(changed.exitCode).toBe(1) expect(changed.stdout).toBe('') - expect(changed.stderr).toBe('\nERROR\n --only=tsc and --skip=tsc contradict each other.\n') + expect(changed.stderr).toBe(cliError('--only=tsc and --skip=tsc contradict each other.')) }) }) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index 79871ba..e3c2dbf 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -1,5 +1,4 @@ import type { Env, Project, Run } from '../../utils/project' -import { report } from '../../utils/project' export const CLAUDE_CODE_STOP = { session_id: 'session', @@ -19,24 +18,13 @@ export const COPILOT_STOP_IN_CLAUDE_FORMAT = { stop_hook_active: false, } -// `tsc -b` writes its build info next to the tsconfig, where it would count as a change. -export const GITIGNORE = { '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n' } - export const TYPE_ERROR = 'export const answer: string = 1;\n' export function dirFlags(app: string): string[] { return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] } -export function location(app: string): string { - return `${app === '' ? '' : `/${app.slice(0, -1)}`}` -} - -export function tscCommand(app: string): string { - return app === '' ? '▶ tsc -p tsconfig.json --noEmit' : '▶ tsc -b tsconfig.json' -} - -export interface HookOptions { +interface HookOptions { readonly args?: ReadonlyArray readonly cwd?: string readonly env?: Env @@ -54,7 +42,3 @@ export function stopHook( input: typeof payload === 'string' ? payload : JSON.stringify(payload), }) } - -export function lines(project: Project, output: string): string[] { - return report(output.replaceAll(project.dir, '')) -} diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index 31e3286..357a49c 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -1,4 +1,4 @@ -import type { Files, Project } from '../utils/project' +import type { Project } from '../utils/project' import { LAYOUTS, monorepo, report, run } from '../utils/project' import { installHusky, prepare } from './utils' @@ -6,20 +6,13 @@ const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } const UNFIXABLE = 'export function ignore({}: object): void {}\n' -function commit(project: Project, message: string) { - return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.path('.') }) -} +const TSC_WITHOUT_REFERENCES = '▶ tsc -p tsconfig.json --noEmit' -function stage(project: Project, files: Files): void { - project.write(files) - project.git('add', '--all') -} - -function checks(output: string): string[] { - return report(output).filter((line) => !line.startsWith('▶ tsc ')) +function commit(project: Project, message: string) { + return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.dir }) } -function blockedByLint(dir: string): string[] { +function blockedByLint(dir: string, tsc: string): string[] { return [ `uncheck staged in ${dir}`, '○ sherif skipped, no package.json among the given files', @@ -27,12 +20,13 @@ function blockedByLint(dir: string): string[] { '✘ oxlint failed', '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', '✔ oxfmt passed', + tsc, '✔ tsc passed', '✘ 1 of 3 checks failed: oxlint', ] } -function fixedAndStaged(dir: string): string[] { +function fixedAndStaged(dir: string, tsc: string): string[] { return [ `uncheck staged in ${dir}`, '○ sherif skipped, no package.json among the given files', @@ -40,22 +34,23 @@ function fixedAndStaged(dir: string): string[] { '✔ oxlint passed', '▶ oxfmt --no-error-on-unmatched-pattern src/spaced.ts', '✔ oxfmt passed', + tsc, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/spaced.ts', ] } -describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create, app }) => { +describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create, app, tsc }) => { it('blocks a commit with a lint error it cannot fix', async () => { const project = create({ '.oxlintrc.json': LINT_CONFIG }) await prepare(project, [], { cwd: app }) - stage(project, { [`${app}src/ignore.ts`]: UNFIXABLE }) + project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) const { exitCode, stderr } = await commit(project, 'ignore') expect(exitCode).toBe(1) - expect(checks(stderr)).toEqual(blockedByLint(project.path(app, '.'))) + expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) expect(project.git('log', '--format=%s')).toBe('init\n') expect(project.git('diff', '--cached', '--name-only')).toBe(`${app}src/ignore.ts\n`) }) @@ -63,28 +58,28 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create it('commits the fixes it applied', async () => { const project = create() await prepare(project, [], { cwd: app }) - stage(project, { [`${app}src/spaced.ts`]: 'export var spaced = 1\n' }) + project.stage({ [`${app}src/spaced.ts`]: 'export var spaced = 1\n' }) const { exitCode, stderr } = await commit(project, 'spaced') expect(exitCode).toBe(0) - expect(checks(stderr)).toEqual(fixedAndStaged(project.path(app, '.'))) + expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') expect(project.read(`${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') - expect(project.git('status', '--porcelain', '--untracked-files=no')).toBe('') + expect(project.git('status', '--porcelain')).toBe('') }) it('runs through the husky 9 dispatcher before the commands of the hook', async () => { const project = installHusky(create(), 'echo "husky hook ran"\n') await prepare(project, [], { cwd: app }) project.commit('husky') - stage(project, { [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) + project.stage({ [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) const { exitCode, stderr } = await commit(project, 'spaced') expect(exitCode).toBe(0) - expect(checks(stderr)).toEqual(fixedAndStaged(project.path(app, '.'))) + expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) expect(stderr).toMatch(/\nhusky hook ran\n$/) expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') }) @@ -95,7 +90,7 @@ describe('committing with the prepared hook of several packages in a monorepo', const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) await prepare(project, [], { cwd: 'packages/core' }) await prepare(project, [], { cwd: 'packages/app' }) - stage(project, { + project.stage({ 'packages/core/src/ignore.ts': UNFIXABLE, 'packages/app/src/spaced.ts': 'export const spaced = 1\n', }) @@ -103,7 +98,9 @@ describe('committing with the prepared hook of several packages in a monorepo', const { exitCode, stderr } = await commit(project, 'both') expect(exitCode).toBe(1) - expect(checks(stderr)).toEqual(blockedByLint(project.path('packages/core'))) + expect(report(stderr)).toEqual( + blockedByLint(project.path('packages/core'), TSC_WITHOUT_REFERENCES), + ) expect(project.read('packages/app/src/spaced.ts')).toBe('export const spaced = 1\n') expect(project.git('log', '--format=%s')).toBe('init\n') }) diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 6822917..143e3cc 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer' import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, monorepo } from '../utils/project' -import { COMMAND, HEADER, hookLine, mode, notWritten, prepare, shownHook, written } from './utils' +import { COMMAND, HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' const HOOK = '.git/hooks/pre-commit' @@ -135,7 +135,7 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(`${line}\n`) - expect(mode(project, HOOK)).toBe(0o755) + expect(project.mode(HOOK)).toBe(0o755) }) it('leaves a hook in another language alone and says what it should run', async () => { diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index b4839c5..d8e458d 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -1,24 +1,18 @@ import { readdirSync } from 'node:fs' -import process from 'node:process' import type { Project } from '../utils/project' -import { LAYOUTS, monorepo, singleRepo } from '../utils/project' -import { chmod, HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' +import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, singleRepo } from '../utils/project' +import { HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' const HOOK = '.git/hooks/pre-commit' -const ROOT_IGNORES_MODES = process.getuid?.() === 0 - function leftoverLocksAndCopies(project: Project, folder = '.git/hooks'): string[] { return readdirSync(project.path(folder)).filter((name) => /\.(?:lock|uncheck-\w+)$/.test(name)) } describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create, app }) => { - // Root reads and writes a file whatever its mode. - it.skipIf(ROOT_IGNORES_MODES)('reports a hooks folder it may not write to', async () => { - const project = create() - chmod(project, '.git/hooks', 0o555) - onTestFinished(() => chmod(project, '.git/hooks', 0o755)) + it.runIf(PERMISSIONS_ENFORCED)('reports a hooks folder it may not write to', async () => { + const project = create().chmod('.git/hooks', 0o555) const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) @@ -33,10 +27,10 @@ describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create expect(project.exists(HOOK)).toBe(false) }) - it.skipIf(ROOT_IGNORES_MODES)('reports a hook it may not read and leaves it alone', async () => { - const project = create().write({ [HOOK]: '#!/bin/sh\npnpm test\n' }) - chmod(project, HOOK, 0o200) - onTestFinished(() => chmod(project, HOOK, 0o644)) + it.runIf(PERMISSIONS_ENFORCED)('reports a hook it may not read and leaves it alone', async () => { + const project = create() + .write({ [HOOK]: '#!/bin/sh\npnpm test\n' }) + .chmod(HOOK, 0o200) const { exitCode, stdout } = await prepare(project, [], { cwd: app }) @@ -47,8 +41,7 @@ describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create `EACCES: permission denied, open '${project.path(HOOK)}'`, ), ) - chmod(project, HOOK, 0o644) - expect(project.read(HOOK)).toBe('#!/bin/sh\npnpm test\n') + expect(project.chmod(HOOK, 0o644).read(HOOK)).toBe('#!/bin/sh\npnpm test\n') expect(leftoverLocksAndCopies(project)).toEqual([]) }) diff --git a/packages/uncheck/tests/prepare/hook.test.ts b/packages/uncheck/tests/prepare/hook.test.ts index b1ef993..b9079b3 100644 --- a/packages/uncheck/tests/prepare/hook.test.ts +++ b/packages/uncheck/tests/prepare/hook.test.ts @@ -1,7 +1,5 @@ -import { dirname } from 'node:path' - -import { LAYOUTS, monorepo, singleRepo } from '../utils/project' -import { chmod, COMMAND, HEADER, hookLine, mode, prepare, shownHook, written } from './utils' +import { cliError, LAYOUTS, monorepo, singleRepo } from '../utils/project' +import { COMMAND, HEADER, hookLine, prepare, shownHook, written } from './utils' describe('prepare flags', () => { it('describes itself and the hook its flags write', async () => { @@ -40,7 +38,9 @@ describe('prepare flags', () => { expect(stdout).toContain('USAGE\n uncheck prepare [flags]\n') expect(stderr).toBe( - `\nERROR\n Invalid value for flag --cwd: "missing". Expected: Path does not exist: ${project.path('missing')}\n`, + cliError( + `Invalid value for flag --cwd: "missing". Expected: Path does not exist: ${project.path('missing')}`, + ), ) expect(exitCode).toBe(1) expect(project.exists('.git/hooks/pre-commit')).toBe(false) @@ -54,7 +54,9 @@ describe('prepare flags', () => { expect(exitCode).toBe(1) expect(stdout).toBe('') expect(stderr).toBe( - '\nERROR\n Nothing to prepare. Pass --pre-commit to write the git hook that runs `uncheck staged --fix` before every commit.\n', + cliError( + 'Nothing to prepare. Pass --pre-commit to write the git hook that runs `uncheck staged --fix` before every commit.', + ), ) expect(project.exists('.git/hooks/pre-commit')).toBe(false) }) @@ -66,7 +68,7 @@ describe('prepare flags', () => { expect(exitCode).toBe(1) expect(stdout).toBe('') - expect(stderr).toBe('\nERROR\n --only=oxlint and --skip=oxlint contradict each other.\n') + expect(stderr).toBe(cliError('--only=oxlint and --skip=oxlint contradict each other.')) expect(project.exists('.git/hooks/pre-commit')).toBe(false) }) }) @@ -75,11 +77,7 @@ describe.each(LAYOUTS)('prepare outside a working tree in a $name', ({ create, a it('has nothing to prepare without a git repository', async () => { const project = create({}, { git: 'none' }) - // Without a ceiling, git would find a repository around the temporary folder and hook it. - const { exitCode, stdout, stderr } = await prepare(project, [], { - cwd: app, - env: { GIT_CEILING_DIRECTORIES: dirname(project.dir) }, - }) + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) expect(stderr).toBe('') expect(exitCode).toBe(0) @@ -118,7 +116,7 @@ describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { expect(exitCode).toBe(0) expect(stdout).toBe(written(shownHook(project, app), 'created')) expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) expect(project.git('status', '--porcelain')).toBe('') }) @@ -136,14 +134,14 @@ describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { it('leaves an up to date hook unchanged and makes it executable again', async () => { const project = create() await prepare(project, [], { cwd: app }) - chmod(project, '.git/hooks/pre-commit', 0o644) + project.chmod('.git/hooks/pre-commit', 0o644) const { exitCode, stdout } = await prepare(project, [], { cwd: app }) expect(exitCode).toBe(0) expect(stdout).toBe(written(shownHook(project, app), 'unchanged')) expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) }) it.each([ @@ -166,7 +164,7 @@ describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { expect(exitCode).toBe(0) expect(stdout).toBe(written(shownHook(project, app), 'updated', command)) expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app, command)}\n`) - expect(mode(project, '.git/hooks/pre-commit')).toBe(0o755) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) }) it('takes the directory from --cwd', async () => { @@ -183,30 +181,24 @@ describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { }) describe('prepare in several packages of a monorepo', () => { - const root = hookLine('') - const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) - const app = hookLine('packages/app/') - - it('keeps a line for each package, in the order they were prepared', async () => { + it('keeps a line for each package in the order they were prepared, and updates one in place', async () => { + const root = hookLine('') + const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) + const app = hookLine('packages/app/') const project = monorepo() - await prepare(project) await prepare(project, ['--only=oxlint'], { cwd: 'packages/core' }) - const { stdout } = await prepare(project, [], { cwd: 'packages/app' }) - expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'updated')) - expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${root}\n${core}\n${app}\n`) - }) + const added = await prepare(project, [], { cwd: 'packages/app' }) - it('updates the line of one package in place and leaves the others alone', async () => { - const project = monorepo() - await prepare(project) - await prepare(project, ['--only=oxlint'], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + expect(added.stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'updated')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${root}\n${core}\n${app}\n`) - const { stdout } = await prepare(project, ['--no-fix']) + const updated = await prepare(project, ['--no-fix']) - expect(stdout).toBe(written('.git/hooks/pre-commit', 'updated', 'pnpm exec uncheck staged')) + expect(updated.stdout).toBe( + written('.git/hooks/pre-commit', 'updated', 'pnpm exec uncheck staged'), + ) expect(project.read('.git/hooks/pre-commit')).toBe( `${HEADER}${hookLine('', 'pnpm exec uncheck staged')}\n${core}\n${app}\n`, ) diff --git a/packages/uncheck/tests/prepare/linked-checkouts.test.ts b/packages/uncheck/tests/prepare/linked-checkouts.test.ts index 23e8f73..ebc2ebf 100644 --- a/packages/uncheck/tests/prepare/linked-checkouts.test.ts +++ b/packages/uncheck/tests/prepare/linked-checkouts.test.ts @@ -1,45 +1,35 @@ -import { join } from 'node:path' +import { basename } from 'node:path' -import type { Project } from '../utils/project' -import { CLI, LAYOUTS, monorepo, run, singleRepo, temporaryDirectory } from '../utils/project' -import { chmod, HEADER, hookLine, prepare, written } from './utils' - -function addWorktree(project: Project): string { - const worktree = join(temporaryDirectory(), 'wt') - - project.git('worktree', 'add', '--quiet', worktree) - - return worktree -} +import { LAYOUTS, linkedWorktree, monorepo, run, singleRepo } from '../utils/project' +import { HEADER, hookLine, prepare, written } from './utils' describe.each(LAYOUTS)('prepare in a linked worktree of a $name', ({ create, app }) => { it('writes the hook the worktree shares with the main one, named by its full path', async () => { const project = create() - const worktree = addWorktree(project) + const worktree = linkedWorktree(project, app) - const { exitCode, stdout, stderr } = await run([...CLI, 'prepare', '--pre-commit'], { - cwd: join(worktree, app), - }) + const { exitCode, stdout, stderr } = await prepare(worktree, [], { cwd: app }) expect(stderr).toBe('') expect(exitCode).toBe(0) expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(project.exists('.git/worktrees/wt/hooks')).toBe(false) + expect(project.exists(`.git/worktrees/${basename(worktree.dir)}/hooks`)).toBe(false) }) }) describe('prepare run by a git hook in a linked worktree of a monorepo', () => { it('enters the package folder although git exported the git folder of the worktree', async () => { const project = monorepo() - const worktree = addWorktree(project) - project.write({ - '.git/hooks/post-checkout': `#!/bin/sh\ncd packages/app && '${project.path('node_modules/.bin/uncheck')}' prepare --pre-commit\n`, - }) - chmod(project, '.git/hooks/post-checkout', 0o755) + const worktree = linkedWorktree(project, 'packages/app/') + project + .write({ + '.git/hooks/post-checkout': `#!/bin/sh\ncd packages/app && '${project.path('node_modules/.bin/uncheck')}' prepare --pre-commit\n`, + }) + .chmod('.git/hooks/post-checkout', 0o755) const { exitCode, stderr } = await run(['git', 'checkout', '--quiet', '-b', 'feature'], { - cwd: worktree, + cwd: worktree.dir, }) expect(exitCode).toBe(0) diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts index 3c9ea98..f50023e 100644 --- a/packages/uncheck/tests/prepare/location.test.ts +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -1,25 +1,31 @@ import { lstatSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { LAYOUTS, temporaryDirectory } from '../utils/project' +import type { Env } from '../utils/project' +import { gitConfig, LAYOUTS, temporaryDirectory, wrappedGit } from '../utils/project' import { - chmod, DISPATCHER, - gitConfig, HEADER, hookLine, HUSKY_SHIM, installHusky, - mode, notWritten, prepare, shownHook, - withGitWithoutShowScope, written, } from './utils' const SHARED_HOOK = '#!/bin/sh\necho "scanning for secrets"\n' +function withGitWithoutShowScope(env: Env = {}): Env { + return { + ...env, + ...wrappedGit( + `case " $* " in *" --show-scope "*) echo "error: unknown option 'show-scope'" >&2; exit 129;; esac`, + ), + } +} + function sharedHooks(): string { const dir = temporaryDirectory() @@ -38,7 +44,7 @@ describe.each(LAYOUTS)('prepare with core.hooksPath in a $name', ({ create, app expect(exitCode).toBe(0) expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) expect(project.read('.githooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, '.githooks/pre-commit')).toBe(0o755) + expect(project.mode('.githooks/pre-commit')).toBe(0o755) expect(project.exists('.git/hooks/pre-commit')).toBe(false) }) @@ -153,7 +159,7 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a return installHusky(create(), 'echo "husky hook ran"\n') } - it('writes the hook husky 9 runs, not its generated shim, and keeps its mode', async () => { + it('writes the hook husky 9 runs, not its generated shim, and keeps its mode, also when unchanged', async () => { const project = husky() const { exitCode, stdout } = await prepare(project, [], { cwd: app }) @@ -161,23 +167,18 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a expect(exitCode).toBe(0) expect(stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'updated')) expect(project.read('.husky/pre-commit')).toBe(`${hookLine(app)}\necho "husky hook ran"\n`) - expect(mode(project, '.husky/pre-commit')).toBe(0o644) + expect(project.mode('.husky/pre-commit')).toBe(0o644) expect(project.read('.husky/_/pre-commit')).toBe(HUSKY_SHIM) - }) - it('leaves the mode of an unchanged husky hook alone', async () => { - const project = husky() - await prepare(project, [], { cwd: app }) - - const { stdout } = await prepare(project, [], { cwd: app }) + const again = await prepare(project, [], { cwd: app }) - expect(stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'unchanged')) - expect(mode(project, '.husky/pre-commit')).toBe(0o644) + expect(again.stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'unchanged')) + expect(project.mode('.husky/pre-commit')).toBe(0o644) }) it('keeps the mode the user gave a husky hook when updating it', async () => { const project = husky() - chmod(project, '.husky/pre-commit', 0o700) + project.chmod('.husky/pre-commit', 0o700) const { exitCode, stdout } = await prepare(project, ['--no-fix'], { cwd: app }) @@ -188,7 +189,7 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a expect(project.read('.husky/pre-commit')).toBe( `${hookLine(app, 'pnpm exec uncheck staged')}\necho "husky hook ran"\n`, ) - expect(mode(project, '.husky/pre-commit')).toBe(0o700) + expect(project.mode('.husky/pre-commit')).toBe(0o700) }) it('creates the hook the Vite+ dispatcher runs, without making it executable', async () => { @@ -199,7 +200,7 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a expect(stdout).toBe(written(shownHook(project, app, '.vite-hooks/pre-commit'), 'created')) expect(project.read('.vite-hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, '.vite-hooks/pre-commit') & 0o111).toBe(0) + expect(project.mode('.vite-hooks/pre-commit') & 0o111).toBe(0) expect(project.exists('.vite-hooks/_/pre-commit')).toBe(false) }) @@ -210,7 +211,7 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a const { stdout } = await prepare(project, [], { cwd: app }) expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) - expect(mode(project, '.githooks/pre-commit')).toBe(0o755) + expect(project.mode('.githooks/pre-commit')).toBe(0o755) }) it('writes into a hooks folder named _ as it is, when its h cannot be read', async () => { @@ -222,7 +223,7 @@ describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, a expect(stdout).toBe(written(shownHook(project, app, '.hooks/_/pre-commit'), 'created')) expect(project.read('.hooks/_/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, '.hooks/_/pre-commit')).toBe(0o755) + expect(project.mode('.hooks/_/pre-commit')).toBe(0o755) }) }) @@ -236,7 +237,7 @@ describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) expect(project.read('scripts/pre-commit')).toBe(`#!/bin/sh\n${hookLine(app)}\npnpm test\n`) - expect(mode(project, 'scripts/pre-commit')).toBe(0o755) + expect(project.mode('scripts/pre-commit')).toBe(0o755) }) it('creates the script a dangling link points to', async () => { @@ -248,6 +249,6 @@ describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'created')) expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) expect(project.read('hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) - expect(mode(project, 'hooks/pre-commit')).toBe(0o755) + expect(project.mode('hooks/pre-commit')).toBe(0o755) }) }) diff --git a/packages/uncheck/tests/prepare/package-manager.test.ts b/packages/uncheck/tests/prepare/package-manager.test.ts index 6eb267c..7c6e42f 100644 --- a/packages/uncheck/tests/prepare/package-manager.test.ts +++ b/packages/uncheck/tests/prepare/package-manager.test.ts @@ -4,10 +4,8 @@ import { HEADER, hookLine, prepare, shownHook, written } from './utils' const YARN = 'yarn run --silent uncheck staged --fix' -function declarePackageManager(project: Project, dir: string, packageManager: unknown): void { - const manifest = JSON.parse(project.read(`${dir}package.json`)) as object - - project.write({ [`${dir}package.json`]: { ...manifest, packageManager } }) +function declarePackageManager(project: Project, dir: string, packageManager: unknown): Project { + return project.update(`${dir}package.json`, (manifest) => ({ ...manifest, packageManager })) } describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ create, app }) => { @@ -39,8 +37,11 @@ describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ crea ])( 'runs uncheck through the declared packageManager %s over the lockfile', async (declared, command) => { - const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) - declarePackageManager(project, app, declared) + const project = declarePackageManager( + create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }), + app, + declared, + ) await expectHookCommand(project, command) }, @@ -50,8 +51,11 @@ describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ crea ['an unknown packageManager', 'deno@2.0.0'], ['a packageManager that is not a string', { name: 'bun' }], ])('falls back to the lockfile with %s', async (_, declared) => { - const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) - declarePackageManager(project, app, declared) + const project = declarePackageManager( + create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }), + app, + declared, + ) await expectHookCommand(project, YARN) }) @@ -74,8 +78,7 @@ describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ crea }) it('runs uncheck with npx --no when nothing names a package manager', async () => { - const project = create({ 'pnpm-lock.yaml': null }) - declarePackageManager(project, '', undefined) + const project = declarePackageManager(create({ 'pnpm-lock.yaml': null }), '', undefined) await expectHookCommand(project, 'npx --no uncheck staged --fix') }) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts index e941c12..1e04eb4 100644 --- a/packages/uncheck/tests/prepare/utils.ts +++ b/packages/uncheck/tests/prepare/utils.ts @@ -1,9 +1,4 @@ -import { execFileSync } from 'node:child_process' -import { chmodSync, statSync, writeFileSync } from 'node:fs' -import { delimiter, join } from 'node:path' - -import type { Env, Project, Run, RunOptions } from '../utils/project' -import { environment, temporaryDirectory } from '../utils/project' +import type { Project, Run, RunOptions } from '../utils/project' export const HEADER = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' @@ -38,43 +33,6 @@ export function notWritten(shown: string, reason: string): string { return `✘ pre-commit ${shown} not written, ${reason}\n` } -export function mode(project: Project, file: string): number { - return statSync(project.path(file)).mode & 0o7777 -} - -export function chmod(project: Project, file: string, value: number): void { - chmodSync(project.path(file), value) -} - -export function gitConfig(content: string): string { - const file = join(temporaryDirectory(), 'gitconfig') - - writeFileSync(file, content) - - return file -} - -export function withGitWithoutShowScope(env: Env = {}): Env { - const real = execFileSync('sh', ['-c', 'command -v git'], { - encoding: 'utf8', - env: environment(), - }).trim() - const bin = temporaryDirectory() - - writeFileSync( - join(bin, 'git'), - [ - '#!/bin/sh', - `case " $* " in *" --show-scope "*) echo "error: unknown option 'show-scope'" >&2; exit 129;; esac`, - `exec '${real}' "$@"`, - '', - ].join('\n'), - { mode: 0o755 }, - ) - - return { ...env, PATH: `${bin}${delimiter}${environment().PATH}` } -} - export const DISPATCHER = [ 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', '[ ! -f "$s" ] && exit 0', @@ -87,14 +45,15 @@ export const DISPATCHER = [ export const HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' export function installHusky(project: Project, hook: string): Project { - project.write({ - '.husky/_/h': DISPATCHER, - '.husky/_/pre-commit': HUSKY_SHIM, - '.husky/pre-commit': hook, - }) - chmod(project, '.husky/_/pre-commit', 0o755) - chmod(project, '.husky/pre-commit', 0o644) - project.git('config', 'core.hooksPath', '.husky/_') + project + .write({ + '.husky/_/h': DISPATCHER, + '.husky/_/pre-commit': HUSKY_SHIM, + '.husky/pre-commit': hook, + }) + .chmod('.husky/_/pre-commit', 0o755) + .chmod('.husky/pre-commit', 0o644) + .git('config', 'core.hooksPath', '.husky/_') return project } diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts index a7898e5..a7b15bc 100644 --- a/packages/uncheck/tests/staged/basics.test.ts +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -1,7 +1,7 @@ -import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' -import { failure, folderOf, inIndex, stage, TSC_COMMAND } from './utils' +import { cliError, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { folderOf, inIndex } from './utils' -describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { +describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) it('needs a git repository', async () => { @@ -9,7 +9,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) - expect(stderr).toBe(failure('`uncheck staged` needs a git repository')) + expect(stderr).toBe(cliError('`uncheck staged` needs a git repository')) expect(stdout).toBe('') expect(exitCode).toBe(1) }) @@ -32,7 +32,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { it('passes clean staged files and leaves unstaged and untracked ones out', async () => { const project = create() - stage(project, { [`${app}src/extra.ts`]: 'export const extra: number = 1;\n' }) + project.stage({ [`${app}src/extra.ts`]: 'export const extra: number = 1;\n' }) project.write({ [`${app}src/index.ts`]: `${project.read(`${app}src/index.ts`)}var unstaged = 1\n`, [`${app}src/untracked.ts`]: 'var untracked = 1\n', @@ -49,15 +49,18 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { '✔ oxlint passed', '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', - TSC_COMMAND[name], + tsc, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)', ]) + expect(project.git('status', '--porcelain')).toBe( + `A ${app}src/extra.ts\n M ${app}src/index.ts\n?? ${app}src/untracked.ts\n`, + ) }) it('fails on lint, format and type errors in the staged files without touching them', async () => { const broken = 'var answer: number = "42"\nexport { answer }\n' - const project = stage(create(), { [`${app}src/index.ts`]: broken }) + const project = create().stage({ [`${app}src/index.ts`]: broken }) const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) @@ -70,7 +73,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { '✘ oxlint failed', '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', '✘ oxfmt failed', - TSC_COMMAND[name], + tsc, '✘ tsc failed', '✘ 3 of 3 checks failed: oxlint, oxfmt, tsc', ' rerun with `--fix` to apply oxlint and oxfmt fixes', @@ -81,7 +84,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { }) it('passes --skip, --only and --require through to the checks', async () => { - const project = stage(create({ [`${app}README.md`]: '# app\n' }), { + const project = create({ [`${app}README.md`]: '# app\n' }).stage({ [`${app}README.md`]: '# app\n\nmore\n', [`${app}src/extra.ts`]: 'export const extra = 1\n', }) @@ -132,7 +135,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ name, create, app }) => { }) it('lists more than three files as a count', async () => { - const project = stage(create(), { + const project = create().stage({ [`${app}src/a.ts`]: 'export const a = 1\n', [`${app}src/b.ts`]: 'export const b = 1\n', [`${app}src/c.ts`]: 'export const c = 1\n', @@ -168,7 +171,7 @@ describe('uncheck staged flags', () => { '--skip=oxlint', ]) - expect(stderr).toBe(failure('--only=oxlint and --skip=oxlint contradict each other.')) + expect(stderr).toBe(cliError('--only=oxlint and --skip=oxlint contradict each other.')) expect(stdout).toBe('') expect(exitCode).toBe(1) }) @@ -181,7 +184,7 @@ describe('uncheck staged in a package of a monorepo', () => { } it('checks and fixes only the staged files of the package it runs in', async () => { - const project = stage(monorepo(), edits) + const project = monorepo().stage(edits) const inside = await project.uncheck(['staged', '--only=oxfmt'], { cwd: 'packages/app' }) const fromTop = await project.uncheck(['staged', '--only=oxfmt', '--cwd', 'packages/app']) @@ -219,7 +222,7 @@ describe('uncheck staged in a package of a monorepo', () => { dependencies: { '@repo/core': 'workspace:*' }, devDependencies: { typescript: '^6.0.0' }, } - const project = stage(monorepo(), { + const project = monorepo().stage({ 'packages/app/package.json': manifest, 'packages/app/src/index.ts': 'var app = 1\nexport { app }\n', }) diff --git a/packages/uncheck/tests/staged/commit.test.ts b/packages/uncheck/tests/staged/commit.test.ts index 24d00be..7f2625f 100644 --- a/packages/uncheck/tests/staged/commit.test.ts +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -1,33 +1,27 @@ -import { chmodSync } from 'node:fs' - -import { LAYOUTS, report, run } from '../utils/project' +import { LAYOUTS, linkedWorktree, report, run } from '../utils/project' import type { Project } from '../utils/project' -import { inIndex, linkedWorktree, stage, stagePartially, status, VERSIONS } from './utils' +import { inIndex, stagePartially, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ create, app }) => { const file = `${app}src/extra.ts` const other = `${app}src/other.ts` function hooked(): Project { - const project = create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }) - - project.write({ - '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix) || exit 1\n`, - }) - chmodSync(project.path('.git/hooks/pre-commit'), 0o755) - - return project + return create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }) + .write({ + '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix) || exit 1\n`, + }) + .chmod('.git/hooks/pre-commit', 0o755) } it('fixes the files of `git commit ` in the commit and in the index it leaves', async () => { - const project = hooked() + const project = hooked().stage({ [other]: 'export const other = 2;\n' }) - stage(project, { [other]: 'export const other = 2;\n' }) project.write({ [file]: VERSIONS.staged }) const { exitCode, stderr } = await run( ['git', 'commit', '--quiet', '--message=extra', '--', file], - { cwd: project.path('.') }, + { cwd: project.dir }, ) expect(exitCode).toBe(0) @@ -36,7 +30,7 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 1;\n') expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.read(file)).toBe(VERSIONS.fixed) - expect(status(project)).toBe(`M ${other}\n`) + expect(project.git('status', '--porcelain')).toBe(`M ${other}\n`) }) it('fixes the files of `git commit -a` in the commit it makes', async () => { @@ -45,14 +39,14 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr project.write({ [file]: VERSIONS.staged, [other]: 'export const other = 2\n' }) const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--all', '--message=all'], { - cwd: project.path('.'), + cwd: project.dir, }) expect(exitCode).toBe(0) expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/other.ts') expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') - expect(status(project)).toBe('') + expect(project.git('status', '--porcelain')).toBe('') }) describe('in a linked worktree, whose hook git hands GIT_DIR', () => { @@ -71,15 +65,14 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr ]) expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(worktree.read(file)).toBe(VERSIONS.merged) - expect(status(worktree)).toBe(` M ${file}\n`) + expect(worktree.git('status', '--porcelain')).toBe(` M ${file}\n`) expect(project.exists('.git/worktrees/linked/uncheck-unstaged')).toBe(false) }) it('fixes the files of `git commit ` in the index the worktree keeps', async () => { const project = hooked() - const worktree = linkedWorktree(project, app) + const worktree = linkedWorktree(project, app).stage({ [other]: 'export const other = 2;\n' }) - stage(worktree, { [other]: 'export const other = 2;\n' }) worktree.write({ [file]: VERSIONS.staged }) const { exitCode, stderr } = await run( @@ -91,8 +84,8 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(inIndex(worktree, file)).toBe(VERSIONS.fixed) - expect(status(worktree)).toBe(`M ${other}\n`) - expect(status(project)).toBe('') + expect(worktree.git('status', '--porcelain')).toBe(`M ${other}\n`) + expect(project.git('status', '--porcelain')).toBe('') }) }) }) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 120ab87..be49d40 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -1,24 +1,13 @@ -import { chmodSync, mkdirSync } from 'node:fs' - -import { git, LAYOUTS, report } from '../utils/project' -import { - EMPTY_COMMIT_ERROR, - failure, - folderOf, - inIndex, - mode, - normalized, - stage, - status, - TSC_COMMAND, - UNTRANSLATED, -} from './utils' - -describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app }) => { +import { mkdirSync } from 'node:fs' + +import { cliError, git, LAYOUTS, report } from '../utils/project' +import { EMPTY_COMMIT_ERROR, folderOf, inIndex } from './utils' + +describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) it('applies the lint and format fixes and stages them', async () => { - const project = stage(create(), { + const project = create().stage({ [`${app}src/index.ts`]: 'var answer = 42\nexport { answer }\n', }) @@ -33,26 +22,27 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } '✔ oxlint passed', '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', '✔ oxfmt passed', - TSC_COMMAND[name], + tsc, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/index.ts', ]) expect(inIndex(project, `${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') expect(project.read(`${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') - expect(status(project)).toBe(`M ${app}src/index.ts\n`) + expect(project.git('status', '--porcelain')).toBe(`M ${app}src/index.ts\n`) }) it('stages the fixes even when a check still fails', async () => { - const project = stage(create(), { + const project = create().stage({ [`${app}src/index.ts`]: 'export const answer: number = "42"\n', }) - const { exitCode, stdout } = await project.uncheck( + const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxfmt', '--only=tsc'], { cwd: folder }, ) + expect(stderr).toBe('') expect(exitCode).toBe(1) expect(report(stdout).slice(-2)).toEqual([ '✘ 1 of 2 checks failed: tsc', @@ -62,7 +52,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } }) it('stages nothing when the fixes change nothing', async () => { - const project = stage(create(), { [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + const project = create().stage({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder, @@ -70,23 +60,23 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } expect(exitCode).toBe(0) expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') - expect(status(project)).toBe(`A ${app}src/extra.ts\n`) + expect(project.git('status', '--porcelain')).toBe(`A ${app}src/extra.ts\n`) }) it('fails when the fixes undo every staged change, unless empty commits are allowed', async () => { const project = create({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) const unformatted = { [`${app}src/extra.ts`]: 'export const extra = 1\n' } - stage(project, unformatted) + project.stage(unformatted) const empty = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) - expect(empty.stderr).toBe(failure(EMPTY_COMMIT_ERROR)) + expect(empty.stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) expect(empty.exitCode).toBe(1) expect(report(empty.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') - expect(status(project)).toBe('') + expect(project.git('status', '--porcelain')).toBe('') - stage(project, unformatted) + project.stage(unformatted) const allowed = await project.uncheck(['staged', '--fix', '--allow-empty', '--only=oxfmt'], { cwd: folder, @@ -95,21 +85,31 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } expect(allowed.stderr).toBe('') expect(allowed.exitCode).toBe(0) expect(report(allowed.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') - expect(status(project)).toBe('') + expect(project.git('status', '--porcelain')).toBe('') }) it('reports why git refused to stage the fixes', async () => { - const project = stage(create(), { [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + const project = create().stage({ [`${app}src/extra.ts`]: 'export const extra = 1\n' }) project.write({ '.git/index.lock': '' }) const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxfmt'], - { cwd: folder, env: UNTRANSLATED }, + { cwd: folder }, ) - expect(normalized(project, stderr)).toMatch( - /^\nERROR\n {2}git update-index \[1 paths\] failed: fatal: Unable to create '\/\.git\/index\.lock': File exists\.\n/, + expect(project.normalize(stderr)).toBe( + cliError( + [ + "git update-index [1 paths] failed: fatal: Unable to create '/.git/index.lock': File exists.", + '', + 'Another git process seems to be running in this repository, e.g.', + "an editor opened by 'git commit'. Please make sure all processes", + 'are terminated then try again. If it still fails, a git process', + 'may have crashed in this repository earlier:', + 'remove the file manually to continue.', + ].join('\n'), + ), ) expect(exitCode).toBe(1) expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') @@ -140,8 +140,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } const project = create() const file = `${app}src/run.ts` - project.write({ [file]: 'export const run = 1\n' }) - chmodSync(project.path(file), 0o755) + project.write({ [file]: 'export const run = 1\n' }).chmod(file, 0o755) project.git('add', '--', file) const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { @@ -153,8 +152,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/run.ts') expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) expect(inIndex(project, file)).toBe('export const run = 1;\n') - expect(mode(project, file)).toBe(0o755) - expect(status(project)).toBe(`A ${file}\n`) + expect(project.mode(file)).toBe(0o755) + expect(project.git('status', '--porcelain')).toBe(`A ${file}\n`) }) it('checks regular files only, never a staged symlink or deletion', async () => { @@ -175,7 +174,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } ]) project.write({ [`${app}src/link.ts`]: null }) - stage(project, { [`${app}src/link.ts`]: 'export const link = 1\n' }) + project.stage({ [`${app}src/link.ts`]: 'export const link = 1\n' }) const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder, @@ -186,8 +185,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/link.ts') expect(inIndex(project, `${app}src/link.ts`)).toBe('export const link = 1;\n') expect(project.read(`${app}src/messy.ts`)).toBe('export const messy = 1\n') - expect(status(project)).toBe( - `A ${app}src/alias.ts\nD ${app}src/gone.ts\nT ${app}src/link.ts\n`, + expect(project.git('status', '--porcelain')).toBe( + `A ${app}src/alias.ts\nD ${app}src/gone.ts\nT ${app}src/link.ts\n?? ${app}src/messy.ts\n`, ) }) @@ -195,7 +194,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } const project = create({ [`${app}routes/i/page.ts`]: 'export const i = 1;\n' }) project.write({ [`${app}routes/i/page.ts`]: 'export const i = 2\n' }) - stage(project, { + project.stage({ [`${app}!x.ts`]: 'export const bang = 1\n', [`${app}-x.ts`]: 'export const dash = 1\n', [`${app}routes/[id]/page.ts`]: 'export const id = 1\n', @@ -222,7 +221,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } expect(inIndex(project, `${app}!x.ts`)).toBe('export const bang = 1;\n') expect(inIndex(project, `${app}-x.ts`)).toBe('export const dash = 1;\n') expect(inIndex(project, `${app}routes/[id]/page.ts`)).toBe('export const id = 1;\n') - expect(status(project)).toBe( + expect(project.git('status', '--porcelain')).toBe( `A ${app}!x.ts\nA ${app}-x.ts\nA ${app}routes/[id]/page.ts\n M ${app}routes/i/page.ts\n`, ) }) @@ -240,7 +239,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ name, create, app } project.commit('submodule') commit('two') project.git('add', '--', `${app}vendor`) - stage(project, { [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + project.stage({ [`${app}src/extra.ts`]: 'export const extra = 1\n' }) commit('three') const before = project.git('rev-parse', `:${app}vendor`) diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index a47b8d0..4276ec5 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -1,7 +1,7 @@ import { writeFileSync } from 'node:fs' import { LAYOUTS, report } from '../utils/project' -import { folderOf, inIndex, stagePartially, startUncheck, status, VERSIONS } from './utils' +import { folderOf, inIndex, stagePartially, startUncheck, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -53,7 +53,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } expect(exitCode).toBe(0) expect(project.read(file)).toBe(VERSIONS.unstaged) - expect(status(project)).toBe(`MM ${file}\n`) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) }) diff --git a/packages/uncheck/tests/staged/line-endings.test.ts b/packages/uncheck/tests/staged/line-endings.test.ts index 2316742..a7fcf70 100644 --- a/packages/uncheck/tests/staged/line-endings.test.ts +++ b/packages/uncheck/tests/staged/line-endings.test.ts @@ -1,5 +1,5 @@ -import { LAYOUTS } from '../utils/project' -import { conflictError, failure, folderOf, inIndex, stage, status } from './utils' +import { cliError, LAYOUTS } from '../utils/project' +import { conflictError, folderOf, inIndex } from './utils' const crlf = (text: string) => text.replaceAll('\n', '\r\n') @@ -13,7 +13,7 @@ describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, a const project = create({ [file]: `export const answer: number = 42;\n${LINES}` }) project.git('config', 'core.autocrlf', 'true') - stage(project, { [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.stage({ [file]: crlf(`export const answer: number = 43\n${LINES}`) }) project.write({ [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), }) @@ -33,7 +33,7 @@ describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, a [file]: crlf(`export const answer: number = 42;\n${LINES}`), }) - stage(project, { [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.stage({ [file]: crlf(`export const answer: number = 43\n${LINES}`) }) project.write({ [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), }) @@ -61,7 +61,7 @@ describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, a const unstaged = `${kept}export const c = 3;\r\nexport const d = 4;\r\n` project.git('config', 'core.safecrlf', 'true') - stage(project, { [file]: `${kept}export const c = 3;\r\n` }) + project.stage({ [file]: `${kept}export const c = 3;\r\n` }) project.write({ [file]: unstaged }) const { exitCode, stderr } = await project.uncheck(['staged', '--only=oxlint'], { @@ -71,21 +71,21 @@ describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, a expect(stderr).toBe('') expect(exitCode).toBe(0) expect(project.read(file)).toBe(unstaged) - expect(status(project)).toBe(`MM ${file}\n`) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) }) it('undoes its fixes rather than turn its line endings to LF', async () => { const project = legacy() const unstaged = `var c = 3;\r\n${kept}export { c };\r\nexport const d = 4;\r\n` - stage(project, { [file]: `var c = 3;\r\n${kept}export { c };\r\n` }) + project.stage({ [file]: `var c = 3;\r\n${kept}export { c };\r\n` }) project.write({ [file]: unstaged }) const { exitCode, stderr } = await project.uncheck(['staged', '--fix', '--only=oxlint'], { cwd: folder, }) - expect(stderr).toBe(failure(conflictError('src/crlf.ts'))) + expect(stderr).toBe(cliError(conflictError('src/crlf.ts'))) expect(exitCode).toBe(1) expect(project.read(file)).toBe(unstaged) expect(inIndex(project, file)).toBe(`var c = 3;\r\n${kept}export { c };\r\n`) diff --git a/packages/uncheck/tests/staged/many-files.test.ts b/packages/uncheck/tests/staged/many-files.test.ts index 9cc6d49..23e7688 100644 --- a/packages/uncheck/tests/staged/many-files.test.ts +++ b/packages/uncheck/tests/staged/many-files.test.ts @@ -1,5 +1,5 @@ import { LAYOUTS, report } from '../utils/project' -import { folderOf, inIndex, stage, VERSIONS } from './utils' +import { folderOf, inIndex, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -16,7 +16,7 @@ describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, a Object.fromEntries(files.map((file) => [file, version])) const project = create(withVersion([...fixed, ...partial], VERSIONS.committed)) - stage(project, { + project.stage({ ...withVersion(fixed, VERSIONS.staged), ...withVersion(partial, VERSIONS.fixed), }) diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 3571e05..123025e 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -1,14 +1,5 @@ -import { LAYOUTS, report, run } from '../utils/project' -import { - commitOnSide, - failure, - folderOf, - inIndex, - stage, - stagePartially, - UNTRANSLATED, - VERSIONS, -} from './utils' +import { cliError, LAYOUTS, report, run } from '../utils/project' +import { commitOnSide, folderOf, inIndex, stagePartially, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -30,7 +21,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap '○ nothing to check, every staged file comes from the branch being merged in', ]) - stage(project, { [other]: 'export const other = 4\n' }) + project.stage({ [other]: 'export const other = 4\n' }) const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder, @@ -49,7 +40,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap }) project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') - stage(project, { [other]: 'export const other = 2\n' }) + project.stage({ [other]: 'export const other = 2\n' }) const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxfmt'], @@ -70,14 +61,12 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap const file = `${app}src/extra.ts` const project = commitOnSide( create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }), - { - [other]: 'export const other = 2;\n', - }, + { [other]: 'export const other = 2;\n' }, ) project.write({ [other]: 'export const other = 3;\n' }).commit('main') - const merge = await run(['git', 'merge', 'side'], { cwd: project.path('.') }) + const merge = await run(['git', 'merge', 'side'], { cwd: project.dir }) expect(merge.exitCode).toBe(1) @@ -85,7 +74,6 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxfmt'], { cwd: folder, - env: UNTRANSLATED, }) const unmerged = project @@ -96,7 +84,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap .join('') expect(stderr).toBe( - failure(`git write-tree failed: ${unmerged}fatal: git-write-tree: error building trees`), + cliError(`git write-tree failed: ${unmerged}fatal: git-write-tree: error building trees`), ) expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) diff --git a/packages/uncheck/tests/staged/precedence.test.ts b/packages/uncheck/tests/staged/precedence.test.ts index 91bc161..8a10309 100644 --- a/packages/uncheck/tests/staged/precedence.test.ts +++ b/packages/uncheck/tests/staged/precedence.test.ts @@ -1,15 +1,12 @@ -import { LAYOUTS, report } from '../utils/project' +import { cliError, LAYOUTS, report } from '../utils/project' import { conflictError, EMPTY_COMMIT_ERROR, - failure, folderOf, inIndex, - normalized, - stage, stagePartially, - status, stranded, + STRANDED_HINT, strandedError, VERSIONS, } from './utils' @@ -23,7 +20,7 @@ describe.each(LAYOUTS)( it('reports the fixes that conflict with unstaged changes over the failed check', async () => { const project = create({ [file]: 'export const extra: number = 1;\n' }) - stage(project, { [file]: 'export const extra: number = "42"\n' }) + project.stage({ [file]: 'export const extra: number = "42"\n' }) project.write({ [file]: 'export const extra: number = "43"\n' }) const { exitCode, stdout, stderr } = await project.uncheck( @@ -31,7 +28,7 @@ describe.each(LAYOUTS)( { cwd: folder }, ) - expect(stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', @@ -40,6 +37,7 @@ describe.each(LAYOUTS)( ]) expect(inIndex(project, file)).toBe('export const extra: number = "42"\n') expect(project.read(file)).toBe('export const extra: number = "43"\n') + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) }) it('reports the unstaged changes it could not put back over the failed check', async () => { @@ -47,27 +45,36 @@ describe.each(LAYOUTS)( project.fake( 'oxlint', - "require('node:fs').chmodSync('src/extra.ts', 0o444)\nprocess.exitCode = 1\n", + "const fs = require('node:fs')\nfs.rmSync('src/extra.ts')\nfs.mkdirSync('src/extra.ts')\nprocess.exitCode = 1\n", ) const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { cwd: folder, }) - expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + const strandedLine = stranded( + 'src/extra.ts', + `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`, + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(normalized(project, stdout)).slice(-6)).toEqual([ + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', '✘ 1 of 1 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', - stranded( - 'src/extra.ts', - `PlatformError: PermissionDenied: FileSystem.copyFile (/.git/uncheck-unstaged/${file})`, - ), + strandedLine, ]) + expect(project.normalize(stdout)).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.git('status', '--porcelain')).toBe(`MD ${file}\n`) }) it('reports a commit the fixes leave empty over the failed check', async () => { @@ -76,21 +83,21 @@ describe.each(LAYOUTS)( [`${app}src/broken.ts`]: 'export const broken: number = "1";\n', }) - stage(project, { [file]: 'export const extra = 1\n' }) + project.stage({ [file]: 'export const extra = 1\n' }) const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxfmt', '--only=tsc'], { cwd: folder }, ) - expect(stderr).toBe(failure(EMPTY_COMMIT_ERROR)) + expect(stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/extra.ts', ]) - expect(status(project)).toBe('') + expect(project.git('status', '--porcelain')).toBe('') }) }, ) diff --git a/packages/uncheck/tests/staged/sparse-checkout.test.ts b/packages/uncheck/tests/staged/sparse-checkout.test.ts index 654c0c8..2ed6b46 100644 --- a/packages/uncheck/tests/staged/sparse-checkout.test.ts +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -1,5 +1,5 @@ -import { LAYOUTS, report, run } from '../utils/project' -import { commitOnSide, conflictError, failure, folderOf, inIndex, stage, status } from './utils' +import { cliError, LAYOUTS, report, run } from '../utils/project' +import { commitOnSide, conflictError, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ create, app }) => { const folder = folderOf(app) @@ -14,14 +14,14 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea project.git('sparse-checkout', 'set', `${app}src`) project.git('merge', '--quiet', '--squash', 'side') - stage(project, { [file]: 'export const extra = 42\n' }) + project.stage({ [file]: 'export const extra = 42\n' }) project.write({ [file]: 'export const extra = 43\n' }) const conflicted = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) - expect(conflicted.stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(conflicted.stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(conflicted.exitCode).toBe(1) - expect(status(project)).toBe(`M ${outside}\nMM ${file}\n`) + expect(project.git('status', '--porcelain')).toBe(`M ${outside}\nMM ${file}\n`) expect(project.read(file)).toBe('export const extra = 43\n') project.git('add', '--', file) @@ -46,7 +46,7 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea project.write({ [outside]: 'export const lib = 3;\n' }).commit('main') project.git('sparse-checkout', 'set', `${app}src`) - const merge = await run(['git', 'merge', '--quiet', 'side'], { cwd: project.path('.') }) + const merge = await run(['git', 'merge', '--quiet', 'side'], { cwd: project.dir }) expect(merge.exitCode).toBe(1) diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts index 32eae2d..688940d 100644 --- a/packages/uncheck/tests/staged/unstaged.test.ts +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -1,22 +1,16 @@ -import { chmodSync, lstatSync, readdirSync } from 'node:fs' +import { lstatSync, readdirSync } from 'node:fs' -import { LAYOUTS, report, run } from '../utils/project' +import { cliError, LAYOUTS, PERMISSIONS_ENFORCED, report, run, wrappedGit } from '../utils/project' import { conflictError, - failure, folderOf, inIndex, LEFTOVER_ERROR, - mode, - normalized, - stage, stagePartially, - status, stranded, + STRANDED_HINT, strandedError, - UNTRANSLATED, VERSIONS, - wrappedGit, } from './utils' describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ create, app }) => { @@ -26,7 +20,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre it('checks the staged version of a partially staged file and puts the unstaged one back', async () => { const project = create({ [file]: VERSIONS.committed }) - stage(project, { [file]: VERSIONS.fixed }) + project.stage({ [file]: VERSIONS.fixed }) project.write({ [file]: `${VERSIONS.fixed}var unstaged = 1\n` }) const { exitCode, stdout, stderr } = await project.uncheck( @@ -49,7 +43,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre '○ unstaged changes of src/extra.ts restored', ]) expect(project.read(file)).toBe(`${VERSIONS.fixed}var unstaged = 1\n`) - expect(status(project)).toBe(`MM ${file}\n`) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) @@ -63,7 +57,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre const fixed = added.replace("'g'", '"g";') const project = create({ [file]: joined, [`${app}src/other.ts`]: 'export const other = 1;\n' }) - stage(project, { [file]: split + added }) + project.stage({ [file]: split + added }) project.write({ [file]: split + edit(added), [`${app}src/other.ts`]: 'export const other = 2\n', @@ -83,11 +77,13 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(project.read(file)).toBe(joined + edit(fixed)) expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') expect(project.read(`${app}src/fresh.ts`)).toBe('export const fresh = 3\n') - expect(status(project)).toBe(`MM ${file}\n M ${app}src/other.ts\n`) + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\n M ${app}src/other.ts\n?? ${app}src/fresh.ts\n`, + ) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) - it('undoes every fix when one conflicts with unstaged changes, so nothing is lost', async () => { + it('undoes every fix when one conflicts with unstaged changes, without running the post-checkout hook', async () => { const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) const stagedFar = ['export const f0 = 0;\n', ...far.slice(1)].join('') const unstagedFar = [ @@ -99,8 +95,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre [file]: 'export const extra = 1;\n', [`${app}src/far.ts`]: far.join(''), }) + .write({ '.git/hooks/post-checkout': '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n' }) + .chmod('.git/hooks/post-checkout', 0o755) - stage(project, { + project.stage({ [file]: 'export const extra = 42\n', [`${app}src/far.ts`]: stagedFar, [`${app}src/other.ts`]: 'export const other = 2\n', @@ -115,7 +113,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre { cwd: folder }, ) - expect(stderr).toBe(failure(conflictError('src/extra.ts'))) + expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/far.ts src/other.ts') expect(inIndex(project, file)).toBe('export const extra = 42\n') @@ -124,6 +122,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(project.read(file)).toBe('export const extra = 43\n') expect(project.read(`${app}src/far.ts`)).toBe(unstagedFar) expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\nMM ${app}src/far.ts\nA ${app}src/other.ts\n`, + ) + expect(project.exists('.git/post-checkout-ran')).toBe(false) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) @@ -133,7 +135,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre [`${app}src/b.ts`]: 'export const b = 1;\n', }) - stage(project, { + project.stage({ [`${app}src/a.ts`]: 'export const a = 2;\n', [`${app}src/b.ts`]: 'export const b = 2;\n', }) @@ -143,7 +145,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) expect(stderr).toBe( - failure( + cliError( 'The unstaged changes of src/a.ts src/b.ts are not edits to a file and cannot be set aside. Stage or stash them, then commit again.', ), ) @@ -151,7 +153,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) expect(project.exists(`${app}src/a.ts`)).toBe(false) expect(lstatSync(project.path(`${app}src/b.ts`)).isSymbolicLink()).toBe(true) - expect(status(project)).toBe(`MD ${app}src/a.ts\nMT ${app}src/b.ts\n`) + expect(project.git('status', '--porcelain')).toBe(`MD ${app}src/a.ts\nMT ${app}src/b.ts\n`) }) it('stops rather than overwrite the unstaged changes an earlier run left behind', async () => { @@ -161,7 +163,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) - expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe('left behind') @@ -170,18 +172,18 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre }) it('stops on the unstaged changes an earlier run left behind even with none to set aside now', async () => { - const project = stage(create(), { [file]: 'export const extra = 1\n' }) + const project = create().stage({ [file]: 'export const extra = 1\n' }) project.write({ '.git/uncheck-unstaged/src/index.ts': 'left behind' }) const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) - expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) expect(project.read('.git/uncheck-unstaged/src/index.ts')).toBe('left behind') expect(inIndex(project, file)).toBe('export const extra = 1\n') - expect(status(project)).toBe(`A ${file}\n`) + expect(project.git('status', '--porcelain')).toBe(`A ${file}\n`) }) it('stops when a parallel run claims the folder for unstaged changes first', async () => { @@ -194,7 +196,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre ), }) - expect(normalized(project, stderr)).toBe(failure(LEFTOVER_ERROR)) + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) expect(readdirSync(project.path('.git/uncheck-unstaged'))).toEqual([]) @@ -205,13 +207,13 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre it('keeps the unstaged mode of a file, whether or not the checks change it', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) - chmodSync(project.path(file), 0o755) + project.chmod(file, 0o755) const checked = await project.uncheck(['staged', '--only=oxlint'], { cwd: folder }) expect(checked.exitCode).toBe(0) expect(project.read(file)).toBe(VERSIONS.unstaged) - expect(mode(project, file)).toBe(0o755) + expect(project.mode(file)).toBe(0o755) const fixed = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) @@ -219,7 +221,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100644 /) expect(project.read(file)).toBe(VERSIONS.merged) - expect(mode(project, file)).toBe(0o755) + expect(project.mode(file)).toBe(0o755) }) it('keeps an edit a tool saves to a partially staged file while the checks run', async () => { @@ -249,7 +251,9 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.read(file)).toBe(VERSIONS.merged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) }) it('reads every unstaged change right, a rename among them', async () => { @@ -267,28 +271,9 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(exitCode).toBe(0) expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.read(file)).toBe(VERSIONS.merged) - expect(status(project)).toBe(`MM ${file}\n R ${app}src/aaa.ts -> ${app}src/moved.ts\n`) - }) - - it('sets unstaged changes aside and back without running the post-checkout hook', async () => { - const project = create({ [file]: 'export const extra = 1;\n' }) - - project.write({ - '.git/hooks/post-checkout': '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n', - }) - chmodSync(project.path('.git/hooks/post-checkout'), 0o755) - stage(project, { [file]: 'export const extra = 42\n' }) - project.write({ [file]: 'export const extra = 43\n' }) - - const { exitCode, stderr } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { - cwd: folder, - }) - - expect(stderr).toBe(failure(conflictError('src/extra.ts'))) - expect(exitCode).toBe(1) - expect(project.exists('.git/post-checkout-ran')).toBe(false) - expect(inIndex(project, file)).toBe('export const extra = 42\n') - expect(project.read(file)).toBe('export const extra = 43\n') + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\n R ${app}src/aaa.ts -> ${app}src/moved.ts\n`, + ) }) it('puts unstaged changes back from a folder whose name has a newline', async () => { @@ -311,27 +296,44 @@ describe.each(LAYOUTS)( const folder = folderOf(app) const file = `${app}src/extra.ts` const saved = `.git/uncheck-unstaged/${file}` - const whereKept = - ' their unstaged versions are in /.git/uncheck-unstaged, at their paths from the top of the repository: copy back what your files are missing and delete the folder\n' - it('keeps the unstaged changes aside and stops when a check leaves the file unwritable', async () => { - const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + it.runIf(PERMISSIONS_ENFORCED)( + 'keeps the unstaged changes aside and stops when a check leaves the file unwritable', + async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) - project.fake('oxlint', "require('node:fs').chmodSync('src/extra.ts', 0o444)\n") + project.fake('oxlint', "require('node:fs').chmodSync('src/extra.ts', 0o444)\n") - const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { - cwd: folder, - }) + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) - expect(stderr).toBe(failure(strandedError('src/extra.ts'))) - expect(exitCode).toBe(1) - expect(normalized(project, stdout)).toContain( - `${stranded('src/extra.ts', `PlatformError: PermissionDenied: FileSystem.copyFile (/${saved})`)}\n${whereKept}`, - ) - expect(project.read(saved)).toBe(VERSIONS.unstaged) - expect(project.read(file)).toBe(VERSIONS.staged) - expect(inIndex(project, file)).toBe(VERSIONS.staged) - }) + const output = project.normalize(stdout) + const strandedLine = stranded( + 'src/extra.ts', + `PlatformError: PermissionDenied: FileSystem.copyFile (/${saved})`, + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(output)).toEqual([ + `uncheck staged in ${project.normalize(project.path(folder))}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + strandedLine, + ]) + expect(output).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + expect(project.read(file)).toBe(VERSIONS.staged) + expect(project.mode(file)).toBe(0o444) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }, + ) it('keeps the unstaged changes aside and stops when a check replaces the file with a folder', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) @@ -343,13 +345,12 @@ describe.each(LAYOUTS)( const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { cwd: folder, - env: UNTRANSLATED, }) - expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(normalized(project, stdout)).toContain( - `${stranded('src/extra.ts', `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`)}\n${whereKept}`, + expect(project.normalize(stdout)).toContain( + `${stranded('src/extra.ts', `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`)}\n${STRANDED_HINT}`, ) expect(project.read(saved)).toBe(VERSIONS.unstaged) expect(inIndex(project, file)).toBe(VERSIONS.staged) @@ -370,15 +371,15 @@ describe.each(LAYOUTS)( const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxfmt'], - { cwd: folder, env: UNTRANSLATED }, + { cwd: folder }, ) const merged = await run(['git', 'hash-object', '--no-filters', '--stdin'], { - cwd: project.path('.'), + cwd: project.dir, input: VERSIONS.merged, }) - expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) expect(report(stdout).slice(-2)).toEqual([ '✔ staged the fixes to src/extra.ts', @@ -405,7 +406,7 @@ describe.each(LAYOUTS)( }, ) - expect(stderr).toBe(failure(strandedError('src/extra.ts'))) + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) expect(report(stdout).at(-2)).toBe('✔ staged the fixes to src/extra.ts') expect(report(stdout).at(-1)).toMatch( @@ -416,22 +417,25 @@ describe.each(LAYOUTS)( expect(project.read(saved)).toBe(VERSIONS.unstaged) }) - it('stops before touching a partially staged file it cannot read', async () => { - const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + it.runIf(PERMISSIONS_ENFORCED)( + 'stops before touching a partially staged file it cannot read', + async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) - chmodSync(project.path(file), 0o000) + project.chmod(file, 0o000) - const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) - expect(normalized(project, stderr)).toBe( - failure(`PermissionDenied: FileSystem.copyFile (/${file})`), - ) - expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) - expect(project.exists('.git/uncheck-unstaged')).toBe(false) - chmodSync(project.path(file), 0o644) - expect(project.read(file)).toBe(VERSIONS.unstaged) - }) + expect(project.normalize(stderr)).toBe( + cliError(`PermissionDenied: FileSystem.copyFile (/${file})`), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + expect(project.mode(file)).toBe(0o000) + expect(project.chmod(file, 0o644).read(file)).toBe(VERSIONS.unstaged) + }, + ) it('puts back what it set aside when git fails to check out the staged versions', async () => { const other = `${app}src/other.ts` @@ -450,14 +454,13 @@ describe.each(LAYOUTS)( const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder, - env: UNTRANSLATED, }) const filter = "external filter 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi' failed" expect(stderr).toBe( - failure( + cliError( `git checkout-index -f [2 paths] failed: error: ${filter} 1\nerror: ${filter}\nfatal: ${other}: smudge filter flaky failed`, ), ) @@ -465,7 +468,7 @@ describe.each(LAYOUTS)( expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) expect(project.read(file)).toBe(VERSIONS.unstaged) expect(project.read(other)).toBe(VERSIONS.unstaged) - expect(status(project)).toBe(`MM ${file}\nMM ${other}\n`) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\nMM ${other}\n`) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) }, diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index 7e0e9d5..a860251 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -1,31 +1,14 @@ -import { execFileSync, spawn } from 'node:child_process' +import { spawn } from 'node:child_process' import type { ChildProcessWithoutNullStreams } from 'node:child_process' -import { statSync, writeFileSync } from 'node:fs' -import { delimiter, join } from 'node:path' -import { CLI, environment, Project, temporaryDirectory } from '../utils/project' -import type { Env, Files, Run } from '../utils/project' +import { CLI, environment } from '../utils/project' +import type { Env, Files, Project, Run } from '../utils/project' /** The folder of a layout's package, relative to the top of the repository. */ export function folderOf(app: string): string { return app.replace(/\/$/, '') || '.' } -// git translates its messages to the locale of the machine, so tests asserting them need this. -export const UNTRANSLATED: Env = { LC_ALL: 'C' } - -/** An environment whose `git` first runs the shell `script`, which finds the real git in `$GIT`. */ -export function wrappedGit(script: string): Env { - const real = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() - const shims = temporaryDirectory() - - writeFileSync(join(shims, 'git'), `#!/bin/sh\nGIT='${real}'\n${script}\nexec "$GIT" "$@"\n`, { - mode: 0o755, - }) - - return { PATH: `${shims}${delimiter}${environment().PATH}` } -} - export const LEFTOVER_ERROR = 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' @@ -45,11 +28,8 @@ export function stranded(files: string, reason: string): string { return `✘ could not put back the unstaged changes of ${files}: ${reason}` } -/** How tsc checks the package of each layout. */ -export const TSC_COMMAND: Readonly> = { - 'single repo': '▶ tsc -p tsconfig.json --noEmit', - 'monorepo': '▶ tsc -b tsconfig.json', -} +export const STRANDED_HINT = + ' their unstaged versions are in /.git/uncheck-unstaged, at their paths from the top of the repository: copy back what your files are missing and delete the folder\n' const MIDDLE = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\n' @@ -67,17 +47,7 @@ export const VERSIONS = { /** Stages the `staged` version of `file`, then leaves its `unstaged` version in the working tree. */ export function stagePartially(project: Project, file: string): Project { - stage(project, { [file]: VERSIONS.staged }) - - return project.write({ [file]: VERSIONS.unstaged }) -} - -/** Writes `files` and stages them as they are. */ -export function stage(project: Project, files: Files): Project { - project.write(files) - project.git('--literal-pathspecs', 'add', '--all', '--', ...Object.keys(files)) - - return project + return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) } /** Commits `files` on a new `side` branch, then goes back to `main`. */ @@ -89,43 +59,10 @@ export function commitOnSide(project: Project, files: Files): Project { return project } -/** A checkout of `project` made with `git worktree add`, with the links an install would make. */ -export function linkedWorktree(project: Project, app: string): Project { - const worktree = new Project(join(temporaryDirectory(), 'linked')) - - project.git('worktree', 'add', '--quiet', worktree.dir) - worktree.link('node_modules', project.path('node_modules')) - - if (app !== '') { - worktree.link(`${app}node_modules/@repo/core`, '../../../core') - } - - return worktree -} - -/** Tracked changes only, since the monorepo's tsc leaves build info files behind. */ -export function status(project: Project): string { - return project.git('status', '--porcelain', '--untracked-files=no') -} - export function inIndex(project: Project, file: string): string { return project.git('show', `:${file}`) } -export function mode(project: Project, file: string): number { - return statSync(project.path(file)).mode & 0o777 -} - -/** How the CLI prints the error that stops a run. */ -export function failure(message: string): string { - return `\nERROR\n ${message}\n` -} - -/** `text` with the folder of `project`, which differs between runs, as ``. */ -export function normalized(project: Project, text: string): string { - return text.replaceAll(project.dir, '') -} - export interface Started { readonly child: ChildProcessWithoutNullStreams /** Resolves once stdout has shown `marker`. */ diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts index ff084a9..0994105 100644 --- a/packages/uncheck/tests/uncheck/cli.test.ts +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -1,6 +1,15 @@ import { readFileSync } from 'node:fs' -import { CLI, LAYOUTS, report, run, singleRepo, temporaryDirectory } from '../utils/project' +import { + CLI, + cliError, + LAYOUTS, + report, + run, + singleRepo, + temporaryDirectory, +} from '../utils/project' +import { CODE_WITH_VAR } from './utils' const { version } = JSON.parse( readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), @@ -19,7 +28,7 @@ function onlyOxlint(dir: string): string[] { } describe.each(LAYOUTS)('uncheck --cwd in a $name', ({ create, app }) => { - const legacyOutsideSrc = { [`${app}legacy.ts`]: 'var count = 1;\nexport { count };\n' } + const legacyOutsideSrc = { [`${app}legacy.ts`]: CODE_WITH_VAR } it('checks the directory given relative to the current one', async () => { const project = create(legacyOutsideSrc) @@ -52,7 +61,9 @@ describe.each(LAYOUTS)('uncheck --cwd in a $name', ({ create, app }) => { expect(stdout).toContain('USAGE') expect(stderr).toBe( - `\nERROR\n Invalid value for flag --cwd: "${app}${cwd}". Expected: ${problem}: ${project.path(app, cwd)}\n`, + cliError( + `Invalid value for flag --cwd: "${app}${cwd}". Expected: ${problem}: ${project.path(app, cwd)}`, + ), ) expect(exitCode).toBe(1) }) @@ -105,7 +116,7 @@ describe('uncheck command line', () => { const { exitCode, stdout, stderr } = await singleRepo().uncheck([flag]) expect(stdout).toContain('USAGE') - expect(stderr).toBe(`\nERROR\n Unrecognized flag: ${flag} in command uncheck\n`) + expect(stderr).toBe(cliError(`Unrecognized flag: ${flag} in command uncheck`)) expect(exitCode).toBe(1) }) }) diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts index 7a95f67..7507ffa 100644 --- a/packages/uncheck/tests/uncheck/fix.test.ts +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -1,14 +1,21 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' -import { layoutChecks, updateJson } from './utils' - -const LEGACY = 'var count = 1;\nexport { count };\n' -const UGLY = 'export const ugly = {a:1,\n b:2}\n' - -describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { - const { sherif, tsc, checks } = layoutChecks(app) +import type { Project } from '../utils/project' +import { LAYOUTS, report } from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CODE_WITH_VAR, + layoutChecks, + monorepoWithMismatchedVersions, + UNFORMATTED_CODE, +} from './utils' + +describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) it('applies lint fixes and rewrites formatting, then passes', async () => { - const project = create({ [`${app}src/legacy.ts`]: LEGACY, [`${app}src/ugly.ts`]: UGLY }) + const project = create({ + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/ugly.ts`]: UNFORMATTED_CODE, + }) const { exitCode, stdout } = await project.uncheck(['--fix']) @@ -29,7 +36,10 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { }) it('offers the fixes of every fixable check that failed', async () => { - const project = create({ [`${app}src/legacy.ts`]: LEGACY, [`${app}src/ugly.ts`]: UGLY }) + const project = create({ + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/ugly.ts`]: UNFORMATTED_CODE, + }) const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=oxfmt']) @@ -42,8 +52,8 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { it('leaves type errors out of the fixes it offers', async () => { const project = create({ - [`${app}src/legacy.ts`]: LEGACY, - [`${app}src/broken.ts`]: 'export const broken: number = "42";\n', + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/broken.ts`]: CODE_WITH_TYPE_ERROR, }) const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) @@ -57,7 +67,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { it('leaves a required check that cannot run out of the fixes it offers', async () => { const project = create( - { [`${app}src/legacy.ts`]: LEGACY }, + { [`${app}src/legacy.ts`]: CODE_WITH_VAR }, { tools: ['sherif', 'oxlint', 'typescript'] }, ) @@ -99,26 +109,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app }) => { }) }) -function monorepoWithMismatchedVersions(sherif: object = {}) { - const project = monorepo() - - updateJson(project, 'package.json', (manifest) => ({ - ...manifest, - sherif: { noInstall: true, ...sherif }, - })) - updateJson(project, 'packages/core/package.json', (manifest) => ({ - ...manifest, - dependencies: { zod: '^3.0.0' }, - })) - updateJson(project, 'packages/app/package.json', (manifest) => ({ - ...manifest, - dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, - })) - - return project -} - -function zodVersions(project: ReturnType) { +function zodVersions(project: Project) { return ['core', 'app'].map( (name) => ( @@ -161,7 +152,7 @@ describe('uncheck --fix in a monorepo', () => { it('only reports what sherif finds in CI, where sherif refuses to fix', async () => { const project = monorepoWithMismatchedVersions() - project.write({ 'packages/app/src/legacy.ts': LEGACY }) + project.write({ 'packages/app/src/legacy.ts': CODE_WITH_VAR }) const { exitCode, stdout } = await project.uncheck(['--fix', '--skip=tsc'], { env: { CI: 'true' }, @@ -207,7 +198,10 @@ describe('uncheck --fix in a monorepo', () => { it('offers the fixes of three checks as a list', async () => { const project = monorepoWithMismatchedVersions() - project.write({ 'packages/app/src/legacy.ts': LEGACY, 'packages/app/src/ugly.ts': UGLY }) + project.write({ + 'packages/app/src/legacy.ts': CODE_WITH_VAR, + 'packages/app/src/ugly.ts': UNFORMATTED_CODE, + }) const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts index 24613e4..419f66b 100644 --- a/packages/uncheck/tests/uncheck/output.test.ts +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -1,8 +1,8 @@ import { availableParallelism } from 'node:os' -import { stripVTControlCharacters, styleText } from 'node:util' +import { styleText } from 'node:util' import { LAYOUTS, report, singleRepo } from '../utils/project' -import { layoutChecks } from './utils' +import { CODE_WITH_VAR, layoutChecks } from './utils' const STANDALONE_TSCONFIG = { compilerOptions: { @@ -27,6 +27,7 @@ const count = (dir) => readdirSync(dir).length; const giveUp = Date.now() + 10_000; let most = 0; +console.log(\`\${config} started\`); mkdirSync('running', { recursive: true }); mkdirSync('finished', { recursive: true }); writeFileSync(\`running/\${marker}\`, ''); @@ -51,7 +52,7 @@ function paint(style: Parameters[0], text: string): string { return styleText(style, text, { validateStream: false }) } -describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app }) => { +describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app, tsc }) => { const { sherif, checks } = layoutChecks(app) it('fails a check whose tool is killed by a signal and still runs the others', async () => { @@ -92,12 +93,12 @@ describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app }) => { .map((line) => line.replace(/\(\d+,\d+\): error (TS\d+).*$/, ' $1')) expect(lines).toEqual([ - ...(app === '' ? [] : ['▶ tsc -b tsconfig.json']), + ...(app === '' ? [] : [tsc]), `▶ tsc -p ${app}scripts/tsconfig.json --noEmit`, `${app}scripts/check.ts TS2322`, `▶ tsc -p ${app}tools/tsconfig.json --noEmit`, `${app}tools/check.ts TS2322`, - ...(app === '' ? ['▶ tsc -p tsconfig.json --noEmit'] : []), + ...(app === '' ? [tsc] : []), ]) expect(report(stdout).at(-1)).toBe('✘ 1 of 1 checks failed: tsc') expect(exitCode).toBe(1) @@ -105,45 +106,7 @@ describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app }) => { }) describe('uncheck output', () => { - it.skipIf(availableParallelism() < 2)( - 'checks standalone projects side by side without mixing their output', - async () => { - const project = singleRepo({ - 'scripts/tsconfig.json': STANDALONE_TSCONFIG, - 'scripts/check.ts': 'export const scripts = 1;\n', - }).fake( - 'typescript', - `const { existsSync, writeFileSync } = require('node:fs'); - -const config = process.argv[process.argv.indexOf('-p') + 1]; -const other = config === 'tsconfig.json' ? 'scripts/tsconfig.json' : 'tsconfig.json'; -const giveUp = Date.now() + 10_000; - -console.log(\`\${config} started\`); -writeFileSync(\`\${config}.started\`, ''); - -(function waitForOther() { - if (existsSync(\`\${other}.started\`)) { - console.log(\`\${config} saw \${other} running\`); - } else if (Date.now() > giveUp) { - console.log(\`\${config} ran alone\`); - } else { - setTimeout(waitForOther, 10); - } -})(); -`, - ) - - const { exitCode, stdout } = await project.uncheck(['--only=tsc']) - - expect(stdout).toMatch( - /^▶ tsc -p scripts\/tsconfig\.json --noEmit\nscripts\/tsconfig\.json started\nscripts\/tsconfig\.json saw tsconfig\.json running\n▶ tsc -p tsconfig\.json --noEmit\ntsconfig\.json started\ntsconfig\.json saw scripts\/tsconfig\.json running\n✔ tsc passed /m, - ) - expect(exitCode).toBe(0) - }, - ) - - it('runs at most four type checkers at once and prints their output in order', async () => { + it('runs up to four type checkers side by side and prints the output of each in one piece, in order', async () => { const cap = Math.min(4, availableParallelism()) const project = singleRepo( Object.fromEntries( @@ -156,7 +119,9 @@ writeFileSync(\`\${config}.started\`, ''); const { exitCode, stdout } = await project.uncheck(['--only=tsc']) - const runs = [...stdout.matchAll(/^▶ tsc -p (\S+) --noEmit\n\1 saw (\d+) running$/gm)] + const runs = [ + ...stdout.matchAll(/^▶ tsc -p (\S+) --noEmit\n\1 started\n\1 saw (\d+) running$/gm), + ] expect(runs.map(([, config]) => config)).toEqual([ ...STANDALONE_PROJECTS.map((name) => `${name}/tsconfig.json`), @@ -193,12 +158,20 @@ writeFileSync(\`\${config}.started\`, ''); `${paint('green', '✔')} ${paint('bold', 'oxfmt')} ${paint('green', 'passed')} `, ) expect(stdout).toContain(`${paint('green', '✔')} all checks passed (oxfmt)\n`) - expect(report(stripVTControlCharacters(stdout)).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) expect(exitCode).toBe(0) }) it('styles failures in red', async () => { - const project = singleRepo({ 'src/legacy.ts': 'var count = 1;\nexport { count };\n' }) + const project = singleRepo({ 'src/legacy.ts': CODE_WITH_VAR }) const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--require=oxlint'], { env: { FORCE_COLOR: '1' }, @@ -215,10 +188,7 @@ writeFileSync(\`\${config}.started\`, ''); it('asks tools for colors when it prints to a terminal', async () => { const project = singleRepo().fake('oxlint', PRINT_FORCE_COLOR) - // Node paints nothing in a terminal whose TERM is unset, which it is in some shells. - const { exitCode, stdout } = await project.uncheckInTerminal(['--only=oxlint'], { - env: { TERM: 'xterm-256color' }, - }) + const { exitCode, stdout } = await project.uncheckInTerminal(['--only=oxlint']) expect(stdout).toContain('\nthe tool sees FORCE_COLOR=1\n') expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxlint)') diff --git a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts index 66f3b33..4d0a301 100644 --- a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts +++ b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts @@ -1,6 +1,6 @@ import type { Project } from '../utils/project' import { LAYOUTS, report } from '../utils/project' -import { checkedFiles, listingProject } from './paths-utils' +import { checkedFiles, listingProject } from './utils' const ROUTES = [ '#draft.ts', diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index 0e543c3..a710599 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -1,8 +1,8 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import { LAYOUTS, temporaryDirectory } from '../utils/project' -import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './paths-utils' +import { cliError, LAYOUTS, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './utils' describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ create, app }) => { const routes = `${app}src/routes` @@ -29,7 +29,9 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ } expect(ignoredDirectory.exitCode).toBe(1) expect(ignoredDirectory.stderr).toBe( - `\nERROR\n No files match ${routes}/dist. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + cliError( + `No files match ${routes}/dist. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), ) expect(named.exitCode).toBe(1) expect(selectedReport(named.stdout)).toEqual([ diff --git a/packages/uncheck/tests/uncheck/paths-globs.test.ts b/packages/uncheck/tests/uncheck/paths-globs.test.ts index 5389189..bc8af90 100644 --- a/packages/uncheck/tests/uncheck/paths-globs.test.ts +++ b/packages/uncheck/tests/uncheck/paths-globs.test.ts @@ -1,6 +1,6 @@ import type { Project } from '../utils/project' import { LAYOUTS } from '../utils/project' -import { checkedFiles, listingProject } from './paths-utils' +import { checkedFiles, listingProject } from './utils' const ROUTES = [ '#draft.ts', diff --git a/packages/uncheck/tests/uncheck/files.test.ts b/packages/uncheck/tests/uncheck/paths-tools.test.ts similarity index 60% rename from packages/uncheck/tests/uncheck/files.test.ts rename to packages/uncheck/tests/uncheck/paths-tools.test.ts index 8405264..87f9d6b 100644 --- a/packages/uncheck/tests/uncheck/files.test.ts +++ b/packages/uncheck/tests/uncheck/paths-tools.test.ts @@ -1,22 +1,30 @@ -import { LAYOUTS, report } from '../utils/project' -import { layoutChecks } from './utils' +import { writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' -const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] +import { LAYOUTS, report, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, NOT_COVERED, selectedReport } from './utils' -describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { - const { tsc } = layoutChecks(app) +const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] - it('checks only the given files with every tool', async () => { - const project = create({ [`${app}src/legacy.ts`]: 'var count = 1;\nexport { count };\n' }) +describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ create, app, tsc }) => { + it('hands oxlint and oxfmt the given files alone and runs tsc on the config covering them', async () => { + const project = create({ + [`${app}src/extra.ts`]: CLEAN_CODE, + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + }) - const { exitCode, stdout } = await project.uncheck([`${app}src/index.ts`]) + const { exitCode, stdout, stderr } = await project.uncheck([ + `${app}src/index.ts`, + `${app}src/extra.ts`, + ]) + expect(stderr).toBe('') expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, no package.json among the given files', - `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, '✔ oxlint passed', - `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -25,6 +33,37 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { expect(exitCode).toBe(0) }) + it('lists every file for "." but leaves the choice to each tool without paths', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/legacy.ts`]: CODE_WITH_VAR, + }) + + const dot = await project.uncheck([...ONLY_FILE_CHECKS, '.'], { cwd: `${app}src/routes` }) + const everything = await project.uncheck(ONLY_FILE_CHECKS, { cwd: `${app}src/routes` }) + + expect(dot.exitCode).toBe(1) + expect(selectedReport(dot.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint --no-error-on-unmatched-pattern home.ts legacy.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern home.ts legacy.ts', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(everything.exitCode).toBe(1) + expect(selectedReport(everything.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + it('fixes only the given files', async () => { const broken = 'var point = {x:1,\n y:2}\nexport { point }\n' const project = create({ [`${app}src/given.ts`]: broken, [`${app}src/other.ts`]: broken }) @@ -50,7 +89,10 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { }) it('passes files that no tool handles to the linter and formatter without failing', async () => { - const project = create({ [`${app}notes.txt`]: 'var draft\n', [`${app}README.md`]: '# App\n' }) + const project = create({ + [`${app}notes.txt`]: 'var draft\n', + [`${app}README.md`]: '# App\n', + }) const { exitCode, stdout } = await project.uncheck([`${app}notes.txt`, `${app}README.md`]) @@ -61,66 +103,12 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { '✔ oxlint passed', `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}README.md ${app}notes.txt`, '✔ oxfmt passed', - '○ tsc skipped, no tsconfig.json covers the given files', - '✔ all checks passed (oxlint, oxfmt)', - ]) - expect(exitCode).toBe(0) - }) - - it('fails on paths that match no file', async () => { - const project = create() - - const { exitCode, stdout, stderr } = await project.uncheck([ - `${app}src/index.ts`, - `${app}missing.ts`, - `${app}lib/**`, - ]) - - expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) - expect(stderr).toBe( - `\nERROR\n No files match ${app}missing.ts, ${app}lib/**. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, - ) - expect(exitCode).toBe(1) - }) - - it('runs with whatever matched when unmatched patterns are allowed', async () => { - const project = create() - - const { exitCode, stdout } = await project.uncheck([ - '--no-error-on-unmatched-pattern', - ...ONLY_FILE_CHECKS, - `${app}src/index.ts`, - `${app}missing.ts`, - ]) - - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - '○ sherif skipped, not selected by --only', - `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, - '✔ oxlint passed', - `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, - '✔ oxfmt passed', - '○ tsc skipped, not selected by --only', + NOT_COVERED, '✔ all checks passed (oxlint, oxfmt)', ]) expect(exitCode).toBe(0) }) - it('passes when nothing matches and unmatched patterns are allowed', async () => { - const project = create() - - const { exitCode, stdout } = await project.uncheck([ - '--no-error-on-unmatched-pattern', - `${app}missing.ts`, - ]) - - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - `○ nothing to check, no files match ${app}missing.ts`, - ]) - expect(exitCode).toBe(0) - }) - it('lists up to three files on the command line and counts more', async () => { const project = create({ [`${app}lib/a.ts`]: 'export const a = 1;\n', @@ -136,14 +124,22 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { `${app}lib/b.ts`, ]) - expect(report(three.stdout)).toContain( + expect(selectedReport(three.stdout)).toEqual([ + `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${app}lib/a.ts ${app}lib/b.ts ${app}lib/c.ts`, - ) + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) expect(three.exitCode).toBe(0) const four = await project.uncheck(['--only=oxlint', `${app}lib`]) - expect(report(four.stdout)).toContain('▶ oxlint --no-error-on-unmatched-pattern [4 files]') + expect(selectedReport(four.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ oxlint --no-error-on-unmatched-pattern [4 files]', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) expect(four.exitCode).toBe(0) }) @@ -153,7 +149,7 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { const files = Object.fromEntries( Array.from({ length: 301 }, (_, index) => [name(index), 'export const value = 1;\n']), ) - const project = create({ ...files, [name(300)]: 'var value = 1;\nexport { value };\n' }) + const project = create({ ...files, [name(300)]: CODE_WITH_VAR }) const { exitCode, stdout } = await project.uncheck([...ONLY_FILE_CHECKS, `${app}generated`]) @@ -176,7 +172,7 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { it('hands tools file names starting with - or ! as files', async () => { const project = create({ - [`${app}-draft.ts`]: 'var draft = 1;\nexport { draft };\n', + [`${app}-draft.ts`]: CODE_WITH_VAR, [`${app}!notes.ts`]: 'export const notes = 1\n', }) @@ -201,7 +197,36 @@ describe.each(LAYOUTS)('uncheck with paths in a $name', ({ create, app }) => { const fix = await project.uncheck(['--fix', ...ONLY_FILE_CHECKS, '*.ts'], { cwd: app }) expect(fix.exitCode).toBe(0) - expect(project.read(`${app}-draft.ts`)).toBe('const draft = 1;\nexport { draft };\n') + expect(project.read(`${app}-draft.ts`)).toBe('const count = 1;\nexport { count };\n') expect(project.read(`${app}!notes.ts`)).toBe('export const notes = 1;\n') }) + + it('hands oxlint and oxfmt a file above the directory it runs in as a "../" path they reject', async () => { + const project = create() + const outside = temporaryDirectory() + writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) + const handed = relative(project.dir, join(outside, 'shared.ts')) + + const { exitCode, stdout, stderr } = await project.uncheck([ + ...ONLY_FILE_CHECKS, + join(outside, 'shared.ts'), + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${handed}`, + '✘ oxlint failed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${handed}`, + '✘ oxfmt failed', + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect( + stdout + .split('\n') + .filter((line) => line === `Error: \`${handed}\`: PATH must not contain ".."`), + ).toHaveLength(2) + }) }) diff --git a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts index 976a08f..c66eb52 100644 --- a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts +++ b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts @@ -1,7 +1,6 @@ import { mkdirSync } from 'node:fs' -import { LAYOUTS, report } from '../utils/project' -import { selectedReport } from './paths-utils' +import { cliError, LAYOUTS, report } from '../utils/project' describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app }) => { it('fails before checking anything and names every path that matched no file', async () => { @@ -9,7 +8,6 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app mkdirSync(project.path(app, 'src/empty')) const { exitCode, stdout, stderr } = await project.uncheck([ - '--only=oxlint', `${app}src/index.ts`, `${app}missing.ts`, `${app}src/**/*.tsx`, @@ -20,7 +18,9 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) expect(stderr).toBe( - `\nERROR\n No files match ${app}missing.ts, ${app}src/**/*.tsx, ${app}lib/, ${app}src/empty. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + cliError( + `No files match ${app}missing.ts, ${app}src/**/*.tsx, ${app}lib/, ${app}src/empty. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), ) }) @@ -28,8 +28,9 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app const project = create() const { exitCode, stdout, stderr } = await project.uncheck([ - '--only=oxlint', '--no-error-on-unmatched-pattern', + '--only=oxlint', + '--only=oxfmt', `${app}missing.ts`, `${app}src/index.ts`, `${app}src/**/*.tsx`, @@ -37,11 +38,15 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(selectedReport(stdout)).toEqual([ + expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, '✔ oxlint passed', - '✔ all checks passed (oxlint)', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', ]) }) diff --git a/packages/uncheck/tests/uncheck/paths-utils.ts b/packages/uncheck/tests/uncheck/paths-utils.ts deleted file mode 100644 index 1c75551..0000000 --- a/packages/uncheck/tests/uncheck/paths-utils.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Files, Layout, Project, ProjectOptions } from '../utils/project' -import { report } from '../utils/project' - -export const CLEAN_CODE = 'export const home = "home";\n' - -/** Fails the `no-var` rule the projects enable in oxlint. */ -export const CODE_WITH_VAR = 'var count = 1;\nexport { count };\n' - -const LISTING_OXLINT = [ - 'for (const arg of process.argv.slice(2)) {', - " if (!arg.startsWith('--')) console.log('checked ' + arg)", - '}', - '', -].join('\n') - -/** A project whose oxlint passes and prints each file it gets, to see every file of a long list. */ -export function listingProject( - create: Layout['create'], - files?: Files, - options?: ProjectOptions, -): Project { - return create(files, { ...options, tools: [] }).fake('oxlint', LISTING_OXLINT) -} - -/** The files the oxlint of `listingProject` printed, in the order it got them. */ -export function checkedFiles(stdout: string): string[] { - return stdout - .split('\n') - .filter((line) => line.startsWith('checked ')) - .map((line) => line.slice('checked '.length)) -} - -/** The report without the checks that `--only` leaves out. */ -export function selectedReport(stdout: string): string[] { - return report(stdout).filter((line) => !line.endsWith(' skipped, not selected by --only')) -} diff --git a/packages/uncheck/tests/uncheck/paths-walk.test.ts b/packages/uncheck/tests/uncheck/paths-walk.test.ts index d50c093..f3951e9 100644 --- a/packages/uncheck/tests/uncheck/paths-walk.test.ts +++ b/packages/uncheck/tests/uncheck/paths-walk.test.ts @@ -1,9 +1,6 @@ -import { chmodSync } from 'node:fs' -import process from 'node:process' - import type { Project } from '../utils/project' -import { LAYOUTS } from '../utils/project' -import { checkedFiles, listingProject } from './paths-utils' +import { LAYOUTS, PERMISSIONS_ENFORCED } from '../utils/project' +import { checkedFiles, listingProject } from './utils' describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, app }) => { const routes = `${app}src/routes` @@ -77,15 +74,9 @@ describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, a ]) }) - // Root reads a folder whatever its mode. - it.skipIf(process.getuid?.() === 0)('walks past a folder it cannot read', async () => { - const project = walkedProject('home.ts', 'locked/secret.ts') - chmodSync(project.path(routes, 'locked'), 0) + it.runIf(PERMISSIONS_ENFORCED)('walks past a folder it cannot read', async () => { + const project = walkedProject('home.ts', 'locked/secret.ts').chmod(`${routes}/locked`, 0) - try { - expect(await check(project, routes)).toEqual([`${routes}/home.ts`]) - } finally { - chmodSync(project.path(routes, 'locked'), 0o755) - } + expect(await check(project, routes)).toEqual([`${routes}/home.ts`]) }) }) diff --git a/packages/uncheck/tests/uncheck/paths-selection.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts similarity index 53% rename from packages/uncheck/tests/uncheck/paths-selection.test.ts rename to packages/uncheck/tests/uncheck/paths.test.ts index 1afacdd..2f666a6 100644 --- a/packages/uncheck/tests/uncheck/paths-selection.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -1,33 +1,10 @@ import { writeFileSync } from 'node:fs' -import { join, relative } from 'node:path' +import { join } from 'node:path' -import { LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' -import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './paths-utils' - -const TSC_OF_LAYOUT: Readonly> = { - 'single repo': '▶ tsc -p tsconfig.json --noEmit', - 'monorepo': '▶ tsc -b tsconfig.json', -} - -describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ name, create, app }) => { - it('checks only the file it is given', async () => { - const project = create({ [`${app}src/legacy.ts`]: CODE_WITH_VAR }) - - const { exitCode, stdout, stderr } = await project.uncheck([ - '--only=oxlint', - `${app}src/index.ts`, - ]) - - expect(stderr).toBe('') - expect(exitCode).toBe(0) - expect(selectedReport(stdout)).toEqual([ - `uncheck in ${project.dir}`, - `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, - '✔ oxlint passed', - '✔ all checks passed (oxlint)', - ]) - }) +import { cliError, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './utils' +describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, app }) => { it('checks every file below a directory it is given', async () => { const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE, @@ -49,64 +26,6 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ name, cr ]) }) - it('hands oxlint and oxfmt the same files and runs tsc on the config covering them', async () => { - const project = create({ [`${app}src/extra.ts`]: CLEAN_CODE }) - - const { exitCode, stdout, stderr } = await project.uncheck([ - `${app}src/index.ts`, - `${app}src/extra.ts`, - ]) - - expect(stderr).toBe('') - expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - '○ sherif skipped, no package.json among the given files', - `▶ oxlint --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, - '✔ oxlint passed', - `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, - '✔ oxfmt passed', - TSC_OF_LAYOUT[name], - '✔ tsc passed', - '✔ all checks passed (oxlint, oxfmt, tsc)', - ]) - }) - - it('lists every file for "." but leaves the choice to each tool without paths', async () => { - const project = create({ - [`${app}src/routes/home.ts`]: CLEAN_CODE, - [`${app}src/routes/legacy.ts`]: CODE_WITH_VAR, - }) - - const dot = await project.uncheck(['--only=oxlint', '--only=oxfmt', '.'], { - cwd: `${app}src/routes`, - }) - const everything = await project.uncheck(['--only=oxlint', '--only=oxfmt'], { - cwd: `${app}src/routes`, - }) - - expect(dot.exitCode).toBe(1) - expect(selectedReport(dot.stdout)).toEqual([ - `uncheck in ${project.path(app, 'src/routes')}`, - '▶ oxlint --no-error-on-unmatched-pattern home.ts legacy.ts', - '✘ oxlint failed', - '▶ oxfmt --check --no-error-on-unmatched-pattern home.ts legacy.ts', - '✔ oxfmt passed', - '✘ 1 of 2 checks failed: oxlint', - ' rerun with `--fix` to apply oxlint fixes', - ]) - expect(everything.exitCode).toBe(1) - expect(selectedReport(everything.stdout)).toEqual([ - `uncheck in ${project.path(app, 'src/routes')}`, - '▶ oxlint', - '✘ oxlint failed', - '▶ oxfmt --check', - '✔ oxfmt passed', - '✘ 1 of 2 checks failed: oxlint', - ' rerun with `--fix` to apply oxlint fixes', - ]) - }) - it('checks a file named by several paths once', async () => { const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE, @@ -197,39 +116,11 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ name, cr expect(exitCode).toBe(1) expect(report(stdout)).toEqual([`uncheck in ${project.path(app, 'src/routes')}`]) expect(stderr).toBe( - `\nERROR\n No files match .., ../*.ts, ${outside}. Pass --no-error-on-unmatched-pattern to run with whatever matched.\n`, + cliError( + `No files match .., ../*.ts, ${outside}. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), ) }) - - it('hands oxlint and oxfmt a file above the directory it runs in as a "../" path they reject', async () => { - const project = create() - const outside = temporaryDirectory() - writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) - const handed = relative(project.dir, join(outside, 'shared.ts')) - - const { exitCode, stdout, stderr } = await project.uncheck([ - '--only=oxlint', - '--only=oxfmt', - join(outside, 'shared.ts'), - ]) - - expect(stderr).toBe('') - expect(exitCode).toBe(1) - expect(selectedReport(stdout)).toEqual([ - `uncheck in ${project.dir}`, - `▶ oxlint --no-error-on-unmatched-pattern ${handed}`, - '✘ oxlint failed', - `▶ oxfmt --check --no-error-on-unmatched-pattern ${handed}`, - '✘ oxfmt failed', - '✘ 2 of 2 checks failed: oxlint, oxfmt', - ' rerun with `--fix` to apply oxlint and oxfmt fixes', - ]) - expect( - stdout - .split('\n') - .filter((line) => line === `Error: \`${handed}\`: PATH must not contain ".."`), - ).toHaveLength(2) - }) }) describe('uncheck selecting files by path in a monorepo package', () => { diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts index b967b98..734b43d 100644 --- a/packages/uncheck/tests/uncheck/presets.test.ts +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -84,8 +84,8 @@ export function pause() { }) } -describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ create, app }) => { - const { sherif, tsc, checks } = layoutChecks(app) +describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) it('reports the problems the presets catch', async () => { const project = presetProject(create, app) diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts index c5f741d..c6873e4 100644 --- a/packages/uncheck/tests/uncheck/project.test.ts +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -1,8 +1,14 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' -import { layoutChecks, updateJson } from './utils' - -describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { - const { sherif, tsc, checks } = layoutChecks(app) +import { LAYOUTS, report } from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CODE_WITH_VAR, + layoutChecks, + monorepoWithMismatchedVersions, + UNFORMATTED_CODE, +} from './utils' + +describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) it('passes a clean project', async () => { const project = create() @@ -25,7 +31,7 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { }) it('fails on a lint error and still runs the other checks', async () => { - const project = create({ [`${app}src/legacy.ts`]: 'var count = 1;\nexport { count };\n' }) + const project = create({ [`${app}src/legacy.ts`]: CODE_WITH_VAR }) const { exitCode, stdout, stderr } = await project.uncheck() @@ -48,7 +54,7 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { }) it('fails on a formatting issue', async () => { - const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = {a:1,\n b:2}\n' }) + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) const { exitCode, stdout } = await project.uncheck() @@ -69,12 +75,13 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { }) it('fails on a type error without offering fixes', async () => { - const project = create({ [`${app}src/broken.ts`]: 'export const broken: number = "42";\n' }) + const project = create({ [`${app}src/broken.ts`]: CODE_WITH_TYPE_ERROR }) const { exitCode, stdout } = await project.uncheck() - expect(stdout).toContain(`${app}src/broken.ts`) - expect(stdout).toContain('TS2322') + expect(stdout).toContain( + `${app}src/broken.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`, + ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, @@ -111,15 +118,7 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app }) => { describe('uncheck in a monorepo', () => { it('fails on a workspace issue sherif finds', async () => { - const project = monorepo() - updateJson(project, 'packages/core/package.json', (manifest) => ({ - ...manifest, - dependencies: { zod: '^3.0.0' }, - })) - updateJson(project, 'packages/app/package.json', (manifest) => ({ - ...manifest, - dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, - })) + const project = monorepoWithMismatchedVersions() const { exitCode, stdout } = await project.uncheck() diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index c53ffb6..f07f190 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -1,11 +1,11 @@ -import { LAYOUTS, project as bareProject, report, singleRepo } from '../utils/project' -import { layoutChecks } from './utils' +import { cliError, LAYOUTS, project as bareProject, report, singleRepo } from '../utils/project' +import { layoutChecks, NOT_COVERED, SKIPPED_BESIDE_TSC, UNFORMATTED_CODE } from './utils' -describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) => { - const { sherif, tsc, checks } = layoutChecks(app) +describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) it('runs only the checks named with --only', async () => { - const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = 1\n' }) + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) @@ -23,7 +23,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) = }) it('skips the checks named with --skip', async () => { - const project = create({ [`${app}src/ugly.ts`]: 'export const ugly = 1\n' }) + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) const { exitCode, stdout } = await project.uncheck(['--skip=oxfmt', '--skip=tsc']) @@ -78,7 +78,16 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) = '--require=oxlint', ]) - expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxlint, oxfmt)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) expect(exitCode).toBe(0) }) @@ -110,10 +119,8 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) = expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, - '○ sherif skipped, not selected by --only', - '○ oxlint skipped, not selected by --only', - '○ oxfmt skipped, not selected by --only', - '○ tsc skipped, no tsconfig.json covers the given files', + ...SKIPPED_BESIDE_TSC, + NOT_COVERED, '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', ]) expect(exitCode).toBe(1) @@ -128,9 +135,12 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app }) = `${app}README.md`, ]) - expect(report(stdout).at(-1)).toBe( + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + NOT_COVERED, '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', - ) + ]) expect(exitCode).toBe(0) }) }) @@ -174,17 +184,20 @@ describe('uncheck check selection', () => { const { exitCode, stdout, stderr } = await project.uncheck(flags) expect(stdout).toBe('') - expect(stderr).toBe(`\nERROR\n ${message}\n`) + expect(stderr).toBe(cliError(message)) expect(exitCode).toBe(1) }) it('refuses a check name it does not know', async () => { const project = singleRepo() - const { exitCode, stderr } = await project.uncheck(['--only=eslint']) + const { exitCode, stdout, stderr } = await project.uncheck(['--only=eslint']) + expect(stdout).toContain('USAGE') expect(stderr).toBe( - '\nERROR\n Invalid value for flag --only: "eslint". Expected: "sherif" | "oxlint" | "oxfmt" | "tsc"\n', + cliError( + 'Invalid value for flag --only: "eslint". Expected: "sherif" | "oxlint" | "oxfmt" | "tsc"', + ), ) expect(exitCode).toBe(1) }) diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts index 0c7cca6..af4fb87 100644 --- a/packages/uncheck/tests/uncheck/sherif.test.ts +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -1,5 +1,4 @@ import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' -import { updateJson } from './utils' const OTHERS_NOT_SELECTED = [ '○ oxlint skipped, not selected by --only', @@ -130,11 +129,11 @@ describe('uncheck sherif in a monorepo', () => { it('finds a workspace declared in the workspaces field of package.json', async () => { const project = monorepo({ 'pnpm-workspace.yaml': null }) - updateJson(project, 'package.json', (manifest) => ({ ...manifest, workspaces: ['packages/*'] })) - updateJson(project, 'packages/app/package.json', (manifest) => ({ - ...manifest, - dependencies: { 'zod': '^3.0.0', '@repo/core': 'workspace:*' }, - })) + .update('package.json', (manifest) => ({ ...manifest, workspaces: ['packages/*'] })) + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { 'zod': '^3.0.0', '@repo/core': 'workspace:*' }, + })) const { exitCode, stdout } = await project.uncheck(['--only=sherif']) diff --git a/packages/uncheck/tests/uncheck/install.test.ts b/packages/uncheck/tests/uncheck/tools.test.ts similarity index 97% rename from packages/uncheck/tests/uncheck/install.test.ts rename to packages/uncheck/tests/uncheck/tools.test.ts index abaca2e..b81c8fa 100644 --- a/packages/uncheck/tests/uncheck/install.test.ts +++ b/packages/uncheck/tests/uncheck/tools.test.ts @@ -17,8 +17,8 @@ Module._resolveFilename = function (request, ...rest) { }; ` -describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app }) => { - const { sherif, tsc, checks } = layoutChecks(app) +describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) it('skips oxlint and oxfmt when they are not installed', async () => { const project = create({}, { tools: ['sherif', 'typescript'] }) @@ -107,7 +107,7 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app }) => it('finds a tool through the resolver of Yarn PnP', async () => { const project = create( { - '.gitignore': 'node_modules\ndist\n.yarn\n.pnp.cjs\n', + '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n.yarn\n.pnp.cjs\n', '.pnp.cjs': YARN_PNP_RESOLVER, '.yarn/unplugged/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, '.yarn/unplugged/oxlint/bin.js': "console.log('oxlint from the Yarn cache');\n", diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts index 3a9be12..d9d8781 100644 --- a/packages/uncheck/tests/uncheck/tsc-extends.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -1,5 +1,5 @@ import { LAYOUTS, monorepo } from '../utils/project' -import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './tsc.utils' +import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './utils' const NO_JS = { compilerOptions: { allowJs: false } } diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts index 596075e..5bc1974 100644 --- a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -1,15 +1,12 @@ -import { writeFileSync } from 'node:fs' -import { delimiter, join, resolve } from 'node:path' - -import { environment, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { LAYOUTS, monorepo, report, wrappedGit } from '../utils/project' import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, - SKIPPED_BY_ONLY, + SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc, -} from './tsc.utils' +} from './utils' describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { it('takes files, include and exclude from the last config setting them, relative to it', async () => { @@ -180,24 +177,17 @@ describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { [`${app}web/src/index.ts`]: '', [`${app}web/data.json`]: '{}\n', }) - const gitDeletingDataJson = temporaryDirectory() - - writeFileSync( - join(gitDeletingDataJson, 'git'), - `#!/bin/sh\n[ "$1" = ls-files ] && rm -f web/data.json\nPATH=\${PATH#*:} exec git "$@"\n`, - { mode: 0o755 }, - ) const { exitCode, stdout } = await project.uncheck( ['--only=tsc', 'web/src/index.ts', 'web/data.json'], - { cwd: app, env: { PATH: `${gitDeletingDataJson}${delimiter}${environment().PATH}` } }, + { cwd: app, env: wrappedGit('[ "$1" = ls-files ] && rm -f web/data.json') }, ) expect(project.exists(`${app}web/data.json`)).toBe(false) expect(exitCode).toBe(0) expect(report(stdout)).toEqual([ - `uncheck in ${resolve(project.dir, app)}`, - ...SKIPPED_BY_ONLY, + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, '▶ tsc -p web/tsconfig.json --noEmit', '✔ tsc passed', '✔ all checks passed (tsc)', diff --git a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts index 1998074..f790e48 100644 --- a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts @@ -1,5 +1,5 @@ import { LAYOUTS } from '../utils/project' -import { ALLOW_JS, NOT_COVERED, tscPlan, withFakeTsc } from './tsc.utils' +import { ALLOW_JS, NOT_COVERED, tscPlan, withFakeTsc } from './utils' describe.each(LAYOUTS)('tsc include and exclude patterns in a $name', ({ create, app }) => { it('reads a pattern without extension or wildcard as a folder', async () => { diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index cd648e4..1c83fba 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -1,5 +1,5 @@ import { LAYOUTS, monorepo, report } from '../utils/project' -import { CONFIG_DIR, NOT_COVERED, SKIPPED_BY_ONLY, tscPlan, withFakeTsc } from './tsc.utils' +import { CONFIG_DIR, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => { it('builds a solution-style tsconfig.json whose references have other names', async () => { @@ -173,14 +173,6 @@ describe('tsc project references across the packages of a monorepo', () => { ) }) - it('builds the solution tsconfig.json at the top for a change in any package', async () => { - const project = withFakeTsc(monorepo) - - expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ - '▶ tsc -b tsconfig.json', - ]) - }) - it('builds a package from its folder for a change in a package it references', async () => { const project = withFakeTsc(monorepo) @@ -197,7 +189,7 @@ describe('tsc project references across the packages of a monorepo', () => { expect(exitCode).toBe(0) expect(report(stdout)).toEqual([ `uncheck in ${project.path('packages/core')}`, - ...SKIPPED_BY_ONLY, + ...SKIPPED_BESIDE_TSC, '▶ tsc -p tsconfig.json --noEmit', '✔ tsc passed', '✔ all checks passed (tsc)', diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 642c40d..9679ca3 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,8 +1,5 @@ -import { chmodSync } from 'node:fs' -import { resolve } from 'node:path' - -import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' -import { ALLOW_JS, NOT_COVERED, SKIPPED_BY_ONLY, tscPlan, withFakeTsc } from './tsc.utils' +import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, singleRepo } from '../utils/project' +import { ALLOW_JS, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { it('skips the check when the project has no tsconfig.json', async () => { @@ -12,8 +9,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(exitCode).toBe(1) expect(report(stdout)).toEqual([ - `uncheck in ${resolve(project.dir, app)}`, - ...SKIPPED_BY_ONLY, + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, '○ tsc skipped, no tsconfig.json found', '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json found', ]) @@ -40,8 +37,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(exitCode).toBe(1) expect(report(stdout)).toEqual([ - `uncheck in ${resolve(project.dir, app)}`, - ...SKIPPED_BY_ONLY, + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, '✘ tsc found 2 tsconfig.json but typescript is not installed', '✘ 1 of 1 checks failed: tsc', ]) @@ -59,8 +56,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(exitCode).toBe(1) expect(report(stdout)).toEqual([ - `uncheck in ${resolve(project.dir, app)}`, - ...SKIPPED_BY_ONLY, + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, '▶ tsc -p tsconfig.json --noEmit', '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', @@ -68,13 +65,10 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(stdout).toContain("tsconfig.json(1,1): error TS1005: '{' expected.") }) - // chmod cannot stop root from reading a file. - it.skipIf(process.getuid?.() === 0)( + it.runIf(PERMISSIONS_ENFORCED)( 'hands an unreadable tsconfig.json to tsc, which reports it', async () => { - const project = create() - - chmodSync(project.path(app, 'tsconfig.json'), 0) + const project = create().chmod(`${app}tsconfig.json`, 0) const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'src/index.ts'], { cwd: app, @@ -82,8 +76,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(exitCode).toBe(1) expect(report(stdout)).toEqual([ - `uncheck in ${resolve(project.dir, app)}`, - ...SKIPPED_BY_ONLY, + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, '▶ tsc -p tsconfig.json --noEmit', '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', @@ -107,7 +101,6 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { { git: 'none' }, ) - expect(project.exists('.git')).toBe(false) expect(await tscPlan(project, app)).toEqual([ '▶ tsc -b tsconfig.json', '▶ tsc -p scripts/tsconfig.json --noEmit', @@ -125,36 +118,17 @@ describe('tsc with the real compiler in a single repo', () => { }, }) - const passed = await project.uncheck(['--only=tsc']) + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) - expect(passed.exitCode).toBe(0) - expect(report(passed.stdout)).toEqual([ + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, - ...SKIPPED_BY_ONLY, + ...SKIPPED_BESIDE_TSC, '▶ tsc -p tsconfig.json --noEmit', '✔ tsc passed', '✔ all checks passed (tsc)', ]) expect(project.exists('src/index.js')).toBe(false) - - project.write({ - 'src/utils.ts': - 'export function double(value: number): number {\n return String(value);\n}\n', - }) - - const failed = await project.uncheck(['--only=tsc']) - - expect(failed.exitCode).toBe(1) - expect(report(failed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BY_ONLY, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) - expect(failed.stdout).toContain( - "src/utils.ts(2,3): error TS2322: Type 'string' is not assignable to type 'number'.", - ) }) }) @@ -167,7 +141,7 @@ describe('tsc with the real compiler in a monorepo', () => { expect(passed.exitCode).toBe(0) expect(report(passed.stdout)).toEqual([ `uncheck in ${project.dir}`, - ...SKIPPED_BY_ONLY, + ...SKIPPED_BESIDE_TSC, '▶ tsc -b tsconfig.json', '✔ tsc passed', '✔ all checks passed (tsc)', @@ -184,7 +158,7 @@ describe('tsc with the real compiler in a monorepo', () => { expect(failed.exitCode).toBe(1) expect(report(failed.stdout)).toEqual([ `uncheck in ${project.dir}`, - ...SKIPPED_BY_ONLY, + ...SKIPPED_BESIDE_TSC, '▶ tsc -b tsconfig.json', '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', diff --git a/packages/uncheck/tests/uncheck/tsc.utils.ts b/packages/uncheck/tests/uncheck/tsc.utils.ts deleted file mode 100644 index c4e55a5..0000000 --- a/packages/uncheck/tests/uncheck/tsc.utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { join } from 'node:path' - -import type { Files, Layout, Project, ProjectOptions } from '../utils/project' -import { report } from '../utils/project' - -export const NOT_COVERED = '○ tsc skipped, no tsconfig.json covers the given files' - -export const SKIPPED_BY_ONLY = [ - '○ sherif skipped, not selected by --only', - '○ oxlint skipped, not selected by --only', - '○ oxfmt skipped, not selected by --only', -] - -// oxlint-disable-next-line no-template-curly-in-string -export const CONFIG_DIR = '${configDir}' - -export const ALLOW_JS = { compilerOptions: { allowJs: true } } - -export function withFakeTsc( - create: Layout['create'], - files?: Files, - options?: ProjectOptions, -): Project { - return create(files, { ...options, tools: [] }).fake('typescript', '') -} - -export async function tscPlan( - project: Project, - cwd: string, - paths: ReadonlyArray = [], -): Promise { - const missing = paths.map((file) => join(cwd, file)).filter((file) => !project.exists(file)) - - project.write(Object.fromEntries(missing.map((file) => [file, '']))) - - const { exitCode, stdout } = await project.uncheck(['--only=tsc', ...paths], { cwd }) - const plan = report(stdout).filter((line) => /^[▶○✘] tsc /.test(line)) - - expect(exitCode).toBe(plan.length > 0 && plan.every((line) => line.startsWith('▶')) ? 0 : 1) - - return plan -} diff --git a/packages/uncheck/tests/uncheck/utils.ts b/packages/uncheck/tests/uncheck/utils.ts index a4aa882..5580887 100644 --- a/packages/uncheck/tests/uncheck/utils.ts +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -1,31 +1,109 @@ -import type { Project } from '../utils/project' +import { join } from 'node:path' + +import type { Files, Layout, Project, ProjectOptions } from '../utils/project' +import { monorepo, report } from '../utils/project' + +export const CLEAN_CODE = 'export const home = "home";\n' + +/** Fails the `no-var` rule the projects enable in oxlint. */ +export const CODE_WITH_VAR = 'var count = 1;\nexport { count };\n' + +export const UNFORMATTED_CODE = 'export const ugly = {a:1,\n b:2}\n' + +export const CODE_WITH_TYPE_ERROR = 'export const broken: number = "42";\n' export interface LayoutChecks { readonly sherif: ReadonlyArray - readonly tsc: string readonly checks: ReadonlyArray } export function layoutChecks(app: string, { fix = false } = {}): LayoutChecks { return app === '' - ? { - sherif: ['○ sherif skipped, not a workspace root'], - tsc: '▶ tsc -p tsconfig.json --noEmit', - checks: ['oxlint', 'oxfmt', 'tsc'], - } + ? { sherif: ['○ sherif skipped, not a workspace root'], checks: ['oxlint', 'oxfmt', 'tsc'] } : { sherif: [fix ? '▶ sherif --fix --select=highest' : '▶ sherif', '✔ sherif passed'], - tsc: '▶ tsc -b tsconfig.json', checks: ['sherif', 'oxlint', 'oxfmt', 'tsc'], } } -export function updateJson( - project: Project, - file: string, - update: (value: Record) => object, +export function monorepoWithMismatchedVersions(sherif: object = {}): Project { + // Without noInstall, sherif --fix runs the package manager to install the versions it aligned. + return monorepo() + .update('package.json', (manifest) => ({ ...manifest, sherif: { noInstall: true, ...sherif } })) + .update('packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) +} + +const LISTING_OXLINT = [ + 'for (const arg of process.argv.slice(2)) {', + " if (!arg.startsWith('--')) console.log('checked ' + arg)", + '}', + '', +].join('\n') + +/** A project whose oxlint passes and prints each file it gets, to see every file of a long list. */ +export function listingProject( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, +): Project { + return create(files, { ...options, tools: [] }).fake('oxlint', LISTING_OXLINT) +} + +/** The files the oxlint of `listingProject` printed, in the order it got them. */ +export function checkedFiles(stdout: string): string[] { + return stdout + .split('\n') + .filter((line) => line.startsWith('checked ')) + .map((line) => line.slice('checked '.length)) +} + +/** The report without the checks that `--only` leaves out. */ +export function selectedReport(stdout: string): string[] { + return report(stdout).filter((line) => !line.endsWith(' skipped, not selected by --only')) +} + +export const NOT_COVERED = '○ tsc skipped, no tsconfig.json covers the given files' + +export const SKIPPED_BESIDE_TSC = [ + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', +] + +// oxlint-disable-next-line no-template-curly-in-string +export const CONFIG_DIR = '${configDir}' + +export const ALLOW_JS = { compilerOptions: { allowJs: true } } + +export function withFakeTsc( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, ): Project { - return project.write({ - [file]: update(JSON.parse(project.read(file)) as Record), - }) + return create(files, { ...options, tools: [] }).fake('typescript', '') +} + +/** The tsc lines of a `--only=tsc` run on `paths`, which it first creates when missing. */ +export async function tscPlan( + project: Project, + cwd: string, + paths: ReadonlyArray = [], +): Promise { + const missing = paths.map((file) => join(cwd, file)).filter((file) => !project.exists(file)) + + project.write(Object.fromEntries(missing.map((file) => [file, '']))) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', ...paths], { cwd }) + const plan = report(stdout).filter((line) => /^[▶○✘] tsc /.test(line)) + + expect(exitCode).toBe(plan.length > 0 && plan.every((line) => line.startsWith('▶')) ? 0 : 1) + + return plan } From 3d94946ca946f544df104b1190124408891a3ec7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 07:07:59 +0700 Subject: [PATCH 6/9] wip: make an empty file list produce no batch --- packages/uncheck/src/commands/staged.ts | 2 +- packages/uncheck/src/tool.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index e940c76..dac2182 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -274,7 +274,7 @@ interface SetAside { readonly base: string } -/** Runs `git -- ` in batches. Without files, `reset` would reset every path. */ +/** Runs `git -- ` in batches, and nothing without files. */ function gitEach( cwd: string, args: ReadonlyArray, diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index 2b5faf6..d035af0 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -54,13 +54,14 @@ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName /** Windows caps a whole command line, node and the tool path included, at 32,767 characters. */ const MAX_ARGV_LENGTH = 30_000 +/** No arguments make no batch: a tool or git command run without paths acts on every file. */ export function argvBatches(args: ReadonlyArray): ReadonlyArray> { - const batches: string[][] = [[]] + const batches: string[][] = [] let length = 0 for (const arg of args) { // Room for the separator and the quotes around an argument with a space. - if (length + arg.length + 3 > MAX_ARGV_LENGTH) { + if (batches.length === 0 || length + arg.length + 3 > MAX_ARGV_LENGTH) { batches.push([]) length = 0 } From 401457df1e4f26d9535e5ba8523e86bf3538df4e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 09:10:44 +0700 Subject: [PATCH 7/9] fix(uncheck): match tsc include patterns in either case, keep tests machine-independent Include patterns now match the way tsc does on case-sensitive and case-insensitive file systems alike, so a Node_Modules folder or a .Min.js file tsc checks on Linux still selects its project. The e2e harness drops GITHUB_ACTIONS, under which oxlint prints annotations instead of its report, and two assertions no longer depend on the git or Node version. --- packages/uncheck/src/checks/tsc.ts | 29 ++++++++++++------- .../uncheck/tests/prepare/failures.test.ts | 3 +- packages/uncheck/tests/staged/fix.test.ts | 15 +++------- .../tests/uncheck/tsc-patterns.test.ts | 13 +++++++++ packages/uncheck/tests/utils/project.ts | 6 +++- 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 3304102..e82ce7e 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -239,7 +239,7 @@ const readReferences = Effect.fn(function* (configPath: string) { interface InputPattern { readonly spec: string - readonly regex: RegExp + readonly matches: (file: string) => boolean } interface TsconfigInputs { @@ -325,10 +325,10 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { configs: chain.map(({ file }) => file), files: resolve(files), include: includeSpecs.flatMap((spec) => { - const regex = compileGlob(spec, 'files') - return regex === undefined ? [] : [{ spec, regex }] + const matches = compileGlob(spec, 'files') + return matches === undefined ? [] : [{ spec, matches }] }), - exclude: excludeSpecs.map((spec) => ({ spec, regex: compileGlob(spec, 'exclude')! })), + exclude: excludeSpecs.map((spec) => ({ spec, matches: compileGlob(spec, 'exclude')! })), extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), } }) @@ -347,13 +347,13 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean { return false } - if (inputs.exclude.some((pattern) => pattern.regex.test(target))) { + if (inputs.exclude.some((pattern) => pattern.matches(target))) { return false } // JSON files only come in through an `include` that names the extension explicitly. return inputs.include.some( - (pattern) => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json')), + (pattern) => pattern.matches(target) && (!json || pattern.spec.endsWith('.json')), ) } @@ -468,7 +468,10 @@ const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' * In `include`, `*` never matches a name ending in `.min.js`; `exclude` patterns also match every * path below them. */ -function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { +function compileGlob( + pattern: string, + usage: 'files' | 'exclude', +): ((file: string) => boolean) | undefined { const components = pattern.replace(/\/+$/, '').split('/') const last = components[components.length - 1]! @@ -497,9 +500,15 @@ function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | unde written = true } - // tsc ignores case on file systems that do, so `include` ignores it and `exclude` does not: on any - // file system that selects every project tsc would check, and at worst one more. - return usage === 'files' ? new RegExp(`^${source}$`, 'i') : new RegExp(`^${source}(?:$|/)`) + // tsc ignores case on file systems that do, even in the lookaheads that keep node_modules and .min.js + // out, so `include` matches the way tsc does on either kind and `exclude` keeps case: on any file + // system that selects every project tsc would check, and at worst one more. + const regexes = + usage === 'files' + ? [new RegExp(`^${source}$`), new RegExp(`^${source}$`, 'i')] + : [new RegExp(`^${source}(?:$|/)`)] + + return (file) => regexes.some((regex) => regex.test(file)) } function filesComponent(component: string): string { diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index d8e458d..106e446 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -67,7 +67,8 @@ describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create const { exitCode, stdout } = await prepare(project, [], { cwd: app }) expect(exitCode).toBe(0) - expect(stdout).toBe( + // Node 26 adds the path to the message. + expect(stdout.replace(` '${project.path(HOOK)}'`, '')).toBe( notWritten(shownHook(project, app), 'EISDIR: illegal operation on a directory, read'), ) expect(leftoverLocksAndCopies(project)).toEqual([]) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index be49d40..445fdef 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -98,18 +98,11 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) { cwd: folder }, ) - expect(project.normalize(stderr)).toBe( + // Each git version words the advice that follows the first line of its message differently. + expect(project.normalize(stderr)).toContain( cliError( - [ - "git update-index [1 paths] failed: fatal: Unable to create '/.git/index.lock': File exists.", - '', - 'Another git process seems to be running in this repository, e.g.', - "an editor opened by 'git commit'. Please make sure all processes", - 'are terminated then try again. If it still fails, a git process', - 'may have crashed in this repository earlier:', - 'remove the file manually to continue.', - ].join('\n'), - ), + "git update-index [1 paths] failed: fatal: Unable to create '/.git/index.lock': File exists.", + ).trimEnd(), ) expect(exitCode).toBe(1) expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') diff --git a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts index f790e48..2ad2c86 100644 --- a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts @@ -103,6 +103,19 @@ describe.each(LAYOUTS)('tsc include and exclude patterns in a $name', ({ create, ]) }) + it('keeps out only node_modules and .min.js spelled in their own case, as tsc does where case matters', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'], ...ALLOW_JS }, + }) + + expect( + await tscPlan(project, app, ['web/src/Node_Modules/index.ts', 'web/src/vendor.Min.js']), + ).toEqual(['▶ tsc -p web/tsconfig.json --noEmit']) + expect( + await tscPlan(project, app, ['web/src/node_modules/index.ts', 'web/src/vendor.min.js']), + ).toEqual([NOT_COVERED]) + }) + it('ignores case in include but not in exclude', async () => { const project = withFakeTsc(create, { [`${app}include/tsconfig.json`]: { include: ['SRC'] }, diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 42eb77f..719f805 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -140,8 +140,12 @@ export function temporaryDirectory(): string { /** The environment of every process the tests start, free of whatever the machine or a git hook set. */ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { + // Under GitHub Actions, oxlint prints annotations in place of its report. const inherited = Object.entries(process.env).filter( - ([key]) => !/^(?:GIT_\w+|CI|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS|NODE_OPTIONS)$/.test(key), + ([key]) => + !/^(?:GIT_\w+|CI|GITHUB_ACTIONS|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS|NODE_OPTIONS)$/.test( + key, + ), ) return { From f480c194fdd0a301e26ebc94b2cc41700cec86ca Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 09:26:24 +0700 Subject: [PATCH 8/9] test(uncheck): pass only HOME, TMPDIR and coverage settings to test processes oxlint and oxfmt change their output for the AI agents they detect, which made two assertions pass inside an agent and fail on CI. The harness now builds the whole environment of the processes it starts instead of filtering the machine's one. --- packages/uncheck/tests/uncheck/paths-git.test.ts | 4 ++-- packages/uncheck/tests/uncheck/paths.test.ts | 2 +- packages/uncheck/tests/utils/project.ts | 16 ++++++---------- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index a710599..506a1f0 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -102,8 +102,8 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) expect(exitCode).toBe(1) - expect(stdout).toContain(`${routes}/linked/util.ts:1:1: error eslint(no-var)`) - expect(stdout).toContain(`${routes}/shared/util.ts:1:1: error eslint(no-var)`) + expect(stdout).toContain(`,-[${routes}/linked/util.ts:1:1]`) + expect(stdout).toContain(`,-[${routes}/shared/util.ts:1:1]`) expect(selectedReport(stdout)).toEqual([ `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/linked ${routes}/shared/util.ts`, diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index 2f666a6..d5f467a 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -15,7 +15,7 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, const { exitCode, stdout } = await project.uncheck(['--only=oxlint', `${app}src/routes/`]) expect(exitCode).toBe(1) - expect(stdout).toContain(`${app}src/routes/nested/legacy.ts:1:1: error eslint(no-var)`) + expect(stdout).toContain(`,-[${app}src/routes/nested/legacy.ts:1:1]`) expect(stdout).not.toContain('scripts/legacy.ts') expect(selectedReport(stdout)).toEqual([ `uncheck in ${project.dir}`, diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 719f805..28441b3 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -138,18 +138,14 @@ export function temporaryDirectory(): string { return mkdtempSync(join(ROOT, 'tmp-')) } -/** The environment of every process the tests start, free of whatever the machine or a git hook set. */ -export function environment(overrides: Env = {}): NodeJS.ProcessEnv { - // Under GitHub Actions, oxlint prints annotations in place of its report. - const inherited = Object.entries(process.env).filter( - ([key]) => - !/^(?:GIT_\w+|CI|GITHUB_ACTIONS|FORCE_COLOR|NO_COLOR|NODE_DISABLE_COLORS|NODE_OPTIONS)$/.test( - key, - ), - ) +// Tools change their output on CI, under GitHub Actions and in the AI agents they detect, so nothing +// else of the machine's environment reaches the processes the tests start. +const INHERITED = new Set(['HOME', 'TMPDIR', 'NODE_V8_COVERAGE']) +/** The environment of every process the tests start, the same on every machine. */ +export function environment(overrides: Env = {}): NodeJS.ProcessEnv { return { - ...Object.fromEntries(inherited), + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => INHERITED.has(key))), PATH: `${SHIMS}${delimiter}${process.env.PATH}`, // git translates the messages tests assert to the language of the machine. LC_ALL: 'C', From 3de71d5b8a8b95c195ff2124fa73a5ee54c59c2d Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 09:33:40 +0700 Subject: [PATCH 9/9] test(uncheck): assert oxlint diagnostics by position and rule, not report layout --- packages/uncheck/tests/uncheck/paths-git.test.ts | 5 +++-- packages/uncheck/tests/uncheck/paths.test.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index 506a1f0..f8628a8 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -102,8 +102,9 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) expect(exitCode).toBe(1) - expect(stdout).toContain(`,-[${routes}/linked/util.ts:1:1]`) - expect(stdout).toContain(`,-[${routes}/shared/util.ts:1:1]`) + expect(stdout).toContain(`${routes}/linked/util.ts:1:1`) + expect(stdout).toContain(`${routes}/shared/util.ts:1:1`) + expect(stdout).toContain('eslint(no-var)') expect(selectedReport(stdout)).toEqual([ `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/linked ${routes}/shared/util.ts`, diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index d5f467a..2d4c1da 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -15,7 +15,8 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, const { exitCode, stdout } = await project.uncheck(['--only=oxlint', `${app}src/routes/`]) expect(exitCode).toBe(1) - expect(stdout).toContain(`,-[${app}src/routes/nested/legacy.ts:1:1]`) + expect(stdout).toContain(`${app}src/routes/nested/legacy.ts:1:1`) + expect(stdout).toContain('eslint(no-var)') expect(stdout).not.toContain('scripts/legacy.ts') expect(selectedReport(stdout)).toEqual([ `uncheck in ${project.dir}`,