@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 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 `vitest` with codemods
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 isolation or a zero IPC in-process execution mode. |
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 Vitest
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` instead of `jest` for Vitest suites.
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, setup, compat bridge, and migration report.
20
- - `--rewrite-imports`: point framework imports at `themis.compat.js`.
21
- - `--convert`: remove common Jest/Vitest imports and rewrite common matcher/test patterns into Themis-native forms.
22
- - `--assist`: run the safe rewrite and conversion passes together, then report leftover Jest/Vitest-only helpers that still need manual follow-up.
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.2.2",
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
- console.log('Runtime compatibility is enabled for @jest/globals, vitest, and @testing-library/react imports.');
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 "vitest".`);
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
- fs.mkdirSync(path.dirname(setupPath), { recursive: true });
73
- if (!fs.existsSync(setupPath)) {
74
- fs.writeFileSync(setupPath, buildMigrationSetupSource(source), 'utf8');
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
- if (!fs.existsSync(compatPath)) {
78
- fs.writeFileSync(compatPath, buildMigrationCompatSource(), 'utf8');
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 = convertMigrationSourceText(original);
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
 
@@ -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 !SUPPORTED_SOURCE_EXTENSIONS.includes(extension)
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
- : await runFilesInWorkers(files, options);
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
- return options.isolation === 'in-process' ? 'in-process' : 'worker';
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
- - Use `--rewrite-imports` to point compatible imports at `themis.compat.js`.
20
- - Use `--convert` to rewrite common Jest/Vitest patterns toward native Themis style.
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 Vitest
25
+ ## When You Are Asked To Migrate From Jest, Vitest, Or node:test
24
26
 
25
- 1. Run `npx themis migrate jest` (or `vitest`) — this scaffolds compatibility, no rewrites yet.
26
- 2. Run `npx themis migrate jest --rewrite-imports` to point imports at `themis.compat.js`.
27
- 3. Run `npx themis migrate jest --convert` to apply codemods toward native Themis style.
28
- 4. Run `npx themis migrate jest --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.
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 Vitest to Themis
2
+ description: Migrate this repo from Jest, Vitest, or node:test to Themis
3
3
  ---
4
4
 
5
- Migrate this repository from Jest or Vitest to Themis. If the user did not say which, detect it: check `package.json` devDependencies for `jest` or `vitest`.
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
- Run the four steps in order, and run `npx themis test` between each step to confirm the suite is still green:
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
- After step 4, 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.
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 Vitest
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
  }