@vitronai/themis 0.1.0-beta.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.
@@ -0,0 +1,90 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const util = require('util');
4
+
5
+ function createSnapshotState(filePath, options = {}) {
6
+ const snapshotDir = path.join(path.dirname(filePath), '__snapshots__');
7
+ const snapshotFile = path.join(snapshotDir, `${path.basename(filePath)}.snapshots.json`);
8
+ const existing = readSnapshotFile(snapshotFile);
9
+ const nextSnapshots = { ...existing };
10
+ const counters = new Map();
11
+ let dirty = false;
12
+ let currentTestName = null;
13
+
14
+ return {
15
+ setCurrentTestName(testName) {
16
+ currentTestName = testName;
17
+ },
18
+ clearCurrentTestName() {
19
+ currentTestName = null;
20
+ },
21
+ matchSnapshot(received, snapshotName) {
22
+ if (!currentTestName) {
23
+ throw new Error('toMatchSnapshot() must be called while a test is running');
24
+ }
25
+
26
+ const baseKey = snapshotName
27
+ ? `${currentTestName}: ${String(snapshotName)}`
28
+ : currentTestName;
29
+ const nextIndex = (counters.get(baseKey) || 0) + 1;
30
+ counters.set(baseKey, nextIndex);
31
+ const snapshotKey = nextIndex === 1 ? baseKey : `${baseKey} (${nextIndex})`;
32
+ const serialized = serializeSnapshot(received);
33
+
34
+ if (!Object.prototype.hasOwnProperty.call(existing, snapshotKey)) {
35
+ nextSnapshots[snapshotKey] = serialized;
36
+ dirty = true;
37
+ return;
38
+ }
39
+
40
+ if (existing[snapshotKey] !== serialized) {
41
+ if (options.updateSnapshots) {
42
+ nextSnapshots[snapshotKey] = serialized;
43
+ dirty = true;
44
+ return;
45
+ }
46
+
47
+ throw new Error(
48
+ `Snapshot mismatch for ${snapshotKey}\n\nExpected:\n${existing[snapshotKey]}\n\nReceived:\n${serialized}`
49
+ );
50
+ }
51
+ },
52
+ save() {
53
+ if (!dirty) {
54
+ return null;
55
+ }
56
+
57
+ fs.mkdirSync(snapshotDir, { recursive: true });
58
+ fs.writeFileSync(snapshotFile, `${JSON.stringify(nextSnapshots, null, 2)}\n`, 'utf8');
59
+ return snapshotFile;
60
+ },
61
+ path: snapshotFile
62
+ };
63
+ }
64
+
65
+ function readSnapshotFile(snapshotFile) {
66
+ if (!fs.existsSync(snapshotFile)) {
67
+ return {};
68
+ }
69
+
70
+ return JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
71
+ }
72
+
73
+ function serializeSnapshot(value) {
74
+ if (typeof value === 'string') {
75
+ return value;
76
+ }
77
+
78
+ return util.inspect(value, {
79
+ depth: 20,
80
+ colors: false,
81
+ sorted: true,
82
+ compact: false,
83
+ breakLength: 80,
84
+ maxArrayLength: null
85
+ });
86
+ }
87
+
88
+ module.exports = {
89
+ createSnapshotState
90
+ };
@@ -0,0 +1,98 @@
1
+ function buildStabilityReport(runResults) {
2
+ if (!Array.isArray(runResults) || runResults.length === 0) {
3
+ return {
4
+ runs: 0,
5
+ summary: {
6
+ stablePass: 0,
7
+ stableFail: 0,
8
+ unstable: 0
9
+ },
10
+ tests: []
11
+ };
12
+ }
13
+
14
+ const runCount = runResults.length;
15
+ const byKey = new Map();
16
+
17
+ for (let runIndex = 0; runIndex < runCount; runIndex += 1) {
18
+ const result = runResults[runIndex] || {};
19
+ const files = Array.isArray(result.files) ? result.files : [];
20
+ for (const file of files) {
21
+ const tests = Array.isArray(file.tests) ? file.tests : [];
22
+ for (const test of tests) {
23
+ if (test.status === 'skipped') {
24
+ continue;
25
+ }
26
+
27
+ const key = `${file.file}::${test.fullName}`;
28
+ let entry = byKey.get(key);
29
+ if (!entry) {
30
+ entry = {
31
+ file: file.file,
32
+ testName: test.name,
33
+ fullName: test.fullName,
34
+ statuses: new Array(runCount).fill('missing')
35
+ };
36
+ byKey.set(key, entry);
37
+ }
38
+ entry.statuses[runIndex] = test.status;
39
+ }
40
+ }
41
+ }
42
+
43
+ const tests = [...byKey.values()].map((entry) => {
44
+ const classification = classifyStability(entry.statuses);
45
+ return {
46
+ file: entry.file,
47
+ testName: entry.testName,
48
+ fullName: entry.fullName,
49
+ statuses: entry.statuses,
50
+ classification
51
+ };
52
+ });
53
+
54
+ tests.sort((a, b) => {
55
+ const fileCompare = a.file.localeCompare(b.file);
56
+ if (fileCompare !== 0) {
57
+ return fileCompare;
58
+ }
59
+ return a.fullName.localeCompare(b.fullName);
60
+ });
61
+
62
+ const summary = {
63
+ stablePass: tests.filter((entry) => entry.classification === 'stable_pass').length,
64
+ stableFail: tests.filter((entry) => entry.classification === 'stable_fail').length,
65
+ unstable: tests.filter((entry) => entry.classification === 'unstable').length
66
+ };
67
+
68
+ return {
69
+ runs: runCount,
70
+ summary,
71
+ tests
72
+ };
73
+ }
74
+
75
+ function classifyStability(statuses) {
76
+ if (!Array.isArray(statuses) || statuses.length === 0) {
77
+ return 'unstable';
78
+ }
79
+ if (statuses.every((status) => status === 'passed')) {
80
+ return 'stable_pass';
81
+ }
82
+ if (statuses.every((status) => status === 'failed')) {
83
+ return 'stable_fail';
84
+ }
85
+ return 'unstable';
86
+ }
87
+
88
+ function hasStabilityBreaches(report) {
89
+ if (!report || !report.summary) {
90
+ return false;
91
+ }
92
+ return report.summary.stableFail > 0 || report.summary.unstable > 0;
93
+ }
94
+
95
+ module.exports = {
96
+ buildStabilityReport,
97
+ hasStabilityBreaches
98
+ };
@@ -0,0 +1,201 @@
1
+ const path = require('path');
2
+ const util = require('util');
3
+
4
+ function createTestUtils(options = {}) {
5
+ const activeMocks = new Set();
6
+ const activeSpies = new Set();
7
+ const moduleLoader = options.moduleLoader;
8
+
9
+ function fn(implementation) {
10
+ const mockFn = createMockFunction(implementation);
11
+ activeMocks.add(mockFn);
12
+ return mockFn;
13
+ }
14
+
15
+ function spyOn(target, methodName) {
16
+ if (!target || typeof target !== 'object') {
17
+ throw new Error('spyOn expects an object target');
18
+ }
19
+
20
+ const original = target[methodName];
21
+ if (typeof original !== 'function') {
22
+ throw new Error(`spyOn expects ${String(methodName)} to be a function`);
23
+ }
24
+
25
+ const spy = fn(function spiedMethod(...args) {
26
+ return original.apply(this, args);
27
+ });
28
+
29
+ spy.mockRestore = () => {
30
+ target[methodName] = original;
31
+ activeSpies.delete(spy);
32
+ return spy;
33
+ };
34
+
35
+ target[methodName] = spy;
36
+ activeSpies.add(spy);
37
+ return spy;
38
+ }
39
+
40
+ function mock(request, factoryOrExports) {
41
+ if (!moduleLoader) {
42
+ throw new Error('mock(...) is unavailable outside the Themis runtime');
43
+ }
44
+
45
+ const callerFile = resolveCallerFile();
46
+ const normalizedFactory = factoryOrExports === undefined ? {} : factoryOrExports;
47
+ moduleLoader.registerMock(request, callerFile, normalizedFactory);
48
+ }
49
+
50
+ function unmock(request) {
51
+ if (!moduleLoader) {
52
+ throw new Error('unmock(...) is unavailable outside the Themis runtime');
53
+ }
54
+
55
+ const callerFile = resolveCallerFile();
56
+ moduleLoader.unregisterMock(request, callerFile);
57
+ }
58
+
59
+ function clearAllMocks() {
60
+ for (const mockFn of activeMocks) {
61
+ mockFn.mockClear();
62
+ }
63
+ }
64
+
65
+ function resetAllMocks() {
66
+ for (const mockFn of activeMocks) {
67
+ mockFn.mockReset();
68
+ }
69
+
70
+ if (moduleLoader) {
71
+ moduleLoader.clearModuleMocks();
72
+ }
73
+ }
74
+
75
+ function restoreAllMocks() {
76
+ for (const spy of [...activeSpies]) {
77
+ if (typeof spy.mockRestore === 'function') {
78
+ spy.mockRestore();
79
+ }
80
+ }
81
+ }
82
+
83
+ return {
84
+ fn,
85
+ spyOn,
86
+ mock,
87
+ unmock,
88
+ clearAllMocks,
89
+ resetAllMocks,
90
+ restoreAllMocks
91
+ };
92
+ }
93
+
94
+ function createMockFunction(implementation) {
95
+ const state = {
96
+ calls: [],
97
+ results: [],
98
+ implementation: implementation
99
+ };
100
+
101
+ function mockFn(...args) {
102
+ state.calls.push(args);
103
+ try {
104
+ const value = state.implementation ? state.implementation.apply(this, args) : undefined;
105
+ state.results.push({ type: 'return', value });
106
+ return value;
107
+ } catch (error) {
108
+ state.results.push({ type: 'throw', value: error });
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ Object.defineProperty(mockFn, '_isThemisMockFunction', {
114
+ configurable: false,
115
+ enumerable: false,
116
+ writable: false,
117
+ value: true
118
+ });
119
+
120
+ mockFn.mock = state;
121
+ mockFn.mockImplementation = (nextImplementation) => {
122
+ state.implementation = nextImplementation;
123
+ return mockFn;
124
+ };
125
+ mockFn.mockReturnValue = (value) => {
126
+ state.implementation = () => value;
127
+ return mockFn;
128
+ };
129
+ mockFn.mockResolvedValue = (value) => {
130
+ state.implementation = () => Promise.resolve(value);
131
+ return mockFn;
132
+ };
133
+ mockFn.mockRejectedValue = (value) => {
134
+ state.implementation = () => Promise.reject(value);
135
+ return mockFn;
136
+ };
137
+ mockFn.mockClear = () => {
138
+ state.calls.length = 0;
139
+ state.results.length = 0;
140
+ return mockFn;
141
+ };
142
+ mockFn.mockReset = () => {
143
+ state.calls.length = 0;
144
+ state.results.length = 0;
145
+ state.implementation = undefined;
146
+ return mockFn;
147
+ };
148
+ mockFn.getMockName = () => 'themis.fn';
149
+
150
+ return mockFn;
151
+ }
152
+
153
+ function isMockFunction(value) {
154
+ return Boolean(value && value._isThemisMockFunction);
155
+ }
156
+
157
+ function formatMockCalls(value) {
158
+ if (!isMockFunction(value)) {
159
+ return format(value);
160
+ }
161
+ return format(value.mock.calls);
162
+ }
163
+
164
+ function resolveCallerFile() {
165
+ const stack = String(new Error().stack || '').split('\n').slice(2);
166
+ for (const line of stack) {
167
+ const match = line.match(/\((.+?):\d+:\d+\)$/) || line.match(/at (.+?):\d+:\d+$/);
168
+ if (!match) {
169
+ continue;
170
+ }
171
+
172
+ const candidate = match[1];
173
+ if (candidate.startsWith('node:')) {
174
+ continue;
175
+ }
176
+
177
+ const normalized = candidate.replace(/\\/g, '/');
178
+ if (
179
+ normalized.endsWith('/src/test-utils.js') ||
180
+ normalized.endsWith('/src/runtime.js') ||
181
+ normalized.endsWith('/src/expect.js')
182
+ ) {
183
+ continue;
184
+ }
185
+
186
+ return candidate;
187
+ }
188
+
189
+ return path.join(process.cwd(), '__themis_unknown_caller__.js');
190
+ }
191
+
192
+ function format(value) {
193
+ return util.inspect(value, { depth: 5, colors: false, maxArrayLength: 20 });
194
+ }
195
+
196
+ module.exports = {
197
+ createTestUtils,
198
+ createMockFunction,
199
+ isMockFunction,
200
+ formatMockCalls
201
+ };
package/src/verdict.js ADDED
@@ -0,0 +1,71 @@
1
+ async function verdictReveal(options = {}) {
2
+ const {
3
+ ok = true,
4
+ stream = process.stdout,
5
+ title = 'VERDICT',
6
+ detail = ok ? 'TRUTH UPHELD' : 'TRUTH VIOLATED',
7
+ delayMs = 120
8
+ } = options;
9
+
10
+ if (!canReveal(stream)) {
11
+ return;
12
+ }
13
+
14
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
15
+ const frames = ok
16
+ ? [
17
+ '⚖️ [= ]',
18
+ '⚖️ [== ]',
19
+ '⚖️ [=== ]',
20
+ '⚖️ [==== ]',
21
+ '⚖️ [=====]'
22
+ ]
23
+ : [
24
+ '⚖️ [==== ]',
25
+ '⚖️ [=== ]',
26
+ '⚖️ [== ]',
27
+ '⚖️ [= ]',
28
+ '⚖️ [ ]'
29
+ ];
30
+
31
+ const waitMs = normalizeDelay(delayMs);
32
+
33
+ stream.write('\x1B[?25l');
34
+ try {
35
+ for (const frame of frames) {
36
+ stream.write(`\r${frame} ${title}`);
37
+ await sleep(waitMs);
38
+ }
39
+ stream.write(`\r${ok ? '✔' : '✖'} ${title}: ${detail}\n`);
40
+ } finally {
41
+ stream.write('\x1B[?25h');
42
+ }
43
+ }
44
+
45
+ function canReveal(stream) {
46
+ if (!stream || typeof stream.write !== 'function') {
47
+ return false;
48
+ }
49
+ if (!stream.isTTY) {
50
+ return false;
51
+ }
52
+ if (process.env.NO_COLOR) {
53
+ return false;
54
+ }
55
+ if (process.env.THEMIS_NO_REVEAL === '1') {
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+
61
+ function normalizeDelay(value) {
62
+ const parsed = Number(value);
63
+ if (!Number.isFinite(parsed) || parsed < 0) {
64
+ return 120;
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ module.exports = {
70
+ verdictReveal
71
+ };
package/src/watch.js ADDED
@@ -0,0 +1,154 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { spawn } = require('child_process');
4
+
5
+ const WATCHABLE_EXTENSIONS = new Set(['.js', '.jsx', '.ts', '.tsx', '.json', '.mjs', '.cjs', '.mts', '.cts']);
6
+ const IGNORED_DIRECTORIES = new Set(['node_modules', '.git', '.themis']);
7
+
8
+ function stripWatchFlags(args) {
9
+ return args.filter((token) => token !== '--watch' && token !== '-w');
10
+ }
11
+
12
+ function collectWatchSignature(cwd) {
13
+ const entries = [];
14
+ walkWatchTree(path.resolve(cwd), entries);
15
+ return entries.sort();
16
+ }
17
+
18
+ function hasWatchSignatureChanged(previousSignature, nextSignature) {
19
+ if (previousSignature.length !== nextSignature.length) {
20
+ return true;
21
+ }
22
+
23
+ for (let i = 0; i < previousSignature.length; i += 1) {
24
+ if (previousSignature[i] !== nextSignature[i]) {
25
+ return true;
26
+ }
27
+ }
28
+
29
+ return false;
30
+ }
31
+
32
+ async function runWatchMode(options) {
33
+ const cwd = path.resolve(options.cwd || process.cwd());
34
+ const cliArgs = stripWatchFlags(options.cliArgs || []);
35
+ const cliPath = path.join(__dirname, '..', 'bin', 'themis.js');
36
+ const pollIntervalMs = Number(options.pollIntervalMs) > 0 ? Number(options.pollIntervalMs) : 400;
37
+ let previousSignature = collectWatchSignature(cwd);
38
+ let running = false;
39
+ let stopped = false;
40
+ let intervalId = null;
41
+ let activeChild = null;
42
+ let resolveStop = null;
43
+ const stopPromise = new Promise((resolve) => {
44
+ resolveStop = resolve;
45
+ });
46
+
47
+ const runOnce = () => new Promise((resolve, reject) => {
48
+ activeChild = spawn(process.execPath, [cliPath, 'test', ...cliArgs], {
49
+ cwd,
50
+ stdio: 'inherit',
51
+ env: process.env
52
+ });
53
+
54
+ let settled = false;
55
+ const finish = (handler) => (value) => {
56
+ if (settled) {
57
+ return;
58
+ }
59
+ settled = true;
60
+ activeChild = null;
61
+ handler(value);
62
+ if (stopped && resolveStop) {
63
+ resolveStop();
64
+ }
65
+ };
66
+
67
+ activeChild.once('error', finish(reject));
68
+ activeChild.once('exit', () => {
69
+ finish(resolve)();
70
+ });
71
+ });
72
+
73
+ const shutdown = () => {
74
+ if (stopped) {
75
+ return;
76
+ }
77
+
78
+ stopped = true;
79
+ if (intervalId) {
80
+ clearInterval(intervalId);
81
+ intervalId = null;
82
+ }
83
+
84
+ process.removeListener('SIGINT', shutdown);
85
+ process.removeListener('SIGTERM', shutdown);
86
+
87
+ if (activeChild && !activeChild.killed) {
88
+ activeChild.kill('SIGINT');
89
+ } else if (resolveStop) {
90
+ resolveStop();
91
+ }
92
+
93
+ process.stdout.write('\nWatch mode stopped.\n');
94
+ };
95
+
96
+ process.once('SIGINT', shutdown);
97
+ process.once('SIGTERM', shutdown);
98
+
99
+ await runOnce();
100
+ process.stdout.write('\nWatching for changes...\n');
101
+
102
+ intervalId = setInterval(async () => {
103
+ if (running || stopped) {
104
+ return;
105
+ }
106
+
107
+ const nextSignature = collectWatchSignature(cwd);
108
+ if (!hasWatchSignatureChanged(previousSignature, nextSignature)) {
109
+ return;
110
+ }
111
+
112
+ previousSignature = nextSignature;
113
+ running = true;
114
+ process.stdout.write('\nChange detected. Re-running...\n');
115
+ await runOnce();
116
+ process.stdout.write('\nWatching for changes...\n');
117
+ running = false;
118
+ }, pollIntervalMs);
119
+
120
+ await stopPromise;
121
+ }
122
+
123
+ function walkWatchTree(dir, entries) {
124
+ const dirEntries = fs.readdirSync(dir, { withFileTypes: true });
125
+ for (const entry of dirEntries) {
126
+ if (entry.isDirectory()) {
127
+ if (IGNORED_DIRECTORIES.has(entry.name) || entry.name === '__snapshots__') {
128
+ continue;
129
+ }
130
+ walkWatchTree(path.join(dir, entry.name), entries);
131
+ continue;
132
+ }
133
+
134
+ if (!entry.isFile()) {
135
+ continue;
136
+ }
137
+
138
+ const extension = path.extname(entry.name);
139
+ if (!WATCHABLE_EXTENSIONS.has(extension)) {
140
+ continue;
141
+ }
142
+
143
+ const targetPath = path.join(dir, entry.name);
144
+ const stats = fs.statSync(targetPath);
145
+ entries.push(`${targetPath}:${Math.round(stats.mtimeMs)}:${stats.size}`);
146
+ }
147
+ }
148
+
149
+ module.exports = {
150
+ collectWatchSignature,
151
+ hasWatchSignatureChanged,
152
+ runWatchMode,
153
+ stripWatchFlags
154
+ };
package/src/worker.js ADDED
@@ -0,0 +1,26 @@
1
+ const { parentPort, workerData } = require('worker_threads');
2
+ const { collectAndRun } = require('./runtime');
3
+
4
+ (async () => {
5
+ try {
6
+ const result = await collectAndRun(workerData.file, {
7
+ match: workerData.match,
8
+ allowedFullNames: workerData.allowedFullNames,
9
+ noMemes: workerData.noMemes,
10
+ cwd: workerData.cwd,
11
+ environment: workerData.environment,
12
+ setupFiles: workerData.setupFiles,
13
+ tsconfigPath: workerData.tsconfigPath,
14
+ updateSnapshots: workerData.updateSnapshots
15
+ });
16
+ parentPort.postMessage({ ok: true, result });
17
+ } catch (error) {
18
+ parentPort.postMessage({
19
+ ok: false,
20
+ error: {
21
+ message: String(error?.message || error),
22
+ stack: String(error?.stack || error)
23
+ }
24
+ });
25
+ }
26
+ })();