@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/CHANGELOG.md +22 -0
- package/README.md +90 -467
- package/docs/api.md +4 -3
- package/docs/migration.md +34 -6
- package/docs/schemas/migration-report.v1.json +5 -1
- package/docs/tutorial-claude-code.md +230 -0
- 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/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) {
|