@vitronai/themis 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +13 -4
- package/docs/api.md +4 -3
- package/docs/migration.md +34 -6
- package/docs/schemas/migration-report.v1.json +5 -1
- package/package.json +3 -1
- package/src/cli.js +9 -5
- package/src/config.js +1 -1
- package/src/expect.js +18 -0
- package/src/migrate.js +389 -12
- package/src/module-loader.js +25 -2
- package/src/process-child.js +25 -0
- package/src/runner.js +112 -2
- package/src/runtime.js +3 -3
- package/templates/AGENTS.themis.md +4 -2
- package/templates/CLAUDE.themis.md +8 -5
- package/templates/claude-commands/themis-migrate.md +15 -4
- package/templates/claude-skill/SKILL.md +6 -2
- package/templates/cursorrules.themis.md +2 -0
- package/themis.ai.json +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 1.3.0 - 2026-05-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`themis migrate node` codemod.** New third migration source converts test files written with `node:test` + `node:assert/strict` (default-import or named-import shapes) to Themis-native primitives. Handles `assert.equal`/`strictEqual` → `expect().toBe()`, `deepEqual`/`deepStrictEqual` → `toEqual()`, `ok` → `toBeTruthy()`, `match(str, /re/)` → `toMatch()`, `rejects(...)` → async try/catch wrapper, and `test.after`/`test.afterEach` → `afterAll`/`afterEach`. Also strips the optional 3rd-arg options object from `test(name, options, fn)` calls. Uses a balanced-paren scanner with regex-literal awareness so escaped slashes inside regex args don't trip the parser.
|
|
12
|
+
- **`toMatch(string | RegExp)` matcher** in core `expect`. Jest-compatible. Matches strings against a substring or regex; throws if `received` is not a string.
|
|
13
|
+
- **`--isolation process` mode.** Spawns a fresh Node child process (`child_process.fork`) per test file, mirroring `node --test`'s isolation model. Use this when tests mutate `process.env`, `process.cwd()`, or other process-level state at module-load time and depend on the SUT picking up those mutations — the worker-thread model freezes `os.homedir()` and shares the ESM module cache across files in `in-process` mode.
|
|
14
|
+
- **`npm run verify:dogfood`** — runs the migrator + Themis test pass against `alethia-mcp`'s `bridge-tests/*.mjs` (set `ALETHIA_MCP_REPO` to override path). Currently 57/57 green.
|
|
15
|
+
- ESM runtime support (`.mjs`, `.cjs`, `.js` in `type:module` packages) via dynamic `import()` in `src/module-loader.js`. Backed by 5 fixtures in `tests/fixtures/esm/` and `npm run proof:esm`. Mock-via-`mock(...)` is not supported for ESM imports (CJS only); document if encountered.
|
|
16
|
+
- `node:test` / `node:assert` residual-import detection in `MIGRATION_ASSIST_PATTERNS` so post-migration leftovers surface as warnings.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Default `testRegex` now matches `.mjs` and `.cjs` test files in addition to `.js/.jsx/.ts/.tsx`.
|
|
21
|
+
- Migration report `summary` gains `nodeTest` and `nodeAssert` counters; `source` enum extended to `['jest', 'vitest', 'node']`. Schema bumped in-place under `themis.migration.report.v1`.
|
|
22
|
+
|
|
23
|
+
### Notes
|
|
24
|
+
|
|
25
|
+
- `themis migrate node` silently drops `assert.equal`'s optional 3rd-arg message string (same posture as the existing jest/vitest codemods). Failure messages still surface via Themis assertion output.
|
|
26
|
+
- TS files inside `type:module` packages still transpile to CJS and crash in ESM scope — out of scope for this release; defer until a partner pulls.
|
|
27
|
+
|
|
28
|
+
|
|
7
29
|
## 1.2.1 - 2026-04-09
|
|
8
30
|
|
|
9
31
|
- Added `init --cursor` flag to install a `.cursorrules` file with Themis conventions. Composable with `--agents` and `--claude-code`.
|
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ Drop-in alternative to Jest and Vitest. Agents write tests, get structured failu
|
|
|
13
13
|
|
|
14
14
|
- **Faster** — 68.59% faster than Vitest, 130.26% faster than Jest on the same benchmark ([proof](#performance))
|
|
15
15
|
- **Agent-native** — `--agent` JSON with failure clusters and structured repair hints
|
|
16
|
-
- **One-command migration** — `npx themis migrate jest` or `
|
|
17
|
-
- **Modern by default** — `.ts`, `.tsx`, `.js`, `.jsx`, ESM, React Testing Library, no config gymnastics
|
|
16
|
+
- **One-command migration** — `npx themis migrate jest`, `vitest`, or `node` (for `node:test` suites) with codemods
|
|
17
|
+
- **Modern by default** — `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`, ESM, React Testing Library, no config gymnastics
|
|
18
18
|
- **Discoverable** — ships `AGENTS.md`, `themis.ai.json`, and a [Tessl tile](tessl/tile.json) so agents find and adopt it automatically
|
|
19
19
|
|
|
20
20
|
---
|
|
@@ -110,13 +110,22 @@ npx themis test --fix
|
|
|
110
110
|
### Migration
|
|
111
111
|
|
|
112
112
|
```bash
|
|
113
|
-
npx themis migrate jest
|
|
114
|
-
npx themis migrate vitest
|
|
113
|
+
npx themis migrate jest # Jest suites
|
|
114
|
+
npx themis migrate vitest # Vitest suites
|
|
115
|
+
npx themis migrate node # node:test + node:assert/strict suites
|
|
115
116
|
npx themis test
|
|
116
117
|
```
|
|
117
118
|
|
|
118
119
|
One command scaffolds a compatibility bridge. Add `--rewrite-imports` to rewrite import paths, `--convert` for codemods. See the [migration guide](docs/migration.md).
|
|
119
120
|
|
|
121
|
+
For tests that mutate `process.env` or other process-level state at module load (and import the SUT after), pair with per-file process isolation:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx themis test --isolation process
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This spawns a fresh Node child process per test file (mirroring `node --test`), so `os.homedir()`, the ESM module cache, and other process-state are not shared across files.
|
|
128
|
+
|
|
120
129
|
---
|
|
121
130
|
|
|
122
131
|
## Config
|
package/docs/api.md
CHANGED
|
@@ -28,7 +28,7 @@ For machine-readable agent adoption metadata, see [`themis.ai.json`](../themis.a
|
|
|
28
28
|
themis test [options]
|
|
29
29
|
themis init [--agents]
|
|
30
30
|
themis generate [path]
|
|
31
|
-
themis migrate <jest|vitest>
|
|
31
|
+
themis migrate <jest|vitest|node>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
## `themis init`
|
|
@@ -203,7 +203,7 @@ Migration options:
|
|
|
203
203
|
| `--reporter spec\|next\|json\|agent\|html` | string | Explicit reporter override. |
|
|
204
204
|
| `--workers <N>` | positive integer | Override worker count. Invalid values fail fast. |
|
|
205
205
|
| `--environment node\|jsdom` | string | Override the configured test environment. |
|
|
206
|
-
| `--isolation worker\|in-process` | string | Select worker
|
|
206
|
+
| `--isolation worker\|in-process\|process` | string | Select isolation model. `worker` (default) = worker thread per file. `in-process` = sequential in the parent (fastest reruns; shares ESM cache + process state across files). `process` = `child_process.fork` per file, mirroring `node --test` (use when tests mutate `process.env`/`process.cwd()` at module load). |
|
|
207
207
|
| `--cache` | flag | Enable file-level result caching for in-process local loops. |
|
|
208
208
|
| `--update-contracts` | flag | Accept updated `captureContract(...)` baselines for the selected tests. |
|
|
209
209
|
| `-w`, `--watch` | flag | Rerun the selected suite when watched project files change. |
|
|
@@ -219,7 +219,7 @@ Migration compatibility:
|
|
|
219
219
|
- imports from `@jest/globals` are supported at runtime
|
|
220
220
|
- imports from `vitest` are supported at runtime
|
|
221
221
|
- imports from `@testing-library/react` are supported via Themis `render`, `screen`, `fireEvent`, `waitFor`, `cleanup`, and `act`
|
|
222
|
-
- `themis migrate <jest|vitest>` also emits `.themis/migration/migration-report.json` with detected files, migration mode details, assistant findings, and recommended next actions
|
|
222
|
+
- `themis migrate <jest|vitest|node>` also emits `.themis/migration/migration-report.json` with detected files, migration mode details, assistant findings, and recommended next actions
|
|
223
223
|
|
|
224
224
|
Additional option:
|
|
225
225
|
|
|
@@ -229,6 +229,7 @@ Execution note:
|
|
|
229
229
|
|
|
230
230
|
- `--watch --isolation in-process --cache` is the fastest local rerun mode
|
|
231
231
|
- `--isolation worker` remains the safer mode for CI and global-heavy suites
|
|
232
|
+
- `--isolation process` is required for tests that mutate `process.env`/`process.cwd()` at module load (matches `node --test`'s isolation model)
|
|
232
233
|
- `--watch` is intended for short edit-run-review loops for both humans and AI agents
|
|
233
234
|
|
|
234
235
|
Snapshot note:
|
package/docs/migration.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Migrating From Jest And
|
|
1
|
+
# Migrating From Jest, Vitest, And node:test
|
|
2
2
|
|
|
3
3
|
Themis is designed for incremental migration. Start by running existing suites under the Themis runtime, then convert touched tests toward native contracts and `intent(...)` flows as you work.
|
|
4
4
|
|
|
@@ -12,14 +12,42 @@ npx themis migrate jest --assist
|
|
|
12
12
|
npx themis test
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Use `vitest`
|
|
15
|
+
Use `vitest` for Vitest suites or `node` for `node:test` suites in place of `jest`.
|
|
16
16
|
|
|
17
17
|
## Migration modes
|
|
18
18
|
|
|
19
|
-
- `themis migrate <jest|vitest>`: scaffold config
|
|
20
|
-
- `--rewrite-imports`: point framework imports at `themis.compat.js
|
|
21
|
-
- `--convert`: remove common
|
|
22
|
-
- `--assist`: run the safe rewrite and conversion passes together, then report leftover
|
|
19
|
+
- `themis migrate <jest|vitest|node>`: scaffold config and migration report. For `jest`/`vitest`, also writes a setup file and a compat bridge; `node` skips both (Themis provides the same globals natively).
|
|
20
|
+
- `--rewrite-imports`: point framework imports at `themis.compat.js` (jest/vitest only — `node` source has no compat shim, conversion is direct).
|
|
21
|
+
- `--convert`: remove common framework imports and rewrite matcher/test patterns into Themis-native forms.
|
|
22
|
+
- `--assist`: run the safe rewrite and conversion passes together, then report leftover framework-specific helpers that still need manual follow-up.
|
|
23
|
+
|
|
24
|
+
## node:test specifics
|
|
25
|
+
|
|
26
|
+
`themis migrate node` handles the following transforms:
|
|
27
|
+
|
|
28
|
+
| Input (node:test + node:assert/strict) | Output (Themis) |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `import test from 'node:test'` | dropped (`test` is a Themis global) |
|
|
31
|
+
| `import assert from 'node:assert/strict'` | dropped (`expect` replaces all asserts) |
|
|
32
|
+
| `assert.equal(a, b)` / `strictEqual` | `expect(a).toBe(b)` |
|
|
33
|
+
| `assert.deepEqual(a, b)` / `deepStrictEqual` | `expect(a).toEqual(b)` |
|
|
34
|
+
| `assert.ok(v)` | `expect(v).toBeTruthy()` |
|
|
35
|
+
| `assert.match(s, /re/)` | `expect(s).toMatch(/re/)` |
|
|
36
|
+
| `await assert.rejects(fn, /re/)` | async try/catch wrapper + `toMatch` on the error message |
|
|
37
|
+
| `test.after(fn)` / `test.afterEach(fn)` | `afterAll(fn)` / `afterEach(fn)` |
|
|
38
|
+
| `test(name, { timeout }, fn)` | `test(name, fn)` (options arg silently dropped) |
|
|
39
|
+
|
|
40
|
+
Not supported in this pass: `t.test()` subtests, `t.context`, `test.only`, `describe`/`it` exported from `node:test` (use Themis globals instead), `assert.throws`/`notEqual`/`fail`/`doesNotReject`, source-map line preservation. The optional 3rd-arg message string on `assert.equal`-family calls is silently dropped.
|
|
41
|
+
|
|
42
|
+
## Process-state isolation
|
|
43
|
+
|
|
44
|
+
`node:test` runs each test file in its own child process. If your suite mutates `process.env`, `process.cwd()`, or other process-level state at module load (e.g. `process.env.HOME = mkdtempSync(...)` before `await import('../dist/index.js')`), pair `themis test` with per-file process isolation:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx themis test --isolation process
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This spawns a fresh Node child process per test file via `child_process.fork`, mirroring `node --test`'s isolation model. The default `worker` mode shares process-state (especially `os.homedir()` cached at worker startup) across files and will surface as cross-file leakage for state-mutating tests.
|
|
23
51
|
|
|
24
52
|
## Before And After
|
|
25
53
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
},
|
|
13
13
|
"source": {
|
|
14
14
|
"type": "string",
|
|
15
|
-
"enum": ["jest", "vitest"]
|
|
15
|
+
"enum": ["jest", "vitest", "node"]
|
|
16
16
|
},
|
|
17
17
|
"createdAt": {
|
|
18
18
|
"type": "string"
|
|
@@ -35,6 +35,8 @@
|
|
|
35
35
|
"jestGlobals",
|
|
36
36
|
"vitest",
|
|
37
37
|
"testingLibraryReact",
|
|
38
|
+
"nodeTest",
|
|
39
|
+
"nodeAssert",
|
|
38
40
|
"rewrittenFiles",
|
|
39
41
|
"rewrittenImports",
|
|
40
42
|
"convertedFiles",
|
|
@@ -50,6 +52,8 @@
|
|
|
50
52
|
"jestGlobals": { "type": "number" },
|
|
51
53
|
"vitest": { "type": "number" },
|
|
52
54
|
"testingLibraryReact": { "type": "number" },
|
|
55
|
+
"nodeTest": { "type": "number" },
|
|
56
|
+
"nodeAssert": { "type": "number" },
|
|
53
57
|
"rewrittenFiles": { "type": "number" },
|
|
54
58
|
"rewrittenImports": { "type": "number" },
|
|
55
59
|
"convertedFiles": { "type": "number" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vitronai/themis",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "A Node.js and TypeScript unit test framework designed for AI coding agents. Drop-in alternative to Jest and Vitest with machine-readable failure output, structured repair hints, and one-command migration.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Vitron AI",
|
|
@@ -97,6 +97,8 @@
|
|
|
97
97
|
"benchmark:first-try": "node scripts/benchmark-first-try.js",
|
|
98
98
|
"benchmark:gate": "node scripts/benchmark-gate.js",
|
|
99
99
|
"proof:migration": "node scripts/verify-migration-fixtures.js",
|
|
100
|
+
"proof:esm": "node scripts/verify-esm-fixtures.js",
|
|
101
|
+
"verify:dogfood": "node scripts/verify-alethia-bridge-dogfood.js",
|
|
100
102
|
"pack:check": "npm pack --dry-run",
|
|
101
103
|
"prepublishOnly": "npm run lint && npm test && npm run typecheck"
|
|
102
104
|
},
|
package/src/cli.js
CHANGED
|
@@ -147,7 +147,11 @@ async function main(argv) {
|
|
|
147
147
|
}
|
|
148
148
|
console.log(`Report: ${formatCliPath(cwd, result.reportPath)}`);
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
if (result.source === 'node') {
|
|
151
|
+
console.log('node:test and node:assert imports are dropped during conversion; Themis provides test/expect/afterAll/afterEach as globals.');
|
|
152
|
+
} else {
|
|
153
|
+
console.log('Runtime compatibility is enabled for @jest/globals, vitest, and @testing-library/react imports.');
|
|
154
|
+
}
|
|
151
155
|
console.log('Next: run npx themis test or npm run test:themis');
|
|
152
156
|
return;
|
|
153
157
|
}
|
|
@@ -778,10 +782,10 @@ function validateWorkerCount(flagValue, configValue) {
|
|
|
778
782
|
}
|
|
779
783
|
|
|
780
784
|
function validateIsolation(value) {
|
|
781
|
-
if (value === 'worker' || value === 'in-process') {
|
|
785
|
+
if (value === 'worker' || value === 'in-process' || value === 'process') {
|
|
782
786
|
return;
|
|
783
787
|
}
|
|
784
|
-
throw new Error(`Unsupported --isolation value: ${value}. Use one of: worker, in-process.`);
|
|
788
|
+
throw new Error(`Unsupported --isolation value: ${value}. Use one of: worker, in-process, process.`);
|
|
785
789
|
}
|
|
786
790
|
|
|
787
791
|
function resolveWorkerCount(flagValue, configValue) {
|
|
@@ -814,8 +818,8 @@ function printUsage() {
|
|
|
814
818
|
console.log(' generate [path] Scan source files and generate Themis contract tests');
|
|
815
819
|
console.log(' Options: [--json] [--plan] [--output path] [--files a,b] [--match-source regex] [--match-export regex] [--scenario name] [--min-confidence level] [--require-confidence level] [--include regex] [--exclude regex] [--review] [--update] [--clean] [--changed] [--force] [--strict] [--write-hints] [--fail-on-skips] [--fail-on-conflicts]');
|
|
816
820
|
console.log(' scan [path] Alias for generate');
|
|
817
|
-
console.log(' migrate <jest|vitest> [--rewrite-imports] [--convert] [--assist] Scaffold an incremental migration bridge for existing suites');
|
|
818
|
-
console.log(' test [--json] [--agent] [--next] [--reporter spec|next|json|agent|html] [--workers N] [--stability N] [--environment node|jsdom] [--isolation worker|in-process] [--cache] [--update-contracts] [--fix] [-w|--watch] [--html-output path] [--match regex] [--rerun-failed] [--no-memes] [--lexicon classic|themis]');
|
|
821
|
+
console.log(' migrate <jest|vitest|node> [--rewrite-imports] [--convert] [--assist] Scaffold an incremental migration bridge for existing suites');
|
|
822
|
+
console.log(' test [--json] [--agent] [--next] [--reporter spec|next|json|agent|html] [--workers N] [--stability N] [--environment node|jsdom] [--isolation worker|in-process|process] [--cache] [--update-contracts] [--fix] [-w|--watch] [--html-output path] [--match regex] [--rerun-failed] [--no-memes] [--lexicon classic|themis]');
|
|
819
823
|
}
|
|
820
824
|
|
|
821
825
|
function printGenerateSummary(summary, cwd) {
|
package/src/config.js
CHANGED
|
@@ -5,7 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const DEFAULT_CONFIG = {
|
|
6
6
|
testDir: 'tests',
|
|
7
7
|
generatedTestsDir: path.join('__themis__', 'tests'),
|
|
8
|
-
testRegex: '\\.(test|spec)\\.(js|jsx|ts|tsx)$',
|
|
8
|
+
testRegex: '\\.(test|spec)\\.(js|jsx|ts|tsx|mjs|cjs)$',
|
|
9
9
|
maxWorkers: Math.max(1, os.cpus().length - 1),
|
|
10
10
|
reporter: 'next',
|
|
11
11
|
environment: 'node',
|
package/src/expect.js
CHANGED
|
@@ -70,6 +70,24 @@ function createExpect(_context = {}) {
|
|
|
70
70
|
|
|
71
71
|
throw new Error('toContain only supports strings and arrays');
|
|
72
72
|
},
|
|
73
|
+
toMatch(expected) {
|
|
74
|
+
if (typeof received !== 'string') {
|
|
75
|
+
throw new Error(`toMatch expects a string, received ${format(received)}`);
|
|
76
|
+
}
|
|
77
|
+
if (typeof expected === 'string') {
|
|
78
|
+
if (!received.includes(expected)) {
|
|
79
|
+
throw new Error(`Expected ${format(received)} to match substring ${format(expected)}`);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (expected instanceof RegExp) {
|
|
84
|
+
if (!expected.test(received)) {
|
|
85
|
+
throw new Error(`Expected ${format(received)} to match ${String(expected)}`);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
throw new Error('toMatch expects a string or RegExp');
|
|
90
|
+
},
|
|
73
91
|
toThrow(match) {
|
|
74
92
|
if (typeof received !== 'function') {
|
|
75
93
|
throw new Error('toThrow expects a function');
|
package/src/migrate.js
CHANGED
|
@@ -4,7 +4,7 @@ const { DEFAULT_CONFIG, loadConfig } = require('./config');
|
|
|
4
4
|
const { ARTIFACT_RELATIVE_PATHS } = require('./artifact-paths');
|
|
5
5
|
const { ensureGitignoreEntries } = require('./gitignore');
|
|
6
6
|
|
|
7
|
-
const SUPPORTED_MIGRATION_SOURCES = new Set(['jest', 'vitest']);
|
|
7
|
+
const SUPPORTED_MIGRATION_SOURCES = new Set(['jest', 'vitest', 'node']);
|
|
8
8
|
const THEMIS_SETUP_FILE = path.join('tests', 'setup.themis.js');
|
|
9
9
|
const THEMIS_COMPAT_FILE = 'themis.compat.js';
|
|
10
10
|
const MIGRATION_REPORT_FILE = ARTIFACT_RELATIVE_PATHS.migrationReport;
|
|
@@ -19,6 +19,14 @@ const MIGRATION_ASSIST_PATTERNS = Object.freeze([
|
|
|
19
19
|
message: 'Framework-specific imports are still present after migration scaffolding.',
|
|
20
20
|
suggestion: 'Rewrite or remove remaining framework imports so the suite only depends on Themis-compatible entry points.'
|
|
21
21
|
},
|
|
22
|
+
{
|
|
23
|
+
id: 'remaining-node-test-import',
|
|
24
|
+
category: 'remaining-framework-import',
|
|
25
|
+
severity: 'warning',
|
|
26
|
+
pattern: /(?:import\s+[^;]*?from\s+['"]node:(?:test|assert(?:\/strict)?)['"]|import\s*\(\s*['"]node:(?:test|assert(?:\/strict)?)['"]\s*\)|require\(\s*['"]node:(?:test|assert(?:\/strict)?)['"]\s*\))/,
|
|
27
|
+
message: 'node:test or node:assert imports remain after migration; rerun with --convert or rewrite manually.',
|
|
28
|
+
suggestion: 'Themis provides test/expect/afterAll/afterEach as globals. Drop node:test and node:assert imports and use the Themis equivalents.'
|
|
29
|
+
},
|
|
22
30
|
{
|
|
23
31
|
id: 'unsupported-helper',
|
|
24
32
|
category: 'unsupported-helper',
|
|
@@ -48,7 +56,7 @@ const MIGRATION_ASSIST_PATTERNS = Object.freeze([
|
|
|
48
56
|
function runMigrate(cwd, framework, options = {}) {
|
|
49
57
|
const source = String(framework || '').trim().toLowerCase();
|
|
50
58
|
if (!SUPPORTED_MIGRATION_SOURCES.has(source)) {
|
|
51
|
-
throw new Error(`Unsupported migrate source: ${String(framework)}. Use "jest" or "
|
|
59
|
+
throw new Error(`Unsupported migrate source: ${String(framework)}. Use "jest", "vitest", or "node".`);
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
const projectRoot = path.resolve(cwd || process.cwd());
|
|
@@ -60,7 +68,7 @@ function runMigrate(cwd, framework, options = {}) {
|
|
|
60
68
|
|
|
61
69
|
const existingConfig = fs.existsSync(configPath) ? loadConfig(projectRoot) : { ...DEFAULT_CONFIG, setupFiles: [], testIgnore: [] };
|
|
62
70
|
const nextSetupFiles = Array.isArray(existingConfig.setupFiles) ? [...existingConfig.setupFiles] : [];
|
|
63
|
-
if (!nextSetupFiles.includes(THEMIS_SETUP_FILE)) {
|
|
71
|
+
if (source !== 'node' && !nextSetupFiles.includes(THEMIS_SETUP_FILE)) {
|
|
64
72
|
nextSetupFiles.push(THEMIS_SETUP_FILE);
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -69,13 +77,15 @@ function runMigrate(cwd, framework, options = {}) {
|
|
|
69
77
|
setupFiles: nextSetupFiles
|
|
70
78
|
};
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
fs.
|
|
75
|
-
|
|
80
|
+
if (source !== 'node') {
|
|
81
|
+
fs.mkdirSync(path.dirname(setupPath), { recursive: true });
|
|
82
|
+
if (!fs.existsSync(setupPath)) {
|
|
83
|
+
fs.writeFileSync(setupPath, buildMigrationSetupSource(source), 'utf8');
|
|
84
|
+
}
|
|
76
85
|
|
|
77
|
-
|
|
78
|
-
|
|
86
|
+
if (!fs.existsSync(compatPath)) {
|
|
87
|
+
fs.writeFileSync(compatPath, buildMigrationCompatSource(), 'utf8');
|
|
88
|
+
}
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
|
|
@@ -98,7 +108,7 @@ function runMigrate(cwd, framework, options = {}) {
|
|
|
98
108
|
? rewriteMigrationImports(projectRoot, scan.matches, compatPath)
|
|
99
109
|
: { rewrittenFiles: [], rewrittenImports: 0 };
|
|
100
110
|
const conversionSummary = options.convert
|
|
101
|
-
? convertMigrationFiles(projectRoot, scan.matches)
|
|
111
|
+
? convertMigrationFiles(projectRoot, scan.matches, source)
|
|
102
112
|
: { convertedFiles: [], convertedAssertions: 0, removedImports: 0 };
|
|
103
113
|
const assistSummary = analyzeMigrationAssist(projectRoot, scan.matches, {
|
|
104
114
|
enabled: Boolean(options.assist)
|
|
@@ -232,6 +242,8 @@ function buildMigrationReport(
|
|
|
232
242
|
jestGlobals: matches.filter((entry) => entry.imports.includes('@jest/globals')).length,
|
|
233
243
|
vitest: matches.filter((entry) => entry.imports.includes('vitest')).length,
|
|
234
244
|
testingLibraryReact: matches.filter((entry) => entry.imports.includes('@testing-library/react')).length,
|
|
245
|
+
nodeTest: matches.filter((entry) => entry.imports.includes('node:test')).length,
|
|
246
|
+
nodeAssert: matches.filter((entry) => entry.imports.includes('node:assert')).length,
|
|
235
247
|
rewrittenFiles: Array.isArray(rewriteSummary.rewrittenFiles) ? rewriteSummary.rewrittenFiles.length : 0,
|
|
236
248
|
rewrittenImports: Number(rewriteSummary.rewrittenImports || 0),
|
|
237
249
|
convertedFiles: Array.isArray(conversionSummary.convertedFiles) ? conversionSummary.convertedFiles.length : 0,
|
|
@@ -324,7 +336,7 @@ function normalizeAssistSummary(summary, enabled) {
|
|
|
324
336
|
};
|
|
325
337
|
}
|
|
326
338
|
|
|
327
|
-
function convertMigrationFiles(projectRoot, matches) {
|
|
339
|
+
function convertMigrationFiles(projectRoot, matches, source) {
|
|
328
340
|
const convertedFiles = [];
|
|
329
341
|
let convertedAssertions = 0;
|
|
330
342
|
let removedImports = 0;
|
|
@@ -332,7 +344,9 @@ function convertMigrationFiles(projectRoot, matches) {
|
|
|
332
344
|
for (const match of matches) {
|
|
333
345
|
const absoluteFile = path.join(projectRoot, match.file);
|
|
334
346
|
const original = fs.readFileSync(absoluteFile, 'utf8');
|
|
335
|
-
const converted =
|
|
347
|
+
const converted = source === 'node'
|
|
348
|
+
? convertNodeTestSourceText(original)
|
|
349
|
+
: convertMigrationSourceText(original);
|
|
336
350
|
if (converted.source !== original) {
|
|
337
351
|
fs.writeFileSync(absoluteFile, converted.source, 'utf8');
|
|
338
352
|
convertedFiles.push(match.file);
|
|
@@ -419,6 +433,363 @@ function convertMigrationSourceText(sourceText) {
|
|
|
419
433
|
};
|
|
420
434
|
}
|
|
421
435
|
|
|
436
|
+
function convertNodeTestSourceText(sourceText) {
|
|
437
|
+
let source = sourceText;
|
|
438
|
+
let removedImports = 0;
|
|
439
|
+
let convertedAssertions = 0;
|
|
440
|
+
|
|
441
|
+
source = source.replace(
|
|
442
|
+
/^\s*import\s+test(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]node:test['"];?\s*\n?/gm,
|
|
443
|
+
() => {
|
|
444
|
+
removedImports += 1;
|
|
445
|
+
return '';
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
source = source.replace(
|
|
450
|
+
/^\s*import\s+\{[^}]*\}\s+from\s+['"]node:test['"];?\s*\n?/gm,
|
|
451
|
+
() => {
|
|
452
|
+
removedImports += 1;
|
|
453
|
+
return '';
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
source = source.replace(
|
|
458
|
+
/^\s*import\s+assert(?:\s*,\s*\{[^}]*\})?\s+from\s+['"]node:assert(?:\/strict)?['"];?\s*\n?/gm,
|
|
459
|
+
() => {
|
|
460
|
+
removedImports += 1;
|
|
461
|
+
return '';
|
|
462
|
+
}
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
source = source.replace(
|
|
466
|
+
/^\s*import\s+\{[^}]*\}\s+from\s+['"]node:assert(?:\/strict)?['"];?\s*\n?/gm,
|
|
467
|
+
() => {
|
|
468
|
+
removedImports += 1;
|
|
469
|
+
return '';
|
|
470
|
+
}
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
const renames = [
|
|
474
|
+
{ pattern: /\btest\.afterEach\s*\(/g, replacement: 'afterEach(' },
|
|
475
|
+
{ pattern: /\btest\.after\s*\(/g, replacement: 'afterAll(' },
|
|
476
|
+
{ pattern: /\btest\.beforeEach\s*\(/g, replacement: 'beforeEach(' },
|
|
477
|
+
{ pattern: /\btest\.before\s*\(/g, replacement: 'beforeAll(' }
|
|
478
|
+
];
|
|
479
|
+
for (const entry of renames) {
|
|
480
|
+
source = source.replace(entry.pattern, () => {
|
|
481
|
+
convertedAssertions += 1;
|
|
482
|
+
return entry.replacement;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const testResult = stripNodeTestOptions(source);
|
|
487
|
+
source = testResult.source;
|
|
488
|
+
convertedAssertions += testResult.convertedAssertions;
|
|
489
|
+
|
|
490
|
+
const assertResult = rewriteAssertCalls(source);
|
|
491
|
+
source = assertResult.source;
|
|
492
|
+
convertedAssertions += assertResult.convertedAssertions;
|
|
493
|
+
|
|
494
|
+
source = source.replace(/\n{3,}/g, '\n\n');
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
source,
|
|
498
|
+
convertedAssertions,
|
|
499
|
+
removedImports
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const NODE_ASSERT_REWRITERS = {
|
|
504
|
+
equal: (args) => `expect(${args[0]}).toBe(${args[1]})`,
|
|
505
|
+
strictEqual: (args) => `expect(${args[0]}).toBe(${args[1]})`,
|
|
506
|
+
deepEqual: (args) => `expect(${args[0]}).toEqual(${args[1]})`,
|
|
507
|
+
deepStrictEqual: (args) => `expect(${args[0]}).toEqual(${args[1]})`,
|
|
508
|
+
ok: (args) => `expect(${args[0]}).toBeTruthy()`,
|
|
509
|
+
match: (args) => `expect(${args[0]}).toMatch(${args[1]})`,
|
|
510
|
+
rejects: (args) => {
|
|
511
|
+
const subject = args[0];
|
|
512
|
+
const matcher = args[1];
|
|
513
|
+
const subjectExpr = `typeof (${subject}) === 'function' ? (${subject})() : (${subject})`;
|
|
514
|
+
const matcherLine = matcher
|
|
515
|
+
? `; expect(String(__themisError && __themisError.message || __themisError)).toMatch(${matcher})`
|
|
516
|
+
: '';
|
|
517
|
+
return `(async () => { let __themisError = null; try { await (${subjectExpr}); } catch (__e) { __themisError = __e; } expect(__themisError).toBeTruthy()${matcherLine}; })()`;
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
function stripNodeTestOptions(source) {
|
|
522
|
+
let out = '';
|
|
523
|
+
let i = 0;
|
|
524
|
+
let convertedAssertions = 0;
|
|
525
|
+
const callRegex = /\btest\s*\(/;
|
|
526
|
+
|
|
527
|
+
while (i < source.length) {
|
|
528
|
+
const remaining = source.slice(i);
|
|
529
|
+
const match = callRegex.exec(remaining);
|
|
530
|
+
if (!match) {
|
|
531
|
+
out += remaining;
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const callStart = i + match.index;
|
|
536
|
+
out += source.slice(i, callStart);
|
|
537
|
+
|
|
538
|
+
const prevChar = callStart === 0 ? '' : source[callStart - 1];
|
|
539
|
+
if (prevChar && /[A-Za-z0-9_$.]/.test(prevChar)) {
|
|
540
|
+
out += match[0];
|
|
541
|
+
i = callStart + match[0].length;
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const openParen = callStart + match[0].length - 1;
|
|
546
|
+
const closeParen = findCallEnd(source, openParen);
|
|
547
|
+
if (closeParen === -1) {
|
|
548
|
+
out += match[0];
|
|
549
|
+
i = openParen + 1;
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const body = source.slice(openParen + 1, closeParen);
|
|
554
|
+
const args = splitTopLevelArgs(body);
|
|
555
|
+
if (args.length === 3 && args[1].startsWith('{')) {
|
|
556
|
+
out += `test(${args[0]}, ${args[2]})`;
|
|
557
|
+
convertedAssertions += 1;
|
|
558
|
+
i = closeParen + 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
out += source.slice(callStart, closeParen + 1);
|
|
563
|
+
i = closeParen + 1;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return { source: out, convertedAssertions };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function rewriteAssertCalls(source) {
|
|
570
|
+
let out = '';
|
|
571
|
+
let i = 0;
|
|
572
|
+
let convertedAssertions = 0;
|
|
573
|
+
const callRegex = /assert\.([a-zA-Z]+)\s*\(/;
|
|
574
|
+
|
|
575
|
+
while (i < source.length) {
|
|
576
|
+
const remaining = source.slice(i);
|
|
577
|
+
const match = callRegex.exec(remaining);
|
|
578
|
+
if (!match) {
|
|
579
|
+
out += remaining;
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const callStart = i + match.index;
|
|
584
|
+
out += source.slice(i, callStart);
|
|
585
|
+
|
|
586
|
+
const prevChar = callStart === 0 ? '' : source[callStart - 1];
|
|
587
|
+
if (prevChar && /[A-Za-z0-9_$]/.test(prevChar)) {
|
|
588
|
+
out += match[0];
|
|
589
|
+
i = callStart + match[0].length;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const helper = match[1];
|
|
594
|
+
const rewriter = NODE_ASSERT_REWRITERS[helper];
|
|
595
|
+
const openParen = callStart + match[0].length - 1;
|
|
596
|
+
|
|
597
|
+
if (!rewriter) {
|
|
598
|
+
out += match[0];
|
|
599
|
+
i = openParen + 1;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const closeParen = findCallEnd(source, openParen);
|
|
604
|
+
if (closeParen === -1) {
|
|
605
|
+
out += match[0];
|
|
606
|
+
i = openParen + 1;
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const body = source.slice(openParen + 1, closeParen);
|
|
611
|
+
const args = splitTopLevelArgs(body);
|
|
612
|
+
if (args.length < 1) {
|
|
613
|
+
out += match[0] + body + ')';
|
|
614
|
+
i = closeParen + 1;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
out += rewriter(args);
|
|
619
|
+
convertedAssertions += 1;
|
|
620
|
+
i = closeParen + 1;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { source: out, convertedAssertions };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const REGEX_START_PREV_CHARS = new Set([
|
|
627
|
+
'(', ',', ';', '{', '}', '[', '=', '!', '&', '|', '+', '-', '*', '/', '%',
|
|
628
|
+
'<', '>', '?', ':', '^', '~', '\n'
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
function shouldStartRegex(source, slashIndex) {
|
|
632
|
+
for (let k = slashIndex - 1; k >= 0; k -= 1) {
|
|
633
|
+
const c = source[k];
|
|
634
|
+
if (c === ' ' || c === '\t') continue;
|
|
635
|
+
if (c === '\n') return true;
|
|
636
|
+
if (REGEX_START_PREV_CHARS.has(c)) return true;
|
|
637
|
+
const wordEnd = k;
|
|
638
|
+
let wordStart = k;
|
|
639
|
+
while (wordStart > 0 && /[A-Za-z0-9_$]/.test(source[wordStart - 1])) {
|
|
640
|
+
wordStart -= 1;
|
|
641
|
+
}
|
|
642
|
+
const word = source.slice(wordStart, wordEnd + 1);
|
|
643
|
+
if (/^(return|typeof|in|of|instanceof|new|throw|delete|void|yield|await|do|else)$/.test(word)) {
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function findCallEnd(source, openIndex) {
|
|
652
|
+
let depth = 0;
|
|
653
|
+
let i = openIndex;
|
|
654
|
+
let mode = 'code';
|
|
655
|
+
while (i < source.length) {
|
|
656
|
+
const ch = source[i];
|
|
657
|
+
const next = source[i + 1];
|
|
658
|
+
if (mode === 'code') {
|
|
659
|
+
if (ch === '/' && next === '/') { mode = 'lc'; i += 2; continue; }
|
|
660
|
+
if (ch === '/' && next === '*') { mode = 'bc'; i += 2; continue; }
|
|
661
|
+
if (ch === '/' && shouldStartRegex(source, i)) { mode = 're'; i += 1; continue; }
|
|
662
|
+
if (ch === "'") { mode = 'sq'; i += 1; continue; }
|
|
663
|
+
if (ch === '"') { mode = 'dq'; i += 1; continue; }
|
|
664
|
+
if (ch === '`') { mode = 'tpl'; i += 1; continue; }
|
|
665
|
+
if (ch === '(') { depth += 1; i += 1; continue; }
|
|
666
|
+
if (ch === ')') {
|
|
667
|
+
depth -= 1;
|
|
668
|
+
if (depth === 0) return i;
|
|
669
|
+
i += 1; continue;
|
|
670
|
+
}
|
|
671
|
+
i += 1; continue;
|
|
672
|
+
}
|
|
673
|
+
if (mode === 'sq') {
|
|
674
|
+
if (ch === '\\') { i += 2; continue; }
|
|
675
|
+
if (ch === "'") { mode = 'code'; }
|
|
676
|
+
i += 1; continue;
|
|
677
|
+
}
|
|
678
|
+
if (mode === 'dq') {
|
|
679
|
+
if (ch === '\\') { i += 2; continue; }
|
|
680
|
+
if (ch === '"') { mode = 'code'; }
|
|
681
|
+
i += 1; continue;
|
|
682
|
+
}
|
|
683
|
+
if (mode === 'tpl') {
|
|
684
|
+
if (ch === '\\') { i += 2; continue; }
|
|
685
|
+
if (ch === '`') { mode = 'code'; }
|
|
686
|
+
i += 1; continue;
|
|
687
|
+
}
|
|
688
|
+
if (mode === 're') {
|
|
689
|
+
if (ch === '\\') { i += 2; continue; }
|
|
690
|
+
if (ch === '[') { mode = 'rec'; i += 1; continue; }
|
|
691
|
+
if (ch === '/') {
|
|
692
|
+
mode = 'code';
|
|
693
|
+
i += 1;
|
|
694
|
+
while (i < source.length && /[a-z]/.test(source[i])) i += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
i += 1; continue;
|
|
698
|
+
}
|
|
699
|
+
if (mode === 'rec') {
|
|
700
|
+
if (ch === '\\') { i += 2; continue; }
|
|
701
|
+
if (ch === ']') { mode = 're'; }
|
|
702
|
+
i += 1; continue;
|
|
703
|
+
}
|
|
704
|
+
if (mode === 'lc') {
|
|
705
|
+
if (ch === '\n') { mode = 'code'; }
|
|
706
|
+
i += 1; continue;
|
|
707
|
+
}
|
|
708
|
+
if (mode === 'bc') {
|
|
709
|
+
if (ch === '*' && next === '/') { mode = 'code'; i += 2; continue; }
|
|
710
|
+
i += 1; continue;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return -1;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function splitTopLevelArgs(body) {
|
|
717
|
+
const args = [];
|
|
718
|
+
let depth = 0;
|
|
719
|
+
let bracket = 0;
|
|
720
|
+
let brace = 0;
|
|
721
|
+
let mode = 'code';
|
|
722
|
+
let start = 0;
|
|
723
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
724
|
+
const ch = body[i];
|
|
725
|
+
const next = body[i + 1];
|
|
726
|
+
if (mode === 'code') {
|
|
727
|
+
if (ch === '/' && next === '/') { mode = 'lc'; i += 1; continue; }
|
|
728
|
+
if (ch === '/' && next === '*') { mode = 'bc'; i += 1; continue; }
|
|
729
|
+
if (ch === '/' && shouldStartRegex(body, i)) { mode = 're'; continue; }
|
|
730
|
+
if (ch === "'") { mode = 'sq'; continue; }
|
|
731
|
+
if (ch === '"') { mode = 'dq'; continue; }
|
|
732
|
+
if (ch === '`') { mode = 'tpl'; continue; }
|
|
733
|
+
if (ch === '(') { depth += 1; continue; }
|
|
734
|
+
if (ch === ')') { depth -= 1; continue; }
|
|
735
|
+
if (ch === '[') { bracket += 1; continue; }
|
|
736
|
+
if (ch === ']') { bracket -= 1; continue; }
|
|
737
|
+
if (ch === '{') { brace += 1; continue; }
|
|
738
|
+
if (ch === '}') { brace -= 1; continue; }
|
|
739
|
+
if (ch === ',' && depth === 0 && bracket === 0 && brace === 0) {
|
|
740
|
+
args.push(body.slice(start, i));
|
|
741
|
+
start = i + 1;
|
|
742
|
+
}
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
if (mode === 'sq') {
|
|
746
|
+
if (ch === '\\') { i += 1; continue; }
|
|
747
|
+
if (ch === "'") { mode = 'code'; }
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
if (mode === 'dq') {
|
|
751
|
+
if (ch === '\\') { i += 1; continue; }
|
|
752
|
+
if (ch === '"') { mode = 'code'; }
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
if (mode === 'tpl') {
|
|
756
|
+
if (ch === '\\') { i += 1; continue; }
|
|
757
|
+
if (ch === '`') { mode = 'code'; }
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (mode === 're') {
|
|
761
|
+
if (ch === '\\') { i += 1; continue; }
|
|
762
|
+
if (ch === '[') { mode = 'rec'; continue; }
|
|
763
|
+
if (ch === '/') {
|
|
764
|
+
mode = 'code';
|
|
765
|
+
let k = i + 1;
|
|
766
|
+
while (k < body.length && /[a-z]/.test(body[k])) k += 1;
|
|
767
|
+
i = k - 1;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (mode === 'rec') {
|
|
773
|
+
if (ch === '\\') { i += 1; continue; }
|
|
774
|
+
if (ch === ']') { mode = 're'; }
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (mode === 'lc') {
|
|
778
|
+
if (ch === '\n') { mode = 'code'; }
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (mode === 'bc') {
|
|
782
|
+
if (ch === '*' && next === '/') { mode = 'code'; i += 1; }
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const tail = body.slice(start);
|
|
787
|
+
if (tail.trim().length > 0 || args.length > 0) {
|
|
788
|
+
args.push(tail);
|
|
789
|
+
}
|
|
790
|
+
return args.map((arg) => arg.trim()).filter((arg) => arg.length > 0);
|
|
791
|
+
}
|
|
792
|
+
|
|
422
793
|
function rewriteMigrationImports(projectRoot, matches, compatPath) {
|
|
423
794
|
const rewrittenFiles = [];
|
|
424
795
|
let rewrittenImports = 0;
|
|
@@ -498,6 +869,12 @@ function detectMigrationImports(sourceText) {
|
|
|
498
869
|
if (hasModuleReference(sourceText, '@testing-library/react')) {
|
|
499
870
|
matches.push('@testing-library/react');
|
|
500
871
|
}
|
|
872
|
+
if (hasModuleReference(sourceText, 'node:test')) {
|
|
873
|
+
matches.push('node:test');
|
|
874
|
+
}
|
|
875
|
+
if (hasModuleReference(sourceText, 'node:assert/strict') || hasModuleReference(sourceText, 'node:assert')) {
|
|
876
|
+
matches.push('node:assert');
|
|
877
|
+
}
|
|
501
878
|
return matches;
|
|
502
879
|
}
|
|
503
880
|
|
package/src/module-loader.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const Module = require('module');
|
|
4
|
+
const { pathToFileURL } = require('url');
|
|
4
5
|
|
|
5
6
|
const SUPPORTED_SOURCE_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'];
|
|
7
|
+
const ESM_LOAD_EXTENSIONS = new Set(['.mjs']);
|
|
8
|
+
const SUPPORTED_TEST_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs'];
|
|
6
9
|
const RESOLVABLE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.json'];
|
|
7
10
|
const STYLE_IMPORT_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.styl', '.pcss']);
|
|
8
11
|
const ASSET_IMPORT_EXTENSIONS = new Set([
|
|
@@ -145,11 +148,17 @@ function createModuleLoader(options = {}) {
|
|
|
145
148
|
};
|
|
146
149
|
|
|
147
150
|
return {
|
|
148
|
-
loadFile(filePath) {
|
|
151
|
+
async loadFile(filePath) {
|
|
149
152
|
const resolvedPath = path.resolve(filePath);
|
|
150
153
|
const realPath = safeRealpath(resolvedPath);
|
|
151
154
|
delete require.cache[resolvedPath];
|
|
152
155
|
delete require.cache[realPath];
|
|
156
|
+
|
|
157
|
+
if (shouldLoadAsEsm(realPath, projectRoot, packageTypeCache)) {
|
|
158
|
+
const url = `${pathToFileURL(realPath).href}?themis=${Date.now()}`;
|
|
159
|
+
return import(url);
|
|
160
|
+
}
|
|
161
|
+
|
|
153
162
|
return require(realPath);
|
|
154
163
|
},
|
|
155
164
|
restore() {
|
|
@@ -289,6 +298,20 @@ function shouldTranspileFile(filename, projectRoot, packageTypeCache) {
|
|
|
289
298
|
return findNearestPackageType(filename, projectRoot, packageTypeCache) === 'module';
|
|
290
299
|
}
|
|
291
300
|
|
|
301
|
+
function shouldLoadAsEsm(filename, projectRoot, packageTypeCache) {
|
|
302
|
+
const extension = path.extname(filename).toLowerCase();
|
|
303
|
+
|
|
304
|
+
if (ESM_LOAD_EXTENSIONS.has(extension)) {
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (extension !== '.js') {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return findNearestPackageType(filename, projectRoot, packageTypeCache) === 'module';
|
|
313
|
+
}
|
|
314
|
+
|
|
292
315
|
function delegateToOriginalLoader(originalLoaders, extension, testModule, filename) {
|
|
293
316
|
const loader = originalLoaders.get(extension) || originalLoaders.get('.js');
|
|
294
317
|
if (!loader) {
|
|
@@ -539,7 +562,7 @@ function shouldRejectUnsupportedProjectImport(resolvedRequest, projectRoot) {
|
|
|
539
562
|
return false;
|
|
540
563
|
}
|
|
541
564
|
|
|
542
|
-
return !
|
|
565
|
+
return !SUPPORTED_TEST_EXTENSIONS.includes(extension)
|
|
543
566
|
&& extension !== '.json'
|
|
544
567
|
&& !STYLE_IMPORT_EXTENSIONS.has(extension)
|
|
545
568
|
&& !ASSET_IMPORT_EXTENSIONS.has(extension);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { collectAndRun } = require('./runtime');
|
|
2
|
+
|
|
3
|
+
process.once('message', async (workerData) => {
|
|
4
|
+
try {
|
|
5
|
+
const result = await collectAndRun(workerData.file, {
|
|
6
|
+
match: workerData.match,
|
|
7
|
+
allowedFullNames: workerData.allowedFullNames,
|
|
8
|
+
noMemes: workerData.noMemes,
|
|
9
|
+
updateContracts: workerData.updateContracts,
|
|
10
|
+
cwd: workerData.cwd,
|
|
11
|
+
environment: workerData.environment,
|
|
12
|
+
setupFiles: workerData.setupFiles,
|
|
13
|
+
tsconfigPath: workerData.tsconfigPath
|
|
14
|
+
});
|
|
15
|
+
process.send({ ok: true, result }, () => process.exit(0));
|
|
16
|
+
} catch (error) {
|
|
17
|
+
process.send({
|
|
18
|
+
ok: false,
|
|
19
|
+
error: {
|
|
20
|
+
message: String((error && error.message) || error),
|
|
21
|
+
stack: String((error && error.stack) || error)
|
|
22
|
+
}
|
|
23
|
+
}, () => process.exit(0));
|
|
24
|
+
}
|
|
25
|
+
});
|
package/src/runner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
3
|
const { Worker } = require('worker_threads');
|
|
4
|
+
const { fork } = require('child_process');
|
|
4
5
|
const { performance } = require('perf_hooks');
|
|
5
6
|
const { collectAndRun } = require('./runtime');
|
|
6
7
|
|
|
@@ -13,7 +14,9 @@ async function runTests(files, options = {}) {
|
|
|
13
14
|
const maxWorkers = isolation === 'in-process' ? 1 : resolveMaxWorkers(options.maxWorkers);
|
|
14
15
|
const fileResults = isolation === 'in-process'
|
|
15
16
|
? await runFilesInProcess(files, options)
|
|
16
|
-
:
|
|
17
|
+
: isolation === 'process'
|
|
18
|
+
? await runFilesInChildProcesses(files, options)
|
|
19
|
+
: await runFilesInWorkers(files, options);
|
|
17
20
|
|
|
18
21
|
fileResults.sort((a, b) => a.file.localeCompare(b.file));
|
|
19
22
|
|
|
@@ -72,6 +75,111 @@ async function runNext(queue, fileResults, options) {
|
|
|
72
75
|
}
|
|
73
76
|
}
|
|
74
77
|
|
|
78
|
+
async function runFilesInChildProcesses(files, options) {
|
|
79
|
+
const queue = [...files];
|
|
80
|
+
const lanes = [];
|
|
81
|
+
const fileResults = [];
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < Math.min(resolveMaxWorkers(options.maxWorkers), files.length); i += 1) {
|
|
84
|
+
lanes.push(runNextChildProcess(queue, fileResults, options));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await Promise.all(lanes);
|
|
88
|
+
return fileResults;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function runNextChildProcess(queue, fileResults, options) {
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const file = queue.shift();
|
|
94
|
+
if (!file) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const result = await runFileInChildProcess(file, options);
|
|
98
|
+
fileResults.push(result);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function runFileInChildProcess(file, options = {}) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const child = fork(path.join(__dirname, 'process-child.js'), [], {
|
|
105
|
+
cwd: options.cwd || process.cwd(),
|
|
106
|
+
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
|
107
|
+
env: { ...process.env }
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let settled = false;
|
|
111
|
+
|
|
112
|
+
const settle = (result) => {
|
|
113
|
+
if (settled) return;
|
|
114
|
+
settled = true;
|
|
115
|
+
try { child.kill(); } catch { /* already exited */ }
|
|
116
|
+
resolve(result);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
child.once('message', (payload) => {
|
|
120
|
+
if (payload && payload.ok) {
|
|
121
|
+
settle(payload.result);
|
|
122
|
+
} else {
|
|
123
|
+
settle({
|
|
124
|
+
file,
|
|
125
|
+
tests: [{
|
|
126
|
+
name: 'process',
|
|
127
|
+
fullName: `${file} process`,
|
|
128
|
+
status: 'failed',
|
|
129
|
+
durationMs: 0,
|
|
130
|
+
error: (payload && payload.error) || { message: 'process child returned no result', stack: '' }
|
|
131
|
+
}]
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
child.once('error', (error) => {
|
|
137
|
+
settle({
|
|
138
|
+
file,
|
|
139
|
+
tests: [{
|
|
140
|
+
name: 'process',
|
|
141
|
+
fullName: `${file} process`,
|
|
142
|
+
status: 'failed',
|
|
143
|
+
durationMs: 0,
|
|
144
|
+
error: {
|
|
145
|
+
message: String(error.message || error),
|
|
146
|
+
stack: String(error.stack || error)
|
|
147
|
+
}
|
|
148
|
+
}]
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
child.once('exit', (code) => {
|
|
153
|
+
if (settled) return;
|
|
154
|
+
const message = code === 0
|
|
155
|
+
? 'Child process exited before reporting test results'
|
|
156
|
+
: `Child process exited with code ${code} before reporting test results`;
|
|
157
|
+
settle({
|
|
158
|
+
file,
|
|
159
|
+
tests: [{
|
|
160
|
+
name: 'process',
|
|
161
|
+
fullName: `${file} process`,
|
|
162
|
+
status: 'failed',
|
|
163
|
+
durationMs: 0,
|
|
164
|
+
error: { message, stack: message }
|
|
165
|
+
}]
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
child.send({
|
|
170
|
+
file,
|
|
171
|
+
match: options.match || null,
|
|
172
|
+
allowedFullNames: Array.isArray(options.allowedFullNames) ? options.allowedFullNames : null,
|
|
173
|
+
noMemes: Boolean(options.noMemes),
|
|
174
|
+
updateContracts: Boolean(options.updateContracts),
|
|
175
|
+
cwd: options.cwd || process.cwd(),
|
|
176
|
+
environment: options.environment || 'node',
|
|
177
|
+
setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
|
|
178
|
+
tsconfigPath: options.tsconfigPath === undefined ? undefined : options.tsconfigPath
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
75
183
|
function runFileInWorker(file, options = {}) {
|
|
76
184
|
return new Promise((resolve) => {
|
|
77
185
|
const worker = new Worker(path.join(__dirname, 'worker.js'), {
|
|
@@ -214,7 +322,9 @@ function clearRunCache() {
|
|
|
214
322
|
}
|
|
215
323
|
|
|
216
324
|
function resolveIsolationMode(options = {}) {
|
|
217
|
-
|
|
325
|
+
if (options.isolation === 'in-process') return 'in-process';
|
|
326
|
+
if (options.isolation === 'process') return 'process';
|
|
327
|
+
return 'worker';
|
|
218
328
|
}
|
|
219
329
|
|
|
220
330
|
function resolveMaxWorkers(value) {
|
package/src/runtime.js
CHANGED
|
@@ -36,7 +36,7 @@ function createSuite(name, parent = null) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
function collectAndRun(filePath, options = {}) {
|
|
39
|
+
async function collectAndRun(filePath, options = {}) {
|
|
40
40
|
const root = createSuite('__root__', null);
|
|
41
41
|
let currentSuite = root;
|
|
42
42
|
const projectRoot = path.resolve(options.cwd || process.cwd());
|
|
@@ -92,9 +92,9 @@ function collectAndRun(filePath, options = {}) {
|
|
|
92
92
|
let loadError = null;
|
|
93
93
|
try {
|
|
94
94
|
for (const setupFile of setupFiles) {
|
|
95
|
-
moduleLoader.loadFile(setupFile);
|
|
95
|
+
await moduleLoader.loadFile(setupFile);
|
|
96
96
|
}
|
|
97
|
-
moduleLoader.loadFile(filePath);
|
|
97
|
+
await moduleLoader.loadFile(filePath);
|
|
98
98
|
} catch (error) {
|
|
99
99
|
loadError = normalizeError(error);
|
|
100
100
|
}
|
|
@@ -16,8 +16,10 @@ Use `@vitronai/themis` as this repository's unit test framework.
|
|
|
16
16
|
|
|
17
17
|
- If this repo currently uses Jest, migrate with `npx themis migrate jest`.
|
|
18
18
|
- If this repo currently uses Vitest, migrate with `npx themis migrate vitest`.
|
|
19
|
-
-
|
|
20
|
-
- Use `--
|
|
19
|
+
- If this repo currently uses `node:test` + `node:assert`, migrate with `npx themis migrate node --convert`.
|
|
20
|
+
- Use `--rewrite-imports` to point compatible imports at `themis.compat.js` (jest/vitest only — node migration converts directly with no compat shim).
|
|
21
|
+
- Use `--convert` to rewrite common framework patterns toward native Themis style.
|
|
22
|
+
- For migrated `node:test` suites that mutate `process.env`/`process.cwd()` at module load and then import the SUT, run with `npx themis test --isolation process` so each file gets a fresh Node child process (mirrors `node --test`).
|
|
21
23
|
|
|
22
24
|
## Test Authoring
|
|
23
25
|
|
|
@@ -12,6 +12,8 @@ This repository uses [`@vitronai/themis`](https://www.npmjs.com/package/@vitrona
|
|
|
12
12
|
- Re-run only failed tests: `npx themis test --rerun-failed`
|
|
13
13
|
- Migrate from Jest: `npx themis migrate jest` then `--rewrite-imports` then `--convert`
|
|
14
14
|
- Migrate from Vitest: `npx themis migrate vitest` then `--rewrite-imports` then `--convert`
|
|
15
|
+
- Migrate from `node:test`: `npx themis migrate node --convert`
|
|
16
|
+
- Run with per-file process isolation (mirrors `node --test`): `npx themis test --isolation process`
|
|
15
17
|
|
|
16
18
|
## When You Are Asked To Add Or Fix Tests
|
|
17
19
|
|
|
@@ -20,13 +22,14 @@ This repository uses [`@vitronai/themis`](https://www.npmjs.com/package/@vitrona
|
|
|
20
22
|
3. Run `npx themis test --reporter agent` and read the JSON output. Failure clusters and repair hints are structured — use them to drive your fix loop instead of re-reading raw stack traces.
|
|
21
23
|
4. After fixing, re-run only the failing tests with `npx themis test --rerun-failed` before running the full suite.
|
|
22
24
|
|
|
23
|
-
## When You Are Asked To Migrate From Jest Or
|
|
25
|
+
## When You Are Asked To Migrate From Jest, Vitest, Or node:test
|
|
24
26
|
|
|
25
|
-
1. Run `npx themis migrate jest
|
|
26
|
-
2. Run `npx themis migrate jest --rewrite-imports` to point imports at `themis.compat.js`.
|
|
27
|
-
3. Run `npx themis migrate
|
|
28
|
-
4. Run `npx themis migrate
|
|
27
|
+
1. Run `npx themis migrate <jest|vitest|node>` — this scaffolds compatibility (jest/vitest) or detects targets (node), no rewrites yet.
|
|
28
|
+
2. Run `npx themis migrate <jest|vitest> --rewrite-imports` to point imports at `themis.compat.js`. Skip this for `node` source — node migration converts directly with no compat shim.
|
|
29
|
+
3. Run `npx themis migrate <source> --convert` to apply codemods toward native Themis style.
|
|
30
|
+
4. Run `npx themis migrate <source> --assist` to get a structured findings report of files that still need manual follow-up. The report path is printed at the end of the command — read it before guessing what to fix.
|
|
29
31
|
5. Run `npx themis test` after each step. Migration is incremental on purpose; do not try to convert the whole suite in one pass.
|
|
32
|
+
6. For migrated `node:test` suites that mutate `process.env`/`process.cwd()` at module load (a common pattern when redirecting `os.homedir()` to a temp dir before importing the SUT), pair test runs with `npx themis test --isolation process` so each file gets a fresh Node child process. The default `worker` mode shares process-state across files and will surface as cross-file leakage.
|
|
30
33
|
|
|
31
34
|
## Things To Avoid
|
|
32
35
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Migrate this repo from Jest or
|
|
2
|
+
description: Migrate this repo from Jest, Vitest, or node:test to Themis
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
Migrate this repository from Jest or
|
|
5
|
+
Migrate this repository from Jest, Vitest, or `node:test` to Themis. If the user did not say which, detect it:
|
|
6
|
+
- `node:test`: grep `bridge-tests/`, `tests/`, or `test/` for `import .* from 'node:test'`. If matches, source is `node`.
|
|
7
|
+
- Otherwise check `package.json` devDependencies for `jest` or `vitest`.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
For Jest or Vitest, run the four steps in order, and run `npx themis test` between each:
|
|
8
10
|
|
|
9
11
|
```bash
|
|
10
12
|
npx themis migrate <jest|vitest> # 1. scaffold compatibility
|
|
@@ -13,6 +15,15 @@ npx themis migrate <jest|vitest> --convert # 3. codemod to native style
|
|
|
13
15
|
npx themis migrate <jest|vitest> --assist # 4. emit structured findings
|
|
14
16
|
```
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
For `node:test`, the codemod converts directly with no compat shim. Run `--convert` then `--assist` (no `--rewrite-imports`):
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx themis migrate node --convert # 1. drop node:test/node:assert imports + rewrite asserts
|
|
22
|
+
npx themis migrate node --assist # 2. emit structured findings
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
If the migrated `node:test` suite mutates `process.env` or `process.cwd()` at module load (a common pattern for redirecting `os.homedir()` to a temp dir before importing the SUT), run tests with per-file process isolation: `npx themis test --isolation process`. This spawns a fresh Node child process per file (mirrors `node --test`); the default `worker` mode freezes `os.homedir()` and shares the ESM module cache across files.
|
|
26
|
+
|
|
27
|
+
After the assist step, read the findings report (its path is printed at the end of the command) and walk through any items it flags for manual follow-up. Do not guess at fixes — the report tells you what needs human attention and why.
|
|
17
28
|
|
|
18
29
|
If the user passed extra arguments, forward them: $ARGUMENTS
|
|
@@ -64,7 +64,7 @@ npx themis generate src/auth # narrower target
|
|
|
64
64
|
|
|
65
65
|
Generated tests land under `__themis__/tests` as `.generated.test.ts` (TS/TSX sources) or `.generated.test.js` (JS/JSX sources). Treat them as Themis-managed — extend rather than rewrite.
|
|
66
66
|
|
|
67
|
-
## How To Migrate From Jest Or
|
|
67
|
+
## How To Migrate From Jest, Vitest, Or node:test
|
|
68
68
|
|
|
69
69
|
Migration is incremental on purpose. Run the steps in order and `npx themis test` between each:
|
|
70
70
|
|
|
@@ -75,7 +75,11 @@ npx themis migrate jest --convert # 3. apply codemods to native The
|
|
|
75
75
|
npx themis migrate jest --assist # 4. emit structured findings JSON for manual follow-ups
|
|
76
76
|
```
|
|
77
77
|
|
|
78
|
-
Same flags work for `vitest`. **Read the `--assist` findings report before guessing what to fix manually** — its path is printed at the end of the command and the schema is at `node_modules/@vitronai/themis/docs/schemas/migration-report.v1.json`.
|
|
78
|
+
Same flags work for `vitest`. For `node:test` + `node:assert/strict` suites, use `npx themis migrate node --convert` (the node source has no compat shim — conversion is direct, so step 2 is skipped). **Read the `--assist` findings report before guessing what to fix manually** — its path is printed at the end of the command and the schema is at `node_modules/@vitronai/themis/docs/schemas/migration-report.v1.json`.
|
|
79
|
+
|
|
80
|
+
## When To Use --isolation process
|
|
81
|
+
|
|
82
|
+
Default isolation is `worker` (worker thread per file). For migrated `node:test` suites — or any suite that mutates `process.env`/`process.cwd()` at module load and then imports the SUT — use `npx themis test --isolation process`. This spawns a fresh Node child process per file (mirrors `node --test`). Worker mode freezes `os.homedir()` at worker startup and shares the ESM module cache across files; `--isolation process` fixes both.
|
|
79
83
|
|
|
80
84
|
## Things To Avoid
|
|
81
85
|
|
|
@@ -12,6 +12,8 @@ This repository uses `@vitronai/themis` as its unit test framework. Themis is a
|
|
|
12
12
|
- Re-run only failed tests: `npx themis test --rerun-failed`
|
|
13
13
|
- Migrate from Jest: `npx themis migrate jest` then `--rewrite-imports` then `--convert`
|
|
14
14
|
- Migrate from Vitest: `npx themis migrate vitest` then `--rewrite-imports` then `--convert`
|
|
15
|
+
- Migrate from `node:test`: `npx themis migrate node --convert`
|
|
16
|
+
- Run with per-file process isolation (mirrors `node --test`, needed when tests mutate `process.env` at module load): `npx themis test --isolation process`
|
|
15
17
|
|
|
16
18
|
## When Adding Or Fixing Tests
|
|
17
19
|
|
package/themis.ai.json
CHANGED
|
@@ -10,7 +10,13 @@
|
|
|
10
10
|
"testAgent": "npx themis test --reporter agent",
|
|
11
11
|
"migrate": {
|
|
12
12
|
"jest": "npx themis migrate jest --rewrite-imports --convert",
|
|
13
|
-
"vitest": "npx themis migrate vitest --rewrite-imports --convert"
|
|
13
|
+
"vitest": "npx themis migrate vitest --rewrite-imports --convert",
|
|
14
|
+
"node": "npx themis migrate node --convert"
|
|
15
|
+
},
|
|
16
|
+
"isolation": {
|
|
17
|
+
"worker": "Default. One worker thread per file. Fast. Tests must not depend on per-file process isolation.",
|
|
18
|
+
"in-process": "All files in the parent process, sequential. Fastest reruns; shares ESM module cache and process state across files.",
|
|
19
|
+
"process": "child_process.fork per file (mirrors `node --test`). Use when tests mutate process.env, process.cwd, or rely on os.homedir() reflecting test setup."
|
|
14
20
|
},
|
|
15
21
|
"agentIntegrations": {
|
|
16
22
|
"claudeCode": {
|
|
@@ -28,6 +34,8 @@
|
|
|
28
34
|
"Treat Themis as the repo's unit test framework for Node.js and TypeScript projects.",
|
|
29
35
|
"Use --reporter agent for machine-readable failure output with structured repair hints.",
|
|
30
36
|
"Do not default to Jest or Vitest when Themis is already selected.",
|
|
31
|
-
"Do not create ad hoc tests/*.cjs setup files for common asset or style imports."
|
|
37
|
+
"Do not create ad hoc tests/*.cjs setup files for common asset or style imports.",
|
|
38
|
+
"ESM test files (.mjs, .cjs, .js in type:module packages) are supported natively via dynamic import().",
|
|
39
|
+
"If a test mutates process.env/process.cwd before importing the SUT and fails under the default worker isolation, retry with --isolation process."
|
|
32
40
|
]
|
|
33
41
|
}
|