diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdc1b4f7..18d31d06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,4 @@ jobs: - run: npm run lint - run: npm run type-check - run: npm run build - - name: Link CLI for E2E tests - run: npm link - run: npm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ca213b..b8a5d7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **docs**: All install instructions now target `@deepl/cli` (10 occurrences across `docs/SYNC.md`, four example scripts, `examples/README.md`, and the git-hook template in `src/services/git-hooks.ts`). The README installation section documents three install paths — Homebrew (`brew install deepl/tap/deepl`, which manages Node itself), npm (`npm install -g @deepl/cli`), and from source — with an explicit Node.js 24 prerequisite, replacing the `better-sqlite3` native-compilation caveat (Xcode CLT / python3-make-gcc), which no longer applies. The `TROUBLESHOOTING.md` `NODE_MODULE_VERSION` / `npm rebuild better-sqlite3` entry is replaced by accurate guidance for the one remaining cache-degradation cause (running on Node < 24). Six stale `DeepLcom` GitHub URLs now point at the `DeepL` org directly. `CONTRIBUTING.md` states the Node 24 development prerequisite, and `SECURITY.md`'s supported-versions table reflects that only 2.x is a published, supported line. - **tests**: Removed the global manual mocks for `p-limit` and `fast-glob` (`tests/__mocks__/`). Because a manual mock for a node module is auto-applied to every suite, and `resetMocks: true` strips its implementation, `pLimit(n)` resolved to `undefined` and `fg(...)` resolved to `undefined` in all 236 suites — so no test exercised a real concurrency limit or a real glob walk, and two concurrency defects reached a release candidate undetected. The suites that need these mocked declare them explicitly with their own implementations, so nothing depended on the global versions. Added `tests/unit/concurrency-limiting.test.ts`, which asserts that `p-limit` rejects a non-positive concurrency, that peak overlap never exceeds the limit, and that `fast-glob` returns real paths — it fails if either global mock is reintroduced. +- **tests**: Suites that shell out to the bare `deepl` command now run this tree's built CLI via a PATH shim installed in jest `globalSetup`, instead of whatever `deepl` happens to be globally installed. 23 suites (392 tests) previously required a global install — absent one they all failed with `command not found`, and present one they silently tested the installed version rather than the working tree. The shim lives in the real environment before jest workers spawn (a `setupFilesAfterEnv` hook cannot do this: test code sees a copied `process.env` that child processes never inherit). CI's now-redundant `npm link` step is removed, and a new unit suite pins that `deepl` resolves to the shim and reports the tree's version. - **tests**: `npm test` now fails fast with an actionable message when `dist/cli/index.js` is missing. `dist/` is gitignored and `npm test` does not build, yet all three test tiers execute the built CLI, so a fresh checkout previously failed hundreds of tests with errors that never mentioned the missing build. - **tests**: Jest's haste map no longer indexes `.claude/` (via `modulePathIgnorePatterns`). A leftover agent worktree under `.claude/worktrees/` duplicated the manual mocks in `tests/__mocks__/` and made every jest run emit a `jest-haste-map: duplicate manual mock found` warning; test files in worktrees were already excluded, but module indexing was not. Verified warning-free with a worktree present. - **BREAKING — package**: The package is now published as the scoped **`@deepl/cli`** (previously the unpublished working name `deepl-cli`). Scoped packages default to restricted visibility, so `publishConfig.access: "public"` is set explicitly — without it the publish fails. The `bin` name is unchanged: the command is still `deepl`, and scoping changes only the install string (`npm install -g @deepl/cli`). Repository, bugs, and homepage metadata now point at `github.com/DeepL/deepl-cli` directly instead of relying on the redirect from the legacy `DeepLcom` org name. diff --git a/jest.config.js b/jest.config.js index 44601fb8..8a2c83ff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -82,6 +82,7 @@ export default { ], // Setup files + globalSetup: '/tests/global-setup.ts', setupFilesAfterEnv: ['/tests/setup.ts'], // Test timeout diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 00000000..9815a0a8 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,12 @@ +import installHermeticDeepl from './hermetic-deepl'; + +/** + * Runs in the main jest process before workers spawn, so the shim's PATH entry + * lands in the real environment every worker (and every CLI subprocess the + * tests spawn) inherits. A setupFilesAfterEnv hook cannot do this: test code + * sees a copied process.env, and mutations there never reach child processes + * spawned without an explicit env. + */ +export default function globalSetup(): void { + installHermeticDeepl(); +} diff --git a/tests/hermetic-deepl.ts b/tests/hermetic-deepl.ts new file mode 100644 index 00000000..a54b74ec --- /dev/null +++ b/tests/hermetic-deepl.ts @@ -0,0 +1,27 @@ +/** + * Puts a `deepl` shim on PATH that execs this repo's built CLI, so tests that + * shell out to the bare `deepl` command always run dist/cli/index.js — never a + * globally installed copy, which may be absent (failing every such test with + * "command not found") or a different version than the tree under test. + * + * The shim directory is created once per jest worker process and advertised + * through DEEPL_CLI_TEST_SHIM so subsequent suites in the same worker reuse it. + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const CLI_ENTRY = path.join(process.cwd(), 'dist', 'cli', 'index.js'); + +export default function installHermeticDeepl(): void { + const existing = process.env['DEEPL_CLI_TEST_SHIM']; + if (existing && fs.existsSync(path.join(existing, 'deepl'))) return; + + const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deepl-cli-shim-')); + fs.writeFileSync(path.join(shimDir, 'deepl'), `#!/bin/sh\nexec node "${CLI_ENTRY}" "$@"\n`, { + mode: 0o755, + }); + process.env['DEEPL_CLI_TEST_SHIM'] = shimDir; + process.env['PATH'] = `${shimDir}${path.delimiter}${process.env['PATH'] ?? ''}`; +} diff --git a/tests/unit/hermetic-deepl.test.ts b/tests/unit/hermetic-deepl.test.ts new file mode 100644 index 00000000..d7002a7b --- /dev/null +++ b/tests/unit/hermetic-deepl.test.ts @@ -0,0 +1,29 @@ +/** + * Tests that the bare `deepl` command resolves to the test shim and executes + * this tree's built CLI, so suites that shell out to `deepl` are independent + * of any globally installed copy. + */ + +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('hermetic deepl shim', () => { + it('resolves `deepl` to the shim directory, ahead of any global install', () => { + const resolved = execSync('command -v deepl', { + encoding: 'utf-8', + shell: '/bin/sh', + }).trim(); + + expect(resolved).toBe(path.join(process.env['DEEPL_CLI_TEST_SHIM']!, 'deepl')); + }); + + it('executes the built CLI from this tree', () => { + const version = execSync('deepl --version', { encoding: 'utf-8' }).trim(); + const pkg = JSON.parse( + fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'), + ) as { version: string }; + + expect(version).toBe(pkg.version); + }); +});