@vitronai/themis 1.2.1 → 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/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) {