@vitronai/themis 0.1.0-beta.0 → 0.1.0-beta.2

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 ADDED
@@ -0,0 +1,280 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { DEFAULT_CONFIG, loadConfig } = require('./config');
4
+
5
+ const SUPPORTED_MIGRATION_SOURCES = new Set(['jest', 'vitest']);
6
+ const THEMIS_SETUP_FILE = path.join('tests', 'setup.themis.js');
7
+ const THEMIS_COMPAT_FILE = 'themis.compat.js';
8
+ const MIGRATION_REPORT_FILE = path.join('.themis', 'migration-report.json');
9
+ const SCANNABLE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
10
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.themis']);
11
+
12
+ function runMigrate(cwd, framework, options = {}) {
13
+ const source = String(framework || '').trim().toLowerCase();
14
+ if (!SUPPORTED_MIGRATION_SOURCES.has(source)) {
15
+ throw new Error(`Unsupported migrate source: ${String(framework)}. Use "jest" or "vitest".`);
16
+ }
17
+
18
+ const projectRoot = path.resolve(cwd || process.cwd());
19
+ const configPath = path.join(projectRoot, 'themis.config.json');
20
+ const packageJsonPath = path.join(projectRoot, 'package.json');
21
+ const setupPath = path.join(projectRoot, THEMIS_SETUP_FILE);
22
+ const compatPath = path.join(projectRoot, THEMIS_COMPAT_FILE);
23
+ const reportPath = path.join(projectRoot, MIGRATION_REPORT_FILE);
24
+
25
+ const existingConfig = fs.existsSync(configPath) ? loadConfig(projectRoot) : { ...DEFAULT_CONFIG, setupFiles: [], testIgnore: [] };
26
+ const nextSetupFiles = Array.isArray(existingConfig.setupFiles) ? [...existingConfig.setupFiles] : [];
27
+ if (!nextSetupFiles.includes(THEMIS_SETUP_FILE)) {
28
+ nextSetupFiles.push(THEMIS_SETUP_FILE);
29
+ }
30
+
31
+ const nextConfig = {
32
+ ...existingConfig,
33
+ setupFiles: nextSetupFiles
34
+ };
35
+
36
+ fs.mkdirSync(path.dirname(setupPath), { recursive: true });
37
+ if (!fs.existsSync(setupPath)) {
38
+ fs.writeFileSync(setupPath, buildMigrationSetupSource(source), 'utf8');
39
+ }
40
+
41
+ if (!fs.existsSync(compatPath)) {
42
+ fs.writeFileSync(compatPath, buildMigrationCompatSource(), 'utf8');
43
+ }
44
+
45
+ fs.writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, 'utf8');
46
+
47
+ let packageUpdated = false;
48
+ if (fs.existsSync(packageJsonPath)) {
49
+ const parsed = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
50
+ const scripts = parsed.scripts && typeof parsed.scripts === 'object' ? { ...parsed.scripts } : {};
51
+ if (!scripts['test:themis']) {
52
+ scripts['test:themis'] = 'themis test';
53
+ parsed.scripts = scripts;
54
+ fs.writeFileSync(packageJsonPath, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8');
55
+ packageUpdated = true;
56
+ }
57
+ }
58
+
59
+ const scan = scanMigrationFiles(projectRoot);
60
+ const rewriteSummary = options.rewriteImports
61
+ ? rewriteMigrationImports(projectRoot, scan.matches, compatPath)
62
+ : { rewrittenFiles: [], rewrittenImports: 0 };
63
+ const report = buildMigrationReport(projectRoot, source, scan.matches, rewriteSummary);
64
+ fs.mkdirSync(path.dirname(reportPath), { recursive: true });
65
+ fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
66
+
67
+ return {
68
+ source,
69
+ configPath,
70
+ setupPath,
71
+ compatPath,
72
+ packageJsonPath: fs.existsSync(packageJsonPath) ? packageJsonPath : null,
73
+ packageUpdated,
74
+ reportPath,
75
+ report,
76
+ rewriteImports: Boolean(options.rewriteImports),
77
+ rewrittenFiles: rewriteSummary.rewrittenFiles
78
+ };
79
+ }
80
+
81
+ function buildMigrationSetupSource(source) {
82
+ return `// Themis migration bridge for ${source} suites.
83
+ // Themis runtime supports imports from "@jest/globals", "vitest",
84
+ // and "@testing-library/react" directly, so this file can stay minimal.
85
+
86
+ afterEach(() => {
87
+ restoreAllMocks();
88
+ cleanup();
89
+ });
90
+ `;
91
+ }
92
+
93
+ function buildMigrationCompatSource() {
94
+ return `const themisCompat = {
95
+ describe,
96
+ test,
97
+ it: test,
98
+ expect,
99
+ beforeAll,
100
+ beforeEach,
101
+ afterEach,
102
+ afterAll,
103
+ render,
104
+ screen,
105
+ fireEvent,
106
+ waitFor,
107
+ cleanup,
108
+ act: async (callback) => (typeof callback === 'function' ? callback() : undefined)
109
+ };
110
+
111
+ const jestLike = {
112
+ fn,
113
+ spyOn,
114
+ mock,
115
+ unmock,
116
+ clearAllMocks,
117
+ resetAllMocks,
118
+ restoreAllMocks,
119
+ useFakeTimers,
120
+ useRealTimers,
121
+ advanceTimersByTime,
122
+ runAllTimers
123
+ };
124
+
125
+ module.exports = {
126
+ ...themisCompat,
127
+ jest: jestLike,
128
+ vi: jestLike
129
+ };
130
+ `;
131
+ }
132
+
133
+ function scanMigrationFiles(projectRoot) {
134
+ const files = [];
135
+ walkProject(projectRoot, files);
136
+ const matches = [];
137
+
138
+ for (const file of files) {
139
+ const sourceText = fs.readFileSync(file, 'utf8');
140
+ const relativeFile = path.relative(projectRoot, file).split(path.sep).join('/');
141
+ const detected = detectMigrationImports(sourceText);
142
+ if (detected.length === 0) {
143
+ continue;
144
+ }
145
+
146
+ matches.push({
147
+ file: relativeFile,
148
+ imports: detected
149
+ });
150
+ }
151
+
152
+ return {
153
+ files,
154
+ matches
155
+ };
156
+ }
157
+
158
+ function buildMigrationReport(projectRoot, source, matches, rewriteSummary = { rewrittenFiles: [], rewrittenImports: 0 }) {
159
+ return {
160
+ schema: 'themis.migration.report.v1',
161
+ source,
162
+ createdAt: new Date().toISOString(),
163
+ summary: {
164
+ matchedFiles: matches.length,
165
+ jestGlobals: matches.filter((entry) => entry.imports.includes('@jest/globals')).length,
166
+ vitest: matches.filter((entry) => entry.imports.includes('vitest')).length,
167
+ testingLibraryReact: matches.filter((entry) => entry.imports.includes('@testing-library/react')).length,
168
+ rewrittenFiles: Array.isArray(rewriteSummary.rewrittenFiles) ? rewriteSummary.rewrittenFiles.length : 0,
169
+ rewrittenImports: Number(rewriteSummary.rewrittenImports || 0)
170
+ },
171
+ files: matches,
172
+ nextActions: [
173
+ 'Run npx themis test to execute migrated suites under the Themis runtime.',
174
+ 'Replace any unsupported Jest/Vitest-only helpers with Themis built-ins or project setup utilities.',
175
+ 'Use npx themis generate src for source-driven unit-layer coverage alongside migrated suites.'
176
+ ],
177
+ rewrites: Array.isArray(rewriteSummary.rewrittenFiles)
178
+ ? rewriteSummary.rewrittenFiles
179
+ : []
180
+ };
181
+ }
182
+
183
+ function rewriteMigrationImports(projectRoot, matches, compatPath) {
184
+ const rewrittenFiles = [];
185
+ let rewrittenImports = 0;
186
+
187
+ for (const match of matches) {
188
+ const absoluteFile = path.join(projectRoot, match.file);
189
+ const original = fs.readFileSync(absoluteFile, 'utf8');
190
+ const compatSpecifier = toPortableRelativeSpecifier(path.relative(path.dirname(absoluteFile), compatPath));
191
+ const rewritten = rewriteMigrationSourceText(original, compatSpecifier);
192
+ if (rewritten.source !== original) {
193
+ fs.writeFileSync(absoluteFile, rewritten.source, 'utf8');
194
+ rewrittenFiles.push(match.file);
195
+ rewrittenImports += rewritten.rewrites;
196
+ }
197
+ }
198
+
199
+ return {
200
+ rewrittenFiles,
201
+ rewrittenImports
202
+ };
203
+ }
204
+
205
+ function rewriteMigrationSourceText(sourceText, compatSpecifier) {
206
+ const patterns = [
207
+ /(['"])@jest\/globals\1/g,
208
+ /(['"])vitest\1/g,
209
+ /(['"])@testing-library\/react\1/g
210
+ ];
211
+
212
+ let rewrites = 0;
213
+ let nextSource = sourceText;
214
+ for (const pattern of patterns) {
215
+ nextSource = nextSource.replace(pattern, (match, quote) => {
216
+ rewrites += 1;
217
+ return `${quote}${compatSpecifier}${quote}`;
218
+ });
219
+ }
220
+
221
+ return {
222
+ source: nextSource,
223
+ rewrites
224
+ };
225
+ }
226
+
227
+ function walkProject(dir, files) {
228
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
229
+ for (const entry of entries) {
230
+ if (entry.isDirectory()) {
231
+ if (IGNORED_DIRECTORIES.has(entry.name)) {
232
+ continue;
233
+ }
234
+ walkProject(path.join(dir, entry.name), files);
235
+ continue;
236
+ }
237
+
238
+ if (!entry.isFile()) {
239
+ continue;
240
+ }
241
+
242
+ const extension = path.extname(entry.name).toLowerCase();
243
+ if (!SCANNABLE_EXTENSIONS.has(extension)) {
244
+ continue;
245
+ }
246
+
247
+ files.push(path.join(dir, entry.name));
248
+ }
249
+ }
250
+
251
+ function detectMigrationImports(sourceText) {
252
+ const matches = [];
253
+ if (hasModuleReference(sourceText, '@jest/globals')) {
254
+ matches.push('@jest/globals');
255
+ }
256
+ if (hasModuleReference(sourceText, 'vitest')) {
257
+ matches.push('vitest');
258
+ }
259
+ if (hasModuleReference(sourceText, '@testing-library/react')) {
260
+ matches.push('@testing-library/react');
261
+ }
262
+ return matches;
263
+ }
264
+
265
+ function hasModuleReference(sourceText, moduleName) {
266
+ const escaped = moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
267
+ return new RegExp(`(?:import\\s+[^;]*?from\\s+['"]${escaped}['"]|import\\s*\\(\\s*['"]${escaped}['"]\\s*\\)|require\\(\\s*['"]${escaped}['"]\\s*\\))`).test(sourceText);
268
+ }
269
+
270
+ function toPortableRelativeSpecifier(relativePath) {
271
+ const normalized = String(relativePath || '').split(path.sep).join('/');
272
+ if (normalized.startsWith('.')) {
273
+ return normalized;
274
+ }
275
+ return `./${normalized}`;
276
+ }
277
+
278
+ module.exports = {
279
+ runMigrate
280
+ };
@@ -13,6 +13,9 @@ const DEFAULT_TS_COMPILER_OPTIONS = {
13
13
 
14
14
  function createModuleLoader(options = {}) {
15
15
  const projectRoot = safeRealpath(path.resolve(options.cwd || process.cwd()));
16
+ const virtualModules = options.virtualModules && typeof options.virtualModules === 'object'
17
+ ? options.virtualModules
18
+ : {};
16
19
  const tsconfigPath = options.tsconfigPath === null
17
20
  ? null
18
21
  : resolveTsconfigPath(projectRoot, options.tsconfigPath);
@@ -53,6 +56,10 @@ function createModuleLoader(options = {}) {
53
56
  }
54
57
 
55
58
  Module._resolveFilename = function themisResolveFilename(request, parent, isMain, resolutionOptions) {
59
+ if (Object.prototype.hasOwnProperty.call(virtualModules, request)) {
60
+ return request;
61
+ }
62
+
56
63
  const aliasedRequest = resolveConfiguredRequest({
57
64
  request,
58
65
  parentFile: parent && parent.filename,
@@ -70,13 +77,19 @@ function createModuleLoader(options = {}) {
70
77
  };
71
78
 
72
79
  Module._load = function themisModuleLoad(request, parent, isMain) {
80
+ if (Object.prototype.hasOwnProperty.call(virtualModules, request)) {
81
+ const virtualValue = virtualModules[request];
82
+ return typeof virtualValue === 'function' ? virtualValue() : virtualValue;
83
+ }
84
+
73
85
  const resolvedRequest = resolveRequestValue({
74
86
  request,
75
87
  parentFile: parent && parent.filename,
76
88
  projectRoot,
77
89
  compilerContext,
78
90
  originalResolveFilename,
79
- isMain
91
+ isMain,
92
+ virtualModules
80
93
  });
81
94
 
82
95
  if (mockRegistry.has(resolvedRequest)) {
@@ -116,7 +129,8 @@ function createModuleLoader(options = {}) {
116
129
  parentFile,
117
130
  projectRoot,
118
131
  compilerContext,
119
- originalResolveFilename
132
+ originalResolveFilename,
133
+ virtualModules
120
134
  });
121
135
  },
122
136
  registerMock(request, parentFile, factoryOrExports) {
@@ -158,8 +172,13 @@ function resolveRequestValue({
158
172
  projectRoot,
159
173
  compilerContext,
160
174
  originalResolveFilename,
161
- isMain = false
175
+ isMain = false,
176
+ virtualModules = null
162
177
  }) {
178
+ if (virtualModules && Object.prototype.hasOwnProperty.call(virtualModules, request)) {
179
+ return request;
180
+ }
181
+
163
182
  const normalizedParent = parentFile ? safeRealpath(path.resolve(parentFile)) : path.join(projectRoot, '__themis_entry__.js');
164
183
  const parentModule = {
165
184
  id: normalizedParent,
package/src/reporter.js CHANGED
@@ -66,7 +66,8 @@ function printAgent(result) {
66
66
  lastRun: '.themis/last-run.json',
67
67
  failedTests: '.themis/failed-tests.json',
68
68
  runDiff: '.themis/run-diff.json',
69
- runHistory: '.themis/run-history.json'
69
+ runHistory: '.themis/run-history.json',
70
+ fixHandoff: '.themis/fix-handoff.json'
70
71
  };
71
72
 
72
73
  const payload = {
@@ -84,8 +85,8 @@ function printAgent(result) {
84
85
  hints: {
85
86
  rerunFailed: 'npx themis test --rerun-failed',
86
87
  targetedRerun: 'npx themis test --match "<regex>"',
87
- updateSnapshots: 'npx themis test -u',
88
- diffLastRun: 'cat .themis/run-diff.json'
88
+ diffLastRun: 'cat .themis/run-diff.json',
89
+ repairGenerated: 'cat .themis/fix-handoff.json'
89
90
  }
90
91
  };
91
92
 
package/src/runner.js CHANGED
@@ -1,20 +1,19 @@
1
+ const fs = require('fs');
1
2
  const path = require('path');
2
3
  const { Worker } = require('worker_threads');
3
4
  const { performance } = require('perf_hooks');
5
+ const { collectAndRun } = require('./runtime');
6
+
7
+ const inProcessResultCache = new Map();
4
8
 
5
9
  async function runTests(files, options = {}) {
6
10
  const startedAt = performance.now();
7
11
  const startedAtIso = new Date().toISOString();
8
- const maxWorkers = resolveMaxWorkers(options.maxWorkers);
9
- const queue = [...files];
10
- const workers = [];
11
- const fileResults = [];
12
-
13
- for (let i = 0; i < Math.min(maxWorkers, files.length); i += 1) {
14
- workers.push(runNext(queue, fileResults, options));
15
- }
16
-
17
- await Promise.all(workers);
12
+ const isolation = resolveIsolationMode(options);
13
+ const maxWorkers = isolation === 'in-process' ? 1 : resolveMaxWorkers(options.maxWorkers);
14
+ const fileResults = isolation === 'in-process'
15
+ ? await runFilesInProcess(files, options)
16
+ : await runFilesInWorkers(files, options);
18
17
 
19
18
  fileResults.sort((a, b) => a.file.localeCompare(b.file));
20
19
 
@@ -41,6 +40,27 @@ async function runTests(files, options = {}) {
41
40
  };
42
41
  }
43
42
 
43
+ async function runFilesInWorkers(files, options) {
44
+ const queue = [...files];
45
+ const workers = [];
46
+ const fileResults = [];
47
+
48
+ for (let i = 0; i < Math.min(resolveMaxWorkers(options.maxWorkers), files.length); i += 1) {
49
+ workers.push(runNext(queue, fileResults, options));
50
+ }
51
+
52
+ await Promise.all(workers);
53
+ return fileResults;
54
+ }
55
+
56
+ async function runFilesInProcess(files, options) {
57
+ const fileResults = [];
58
+ for (const file of files) {
59
+ fileResults.push(await runFileInProcess(file, options));
60
+ }
61
+ return fileResults;
62
+ }
63
+
44
64
  async function runNext(queue, fileResults, options) {
45
65
  while (queue.length > 0) {
46
66
  const file = queue.shift();
@@ -63,8 +83,7 @@ function runFileInWorker(file, options = {}) {
63
83
  cwd: options.cwd || process.cwd(),
64
84
  environment: options.environment || 'node',
65
85
  setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
66
- tsconfigPath: options.tsconfigPath === undefined ? undefined : options.tsconfigPath,
67
- updateSnapshots: Boolean(options.updateSnapshots)
86
+ tsconfigPath: options.tsconfigPath === undefined ? undefined : options.tsconfigPath
68
87
  }
69
88
  });
70
89
 
@@ -155,6 +174,47 @@ function runFileInWorker(file, options = {}) {
155
174
  });
156
175
  }
157
176
 
177
+ async function runFileInProcess(file, options = {}) {
178
+ const cacheKey = options.cache ? buildInProcessCacheKey(file, options) : null;
179
+ if (cacheKey && inProcessResultCache.has(cacheKey)) {
180
+ return cloneResult(inProcessResultCache.get(cacheKey));
181
+ }
182
+
183
+ const result = await collectAndRun(file, options);
184
+ if (cacheKey) {
185
+ inProcessResultCache.set(cacheKey, cloneResult(result));
186
+ }
187
+ return result;
188
+ }
189
+
190
+ function buildInProcessCacheKey(file, options) {
191
+ const stats = fs.statSync(file);
192
+ return JSON.stringify({
193
+ file: path.resolve(file),
194
+ size: stats.size,
195
+ mtimeMs: Math.round(stats.mtimeMs),
196
+ match: options.match || null,
197
+ allowedFullNames: Array.isArray(options.allowedFullNames) ? options.allowedFullNames : null,
198
+ noMemes: Boolean(options.noMemes),
199
+ cwd: options.cwd || process.cwd(),
200
+ environment: options.environment || 'node',
201
+ setupFiles: Array.isArray(options.setupFiles) ? options.setupFiles : [],
202
+ tsconfigPath: options.tsconfigPath === undefined ? '__default__' : options.tsconfigPath
203
+ });
204
+ }
205
+
206
+ function cloneResult(result) {
207
+ return JSON.parse(JSON.stringify(result));
208
+ }
209
+
210
+ function clearRunCache() {
211
+ inProcessResultCache.clear();
212
+ }
213
+
214
+ function resolveIsolationMode(options = {}) {
215
+ return options.isolation === 'in-process' ? 'in-process' : 'worker';
216
+ }
217
+
158
218
  function resolveMaxWorkers(value) {
159
219
  const parsed = Number(value);
160
220
  if (!Number.isFinite(parsed) || parsed < 1) {
@@ -164,5 +224,6 @@ function resolveMaxWorkers(value) {
164
224
  }
165
225
 
166
226
  module.exports = {
167
- runTests
227
+ runTests,
228
+ clearRunCache
168
229
  };