flake-monster 0.1.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/LICENSE +21 -0
- package/README.md +281 -0
- package/bin/flake-monster.js +6 -0
- package/package.json +48 -0
- package/src/adapters/adapter-interface.js +86 -0
- package/src/adapters/javascript/codegen.js +13 -0
- package/src/adapters/javascript/index.js +76 -0
- package/src/adapters/javascript/injector.js +438 -0
- package/src/adapters/javascript/parser.js +19 -0
- package/src/adapters/javascript/remover.js +128 -0
- package/src/adapters/registry.js +64 -0
- package/src/cli/commands/inject.js +64 -0
- package/src/cli/commands/restore.js +107 -0
- package/src/cli/commands/test.js +215 -0
- package/src/cli/index.js +19 -0
- package/src/core/config.js +57 -0
- package/src/core/engine.js +156 -0
- package/src/core/flake-analyzer.js +64 -0
- package/src/core/manifest.js +137 -0
- package/src/core/parsers/index.js +40 -0
- package/src/core/parsers/jest.js +52 -0
- package/src/core/parsers/node-test.js +64 -0
- package/src/core/parsers/tap.js +92 -0
- package/src/core/profile.js +72 -0
- package/src/core/reporter.js +75 -0
- package/src/core/seed.js +59 -0
- package/src/core/workspace.js +139 -0
- package/src/runtime/javascript/flake-monster.runtime.js +5 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const MANIFEST_VERSION = 1;
|
|
6
|
+
const MANIFEST_FILENAME = 'manifest.json';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tracks all injections for reliable removal and reporting.
|
|
10
|
+
* Stored at .flake-monster/manifest.json (or inside a workspace).
|
|
11
|
+
*/
|
|
12
|
+
export class Manifest {
|
|
13
|
+
constructor() {
|
|
14
|
+
this.version = MANIFEST_VERSION;
|
|
15
|
+
this.createdAt = new Date().toISOString();
|
|
16
|
+
this.seed = null;
|
|
17
|
+
this.mode = null;
|
|
18
|
+
this.files = {};
|
|
19
|
+
this.runtimeFiles = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Record injection results for a file.
|
|
24
|
+
* @param {string} relativePath - path relative to project/workspace root
|
|
25
|
+
* @param {string} adapterId - e.g. "javascript"
|
|
26
|
+
* @param {string} originalHash - hash of original source
|
|
27
|
+
* @param {string} modifiedHash - hash of modified source
|
|
28
|
+
* @param {Object} result - InjectionResult from adapter
|
|
29
|
+
*/
|
|
30
|
+
addFile(relativePath, adapterId, originalHash, modifiedHash, result) {
|
|
31
|
+
this.files[relativePath] = {
|
|
32
|
+
adapter: adapterId,
|
|
33
|
+
originalHash,
|
|
34
|
+
modifiedHash,
|
|
35
|
+
injections: result.points,
|
|
36
|
+
runtimeImportAdded: result.runtimeNeeded,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Add a runtime file that was copied into the project/workspace.
|
|
42
|
+
* @param {string} relativePath
|
|
43
|
+
*/
|
|
44
|
+
addRuntimeFile(relativePath) {
|
|
45
|
+
if (!this.runtimeFiles.includes(relativePath)) {
|
|
46
|
+
this.runtimeFiles.push(relativePath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Get all tracked file entries. */
|
|
51
|
+
getFiles() {
|
|
52
|
+
return this.files;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Get the total number of injections across all files. */
|
|
56
|
+
getTotalInjections() {
|
|
57
|
+
let total = 0;
|
|
58
|
+
for (const entry of Object.values(this.files)) {
|
|
59
|
+
total += entry.injections.length;
|
|
60
|
+
}
|
|
61
|
+
return total;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if a file's current content matches what we wrote during injection.
|
|
66
|
+
* @param {string} currentHash
|
|
67
|
+
* @param {string} relativePath
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
isFileUnmodified(relativePath, currentHash) {
|
|
71
|
+
const entry = this.files[relativePath];
|
|
72
|
+
if (!entry) return false;
|
|
73
|
+
return entry.modifiedHash === currentHash;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save manifest to disk.
|
|
78
|
+
* @param {string} dirPath - directory to write manifest.json in
|
|
79
|
+
*/
|
|
80
|
+
async save(dirPath) {
|
|
81
|
+
const filePath = join(dirPath, MANIFEST_FILENAME);
|
|
82
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
83
|
+
const data = {
|
|
84
|
+
version: this.version,
|
|
85
|
+
createdAt: this.createdAt,
|
|
86
|
+
seed: this.seed,
|
|
87
|
+
mode: this.mode,
|
|
88
|
+
files: this.files,
|
|
89
|
+
runtimeFiles: this.runtimeFiles,
|
|
90
|
+
};
|
|
91
|
+
await writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Load manifest from disk. Returns null if no manifest exists.
|
|
96
|
+
* @param {string} dirPath
|
|
97
|
+
* @returns {Promise<Manifest|null>}
|
|
98
|
+
*/
|
|
99
|
+
static async load(dirPath) {
|
|
100
|
+
const filePath = join(dirPath, MANIFEST_FILENAME);
|
|
101
|
+
try {
|
|
102
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
103
|
+
const data = JSON.parse(raw);
|
|
104
|
+
const manifest = new Manifest();
|
|
105
|
+
manifest.version = data.version;
|
|
106
|
+
manifest.createdAt = data.createdAt;
|
|
107
|
+
manifest.seed = data.seed;
|
|
108
|
+
manifest.mode = data.mode;
|
|
109
|
+
manifest.files = data.files || {};
|
|
110
|
+
manifest.runtimeFiles = data.runtimeFiles || [];
|
|
111
|
+
return manifest;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Delete manifest file.
|
|
119
|
+
* @param {string} dirPath
|
|
120
|
+
*/
|
|
121
|
+
static async delete(dirPath) {
|
|
122
|
+
try {
|
|
123
|
+
await unlink(join(dirPath, MANIFEST_FILENAME));
|
|
124
|
+
} catch {
|
|
125
|
+
// Already gone
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Compute SHA-256 hash of a string (for file content tracking).
|
|
132
|
+
* @param {string} content
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function hashContent(content) {
|
|
136
|
+
return 'sha256:' + createHash('sha256').update(content, 'utf-8').digest('hex');
|
|
137
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseJestOutput } from './jest.js';
|
|
2
|
+
import { parseNodeTestOutput } from './node-test.js';
|
|
3
|
+
import { parseTapOutput } from './tap.js';
|
|
4
|
+
|
|
5
|
+
const parsers = {
|
|
6
|
+
jest: parseJestOutput,
|
|
7
|
+
'node-test': parseNodeTestOutput,
|
|
8
|
+
tap: parseTapOutput,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Auto-detect the test runner from the command string.
|
|
13
|
+
* Returns parser key; defaults to 'tap' as a universal fallback.
|
|
14
|
+
*/
|
|
15
|
+
export function detectRunner(testCommand) {
|
|
16
|
+
if (testCommand.includes('jest') || testCommand.includes('react-scripts test')) {
|
|
17
|
+
return 'jest';
|
|
18
|
+
}
|
|
19
|
+
if (testCommand.includes('node --test') || testCommand.includes('node:test')) {
|
|
20
|
+
return 'node-test';
|
|
21
|
+
}
|
|
22
|
+
return 'tap';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse test runner output using the specified or auto-detected runner.
|
|
27
|
+
* Returns normalized { parsed, tests, totalPassed, totalFailed, totalSkipped }.
|
|
28
|
+
*/
|
|
29
|
+
export function parseTestOutput(runner, stdout) {
|
|
30
|
+
const parserFn = parsers[runner];
|
|
31
|
+
if (!parserFn) {
|
|
32
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return parserFn(stdout);
|
|
37
|
+
} catch {
|
|
38
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses Jest JSON output (from `jest --json`).
|
|
3
|
+
*
|
|
4
|
+
* Jest JSON schema:
|
|
5
|
+
* {
|
|
6
|
+
* testResults: [{
|
|
7
|
+
* testFilePath: string,
|
|
8
|
+
* testResults: [{
|
|
9
|
+
* fullName: string,
|
|
10
|
+
* status: "passed" | "failed" | "pending",
|
|
11
|
+
* duration: number (ms),
|
|
12
|
+
* failureMessages: string[]
|
|
13
|
+
* }]
|
|
14
|
+
* }]
|
|
15
|
+
* }
|
|
16
|
+
*/
|
|
17
|
+
export function parseJestOutput(stdout) {
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(stdout);
|
|
20
|
+
if (!data.testResults || !Array.isArray(data.testResults)) {
|
|
21
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tests = [];
|
|
25
|
+
for (const suite of data.testResults) {
|
|
26
|
+
const file = suite.testFilePath || '';
|
|
27
|
+
for (const t of suite.testResults || []) {
|
|
28
|
+
const status =
|
|
29
|
+
t.status === 'passed' ? 'passed' :
|
|
30
|
+
t.status === 'failed' ? 'failed' : 'skipped';
|
|
31
|
+
|
|
32
|
+
tests.push({
|
|
33
|
+
name: t.fullName || t.title || '',
|
|
34
|
+
file,
|
|
35
|
+
status,
|
|
36
|
+
durationMs: t.duration ?? null,
|
|
37
|
+
failureMessage: t.failureMessages?.length ? t.failureMessages.join('\n') : null,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
parsed: true,
|
|
44
|
+
tests,
|
|
45
|
+
totalPassed: tests.filter(t => t.status === 'passed').length,
|
|
46
|
+
totalFailed: tests.filter(t => t.status === 'failed').length,
|
|
47
|
+
totalSkipped: tests.filter(t => t.status === 'skipped').length,
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses node:test JSON reporter output (NDJSON).
|
|
3
|
+
*
|
|
4
|
+
* Each line is a JSON object with:
|
|
5
|
+
* type: "test:pass" | "test:fail" | "test:start" | "test:plan" | ...
|
|
6
|
+
* data: { name, nesting, details: { duration_ms, error? }, file? }
|
|
7
|
+
*
|
|
8
|
+
* We only care about "test:pass" and "test:fail" events.
|
|
9
|
+
* Filter to leaf tests: nesting > 0, or nesting === 0 if no nested tests exist.
|
|
10
|
+
*/
|
|
11
|
+
export function parseNodeTestOutput(stdout) {
|
|
12
|
+
try {
|
|
13
|
+
const lines = stdout.split('\n').filter(l => l.trim());
|
|
14
|
+
const events = [];
|
|
15
|
+
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
try {
|
|
18
|
+
events.push(JSON.parse(line));
|
|
19
|
+
} catch {
|
|
20
|
+
// skip non-JSON lines (e.g. stderr mixed in)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const testEvents = events.filter(
|
|
25
|
+
e => e.type === 'test:pass' || e.type === 'test:fail'
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (testEvents.length === 0) {
|
|
29
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Determine which events are leaf tests (not describe blocks).
|
|
33
|
+
// Describe blocks at nesting 0 have child tests at nesting > 0.
|
|
34
|
+
// If ALL events are nesting 0, they're individual tests (no describes).
|
|
35
|
+
const maxNesting = Math.max(...testEvents.map(e => e.data?.nesting ?? 0));
|
|
36
|
+
const leafEvents = maxNesting > 0
|
|
37
|
+
? testEvents.filter(e => (e.data?.nesting ?? 0) > 0)
|
|
38
|
+
: testEvents;
|
|
39
|
+
|
|
40
|
+
const tests = leafEvents.map(e => {
|
|
41
|
+
const d = e.data || {};
|
|
42
|
+
const status = e.type === 'test:pass' ? 'passed' : 'failed';
|
|
43
|
+
const error = d.details?.error;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: d.name || '',
|
|
47
|
+
file: d.file || '',
|
|
48
|
+
status,
|
|
49
|
+
durationMs: d.details?.duration_ms ?? null,
|
|
50
|
+
failureMessage: error ? (error.message || String(error)) : null,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
parsed: true,
|
|
56
|
+
tests,
|
|
57
|
+
totalPassed: tests.filter(t => t.status === 'passed').length,
|
|
58
|
+
totalFailed: tests.filter(t => t.status === 'failed').length,
|
|
59
|
+
totalSkipped: tests.filter(t => t.status === 'skipped').length,
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses TAP version 13 output.
|
|
3
|
+
*
|
|
4
|
+
* TAP lines:
|
|
5
|
+
* ok N - description
|
|
6
|
+
* not ok N - description
|
|
7
|
+
* ok N - description # skip reason
|
|
8
|
+
* ok N - description # todo reason
|
|
9
|
+
* --- (YAML diagnostic block start)
|
|
10
|
+
* ... (YAML diagnostic block end)
|
|
11
|
+
* 1..N (plan line, ignored)
|
|
12
|
+
* TAP version 13 (version line, ignored)
|
|
13
|
+
*/
|
|
14
|
+
export function parseTapOutput(stdout) {
|
|
15
|
+
try {
|
|
16
|
+
const lines = stdout.split('\n');
|
|
17
|
+
const tests = [];
|
|
18
|
+
let inYaml = false;
|
|
19
|
+
let yamlLines = [];
|
|
20
|
+
let yamlMessage = null;
|
|
21
|
+
|
|
22
|
+
const TEST_LINE = /^(ok|not ok)\s+(\d+)?\s*-?\s*(.*)/;
|
|
23
|
+
const DIRECTIVE = /#\s*(skip|todo)\b/i;
|
|
24
|
+
|
|
25
|
+
for (const line of lines) {
|
|
26
|
+
if (inYaml) {
|
|
27
|
+
if (line.trim() === '...') {
|
|
28
|
+
// End YAML block, attach to last failed test
|
|
29
|
+
if (tests.length > 0 && tests[tests.length - 1].status === 'failed') {
|
|
30
|
+
tests[tests.length - 1].failureMessage =
|
|
31
|
+
yamlMessage || yamlLines.join('\n') || null;
|
|
32
|
+
}
|
|
33
|
+
inYaml = false;
|
|
34
|
+
yamlLines = [];
|
|
35
|
+
yamlMessage = null;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const trimmed = line.trim();
|
|
39
|
+
yamlLines.push(trimmed);
|
|
40
|
+
const msgMatch = trimmed.match(/^message:\s*['"]?(.*?)['"]?\s*$/);
|
|
41
|
+
if (msgMatch) {
|
|
42
|
+
yamlMessage = msgMatch[1];
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (line.trim() === '---' && tests.length > 0) {
|
|
48
|
+
inYaml = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const match = line.match(TEST_LINE);
|
|
53
|
+
if (!match) continue;
|
|
54
|
+
|
|
55
|
+
const ok = match[1] === 'ok';
|
|
56
|
+
const description = match[3] || '';
|
|
57
|
+
|
|
58
|
+
const directiveMatch = description.match(DIRECTIVE);
|
|
59
|
+
let status;
|
|
60
|
+
let name = description;
|
|
61
|
+
|
|
62
|
+
if (directiveMatch) {
|
|
63
|
+
status = 'skipped';
|
|
64
|
+
name = description.slice(0, description.indexOf('#')).trim();
|
|
65
|
+
} else {
|
|
66
|
+
status = ok ? 'passed' : 'failed';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
tests.push({
|
|
70
|
+
name,
|
|
71
|
+
file: null,
|
|
72
|
+
status,
|
|
73
|
+
durationMs: null,
|
|
74
|
+
failureMessage: null,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (tests.length === 0) {
|
|
79
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
parsed: true,
|
|
84
|
+
tests,
|
|
85
|
+
totalPassed: tests.filter(t => t.status === 'passed').length,
|
|
86
|
+
totalFailed: tests.filter(t => t.status === 'failed').length,
|
|
87
|
+
totalSkipped: tests.filter(t => t.status === 'skipped').length,
|
|
88
|
+
};
|
|
89
|
+
} catch {
|
|
90
|
+
return { parsed: false, tests: [], totalPassed: 0, totalFailed: 0, totalSkipped: 0 };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const VALID_MODES = ['light', 'medium', 'hardcore'];
|
|
2
|
+
const VALID_DISTRIBUTIONS = ['uniform'];
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FlakeProfile resolves mode settings and delay distribution config.
|
|
6
|
+
* Passed to adapters as part of injection options.
|
|
7
|
+
*/
|
|
8
|
+
export class FlakeProfile {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {string} [options.mode='medium']
|
|
12
|
+
* @param {number} [options.minDelayMs=0]
|
|
13
|
+
* @param {number} [options.maxDelayMs=50]
|
|
14
|
+
* @param {string} [options.distribution='uniform']
|
|
15
|
+
* @param {boolean} [options.skipTryCatch=false]
|
|
16
|
+
* @param {boolean} [options.skipGenerators=true]
|
|
17
|
+
*/
|
|
18
|
+
constructor(options = {}) {
|
|
19
|
+
this.mode = options.mode || 'medium';
|
|
20
|
+
this.minDelayMs = options.minDelayMs ?? 0;
|
|
21
|
+
this.maxDelayMs = options.maxDelayMs ?? 50;
|
|
22
|
+
this.distribution = options.distribution || 'uniform';
|
|
23
|
+
this.skipTryCatch = options.skipTryCatch ?? false;
|
|
24
|
+
this.skipGenerators = options.skipGenerators ?? true;
|
|
25
|
+
|
|
26
|
+
if (!VALID_MODES.includes(this.mode)) {
|
|
27
|
+
throw new Error(`Invalid mode "${this.mode}". Must be one of: ${VALID_MODES.join(', ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (!VALID_DISTRIBUTIONS.includes(this.distribution)) {
|
|
30
|
+
throw new Error(`Invalid distribution "${this.distribution}". Must be one of: ${VALID_DISTRIBUTIONS.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
if (this.minDelayMs < 0) throw new Error('minDelayMs must be >= 0');
|
|
33
|
+
if (this.maxDelayMs < this.minDelayMs) throw new Error('maxDelayMs must be >= minDelayMs');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the inject options object to pass to a language adapter.
|
|
38
|
+
* @param {string} filePath - relative file path
|
|
39
|
+
* @param {number} seed - integer seed for this run
|
|
40
|
+
* @returns {Object}
|
|
41
|
+
*/
|
|
42
|
+
toInjectOptions(filePath, seed) {
|
|
43
|
+
return {
|
|
44
|
+
filePath,
|
|
45
|
+
mode: this.mode,
|
|
46
|
+
seed,
|
|
47
|
+
delayConfig: {
|
|
48
|
+
minMs: this.minDelayMs,
|
|
49
|
+
maxMs: this.maxDelayMs,
|
|
50
|
+
distribution: this.distribution,
|
|
51
|
+
},
|
|
52
|
+
skipTryCatch: this.skipTryCatch,
|
|
53
|
+
skipGenerators: this.skipGenerators,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a FlakeProfile from a merged config object.
|
|
59
|
+
* @param {Object} config
|
|
60
|
+
* @returns {FlakeProfile}
|
|
61
|
+
*/
|
|
62
|
+
static fromConfig(config) {
|
|
63
|
+
return new FlakeProfile({
|
|
64
|
+
mode: config.mode,
|
|
65
|
+
minDelayMs: config.minDelayMs,
|
|
66
|
+
maxDelayMs: config.maxDelayMs,
|
|
67
|
+
distribution: config.distribution,
|
|
68
|
+
skipTryCatch: config.skipTryCatch,
|
|
69
|
+
skipGenerators: config.skipGenerators,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formats test run results for terminal output.
|
|
3
|
+
*/
|
|
4
|
+
export class Reporter {
|
|
5
|
+
constructor({ quiet = false } = {}) {
|
|
6
|
+
this.quiet = quiet;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
log(...args) {
|
|
10
|
+
if (!this.quiet) console.log(...args);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Print a summary of all test runs.
|
|
15
|
+
* @param {Object[]} results - Array of per-run results
|
|
16
|
+
* Each: { runIndex, seed, exitCode, stdout, stderr, durationMs, workspacePath, kept }
|
|
17
|
+
* @param {number} totalRuns
|
|
18
|
+
*/
|
|
19
|
+
summarize(results, totalRuns) {
|
|
20
|
+
if (this.quiet) return;
|
|
21
|
+
console.log('\n--- FlakeMonster Results ---\n');
|
|
22
|
+
|
|
23
|
+
const failures = [];
|
|
24
|
+
|
|
25
|
+
for (const r of results) {
|
|
26
|
+
const status = r.exitCode === 0 ? 'PASS' : 'FAIL';
|
|
27
|
+
const dur = (r.durationMs / 1000).toFixed(1);
|
|
28
|
+
let line = ` Run ${r.runIndex + 1}/${totalRuns}: ${status} (seed=${r.seed}, ${dur}s)`;
|
|
29
|
+
|
|
30
|
+
if (r.exitCode !== 0 && r.kept) {
|
|
31
|
+
line += `\n Workspace kept: ${r.workspacePath}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(line);
|
|
35
|
+
|
|
36
|
+
if (r.exitCode !== 0) {
|
|
37
|
+
failures.push(r);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const passed = results.filter((r) => r.exitCode === 0).length;
|
|
42
|
+
const failed = results.length - passed;
|
|
43
|
+
|
|
44
|
+
console.log(`\n Summary: ${passed}/${totalRuns} passed, ${failed}/${totalRuns} failed`);
|
|
45
|
+
|
|
46
|
+
if (failures.length > 0) {
|
|
47
|
+
const seeds = failures.map((f) => f.seed).join(', ');
|
|
48
|
+
console.log(` Failing seeds: ${seeds}`);
|
|
49
|
+
console.log(`\n Reproduce a failure:`);
|
|
50
|
+
console.log(` flake-monster test --runs 1 --seed ${failures[0].seed} --cmd "<your test command>"`);
|
|
51
|
+
} else {
|
|
52
|
+
console.log('\n No flakes detected in this run.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
console.log('');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Print a single run result in real-time (during execution).
|
|
60
|
+
* @param {Object} result
|
|
61
|
+
* @param {number} totalRuns
|
|
62
|
+
*/
|
|
63
|
+
printRunResult(result, totalRuns) {
|
|
64
|
+
if (this.quiet) return;
|
|
65
|
+
const status = result.exitCode === 0 ? 'PASS' : 'FAIL';
|
|
66
|
+
const dur = (result.durationMs / 1000).toFixed(1);
|
|
67
|
+
let line = ` Run ${result.runIndex + 1}/${totalRuns}: ${status} (seed=${result.seed}, ${dur}s)`;
|
|
68
|
+
|
|
69
|
+
if (result.exitCode !== 0 && result.kept) {
|
|
70
|
+
line += ` — workspace kept`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
console.log(line);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/core/seed.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mulberry32 seeded PRNG.
|
|
3
|
+
* Returns a function that produces floats in [0, 1) deterministically.
|
|
4
|
+
* @param {number} seed - 32-bit integer seed
|
|
5
|
+
* @returns {() => number}
|
|
6
|
+
*/
|
|
7
|
+
export function createRng(seed) {
|
|
8
|
+
return function () {
|
|
9
|
+
seed |= 0;
|
|
10
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
11
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
12
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
13
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Simple string hash (DJB2). Returns an unsigned 32-bit integer.
|
|
19
|
+
* @param {string} str
|
|
20
|
+
* @returns {number}
|
|
21
|
+
*/
|
|
22
|
+
export function hashString(str) {
|
|
23
|
+
let hash = 5381;
|
|
24
|
+
for (let i = 0; i < str.length; i++) {
|
|
25
|
+
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
|
|
26
|
+
}
|
|
27
|
+
return hash >>> 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive a sub-seed from a base seed + string context.
|
|
32
|
+
* Each (file, function, index) combination gets a distinct but deterministic seed.
|
|
33
|
+
* @param {number} baseSeed
|
|
34
|
+
* @param {string} context - e.g. "src/user.js:loadUser:0"
|
|
35
|
+
* @returns {number}
|
|
36
|
+
*/
|
|
37
|
+
export function deriveSeed(baseSeed, context) {
|
|
38
|
+
return (baseSeed + hashString(context)) | 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Generate a random seed when user passes --seed auto.
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
export function randomSeed() {
|
|
46
|
+
return Math.floor(Math.random() * 0xffffffff);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse seed from CLI input. Returns a numeric seed.
|
|
51
|
+
* @param {string|number} input - "auto" or a numeric string/number
|
|
52
|
+
* @returns {number}
|
|
53
|
+
*/
|
|
54
|
+
export function parseSeed(input) {
|
|
55
|
+
if (input === 'auto') return randomSeed();
|
|
56
|
+
const n = Number(input);
|
|
57
|
+
if (Number.isNaN(n)) throw new Error(`Invalid seed: ${input}`);
|
|
58
|
+
return n | 0;
|
|
59
|
+
}
|