@lunatest/core 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/dist/config/lua-config.d.ts +4 -0
- package/dist/config/lua-config.js +71 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/mocks/provider.d.ts +56 -0
- package/dist/mocks/provider.js +166 -0
- package/dist/provider/luna-provider.d.ts +27 -0
- package/dist/provider/luna-provider.js +117 -0
- package/dist/runner/assert.d.ts +11 -0
- package/dist/runner/assert.js +58 -0
- package/dist/runner/execute-scenario.d.ts +38 -0
- package/dist/runner/execute-scenario.js +70 -0
- package/dist/runner/performance.d.ts +14 -0
- package/dist/runner/performance.js +31 -0
- package/dist/runner/reporter.d.ts +5 -0
- package/dist/runner/reporter.js +70 -0
- package/dist/runner/runner.d.ts +42 -0
- package/dist/runner/runner.js +74 -0
- package/dist/runtime/bridge.d.ts +2 -0
- package/dist/runtime/bridge.js +48 -0
- package/dist/runtime/engine.d.ts +2 -0
- package/dist/runtime/engine.js +85 -0
- package/dist/runtime/sandbox.d.ts +3 -0
- package/dist/runtime/sandbox.js +54 -0
- package/dist/runtime/scenario-runtime.d.ts +70 -0
- package/dist/runtime/scenario-runtime.js +156 -0
- package/dist/runtime/types.d.ts +17 -0
- package/dist/runtime/types.js +9 -0
- package/dist/scenario/index.d.ts +43 -0
- package/dist/scenario/index.js +85 -0
- package/package.json +36 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { performance } from "node:perf_hooks";
|
|
2
|
+
import { runScenario } from "./runner.js";
|
|
3
|
+
function percentile(values, ratio) {
|
|
4
|
+
if (values.length === 0) {
|
|
5
|
+
return 0;
|
|
6
|
+
}
|
|
7
|
+
const sorted = [...values].sort((left, right) => left - right);
|
|
8
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1));
|
|
9
|
+
return sorted[index] ?? 0;
|
|
10
|
+
}
|
|
11
|
+
export async function measureScenarioPerformance(input) {
|
|
12
|
+
const samplesMs = [];
|
|
13
|
+
const iterations = Math.max(0, input.iterations);
|
|
14
|
+
const totalStart = performance.now();
|
|
15
|
+
for (let index = 0; index < iterations; index += 1) {
|
|
16
|
+
const start = performance.now();
|
|
17
|
+
await runScenario({
|
|
18
|
+
scenario: input.scenario,
|
|
19
|
+
resolveUi: input.resolveUi,
|
|
20
|
+
});
|
|
21
|
+
samplesMs.push(performance.now() - start);
|
|
22
|
+
}
|
|
23
|
+
const totalMs = performance.now() - totalStart;
|
|
24
|
+
return {
|
|
25
|
+
iterations,
|
|
26
|
+
totalMs,
|
|
27
|
+
averageMs: iterations === 0 ? 0 : totalMs / iterations,
|
|
28
|
+
p95Ms: percentile(samplesMs, 0.95),
|
|
29
|
+
samplesMs,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RunAllResult } from "./runner.js";
|
|
2
|
+
export declare function toConsoleReport(result: RunAllResult): string;
|
|
3
|
+
export declare function toJsonReport(result: RunAllResult): string;
|
|
4
|
+
export declare function toJunitReport(result: RunAllResult): string;
|
|
5
|
+
export declare function toHtmlReport(result: RunAllResult): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
function escapeXml(value) {
|
|
2
|
+
return value
|
|
3
|
+
.replaceAll("&", "&")
|
|
4
|
+
.replaceAll("<", "<")
|
|
5
|
+
.replaceAll(">", ">")
|
|
6
|
+
.replaceAll("\"", """)
|
|
7
|
+
.replaceAll("'", "'");
|
|
8
|
+
}
|
|
9
|
+
function scenarioLine(result) {
|
|
10
|
+
return `${result.pass ? "PASS" : "FAIL"} ${result.scenarioName}`;
|
|
11
|
+
}
|
|
12
|
+
export function toConsoleReport(result) {
|
|
13
|
+
const lines = [
|
|
14
|
+
"Scenario Summary",
|
|
15
|
+
`total=${result.total}`,
|
|
16
|
+
`passed=${result.passed}`,
|
|
17
|
+
`failed=${result.failed}`,
|
|
18
|
+
];
|
|
19
|
+
for (const item of result.results) {
|
|
20
|
+
lines.push(scenarioLine(item));
|
|
21
|
+
if (!item.pass && item.diff) {
|
|
22
|
+
lines.push(item.diff);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return lines.join("\n");
|
|
26
|
+
}
|
|
27
|
+
export function toJsonReport(result) {
|
|
28
|
+
return JSON.stringify(result, null, 2);
|
|
29
|
+
}
|
|
30
|
+
export function toJunitReport(result) {
|
|
31
|
+
const testcases = result.results
|
|
32
|
+
.map((item) => {
|
|
33
|
+
if (item.pass) {
|
|
34
|
+
return `<testcase classname="lunatest" name="${escapeXml(item.scenarioName)}" />`;
|
|
35
|
+
}
|
|
36
|
+
return [
|
|
37
|
+
`<testcase classname="lunatest" name="${escapeXml(item.scenarioName)}">`,
|
|
38
|
+
`<failure message="${escapeXml(item.diff || "assertion failed")}" />`,
|
|
39
|
+
"</testcase>",
|
|
40
|
+
].join("");
|
|
41
|
+
})
|
|
42
|
+
.join("");
|
|
43
|
+
return [
|
|
44
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
45
|
+
`<testsuite name="lunatest" tests="${result.total}" failures="${result.failed}">`,
|
|
46
|
+
testcases,
|
|
47
|
+
"</testsuite>",
|
|
48
|
+
].join("");
|
|
49
|
+
}
|
|
50
|
+
export function toHtmlReport(result) {
|
|
51
|
+
const rows = result.results
|
|
52
|
+
.map((item) => {
|
|
53
|
+
const status = item.pass ? "PASS" : "FAIL";
|
|
54
|
+
const diff = item.diff ? item.diff : "";
|
|
55
|
+
return `<tr><td>${escapeXml(item.scenarioName)}</td><td>${status}</td><td>${escapeXml(diff)}</td></tr>`;
|
|
56
|
+
})
|
|
57
|
+
.join("");
|
|
58
|
+
return [
|
|
59
|
+
"<!doctype html>",
|
|
60
|
+
"<html><head><meta charset=\"utf-8\" /><title>LunaTest Report</title></head>",
|
|
61
|
+
"<body>",
|
|
62
|
+
"<h1>LunaTest Report</h1>",
|
|
63
|
+
`<p>total=${result.total}, passed=${result.passed}, failed=${result.failed}</p>`,
|
|
64
|
+
"<table border=\"1\" cellspacing=\"0\" cellpadding=\"6\">",
|
|
65
|
+
"<thead><tr><th>Scenario</th><th>Status</th><th>Diff</th></tr></thead>",
|
|
66
|
+
`<tbody>${rows}</tbody>`,
|
|
67
|
+
"</table>",
|
|
68
|
+
"</body></html>",
|
|
69
|
+
].join("");
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type AssertionResult } from "./assert.js";
|
|
2
|
+
type ScenarioLike = {
|
|
3
|
+
name: string;
|
|
4
|
+
given?: Record<string, unknown>;
|
|
5
|
+
when: {
|
|
6
|
+
action: string;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
then_ui: Record<string, unknown>;
|
|
10
|
+
then_state?: Record<string, unknown>;
|
|
11
|
+
stages?: Array<{
|
|
12
|
+
name: string;
|
|
13
|
+
}>;
|
|
14
|
+
not_present?: string[];
|
|
15
|
+
timing_ms?: number;
|
|
16
|
+
};
|
|
17
|
+
export type RunScenarioInput = {
|
|
18
|
+
scenario: ScenarioLike;
|
|
19
|
+
resolveUi: (scenario: ScenarioLike) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
20
|
+
resolveState?: (scenario: ScenarioLike) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
21
|
+
resolveTransitions?: (scenario: ScenarioLike) => Promise<string[]> | string[];
|
|
22
|
+
resolveElapsedMs?: (scenario: ScenarioLike) => Promise<number> | number;
|
|
23
|
+
};
|
|
24
|
+
export type RunScenarioResult = {
|
|
25
|
+
scenarioName: string;
|
|
26
|
+
pass: boolean;
|
|
27
|
+
diff: string;
|
|
28
|
+
expectedUi: Record<string, unknown>;
|
|
29
|
+
actualUi: Record<string, unknown>;
|
|
30
|
+
expectedState?: Record<string, unknown>;
|
|
31
|
+
actualState?: Record<string, unknown>;
|
|
32
|
+
assertions: Record<string, AssertionResult>;
|
|
33
|
+
};
|
|
34
|
+
export type RunAllResult = {
|
|
35
|
+
total: number;
|
|
36
|
+
passed: number;
|
|
37
|
+
failed: number;
|
|
38
|
+
results: RunScenarioResult[];
|
|
39
|
+
};
|
|
40
|
+
export declare function runScenario(input: RunScenarioInput): Promise<RunScenarioResult>;
|
|
41
|
+
export declare function runAll(items: RunScenarioInput[]): Promise<RunAllResult>;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { assertNot, assertState, assertTiming, assertTransition, assertUI, } from "./assert.js";
|
|
2
|
+
function missingResolver(label) {
|
|
3
|
+
return {
|
|
4
|
+
pass: false,
|
|
5
|
+
diff: `${label} resolver is required for this scenario`,
|
|
6
|
+
expected: "resolver",
|
|
7
|
+
actual: "missing",
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
function collectFailureDiff(assertions) {
|
|
11
|
+
return Object.entries(assertions)
|
|
12
|
+
.filter(([, value]) => !value.pass)
|
|
13
|
+
.map(([name, value]) => `[${name}] ${value.diff}`)
|
|
14
|
+
.join("\n");
|
|
15
|
+
}
|
|
16
|
+
export async function runScenario(input) {
|
|
17
|
+
const actualUi = await input.resolveUi(input.scenario);
|
|
18
|
+
const assertions = {
|
|
19
|
+
ui: assertUI(input.scenario.then_ui, actualUi),
|
|
20
|
+
};
|
|
21
|
+
let actualState;
|
|
22
|
+
if (input.scenario.then_state) {
|
|
23
|
+
if (!input.resolveState) {
|
|
24
|
+
assertions.state = missingResolver("state");
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
actualState = await input.resolveState(input.scenario);
|
|
28
|
+
assertions.state = assertState(input.scenario.then_state, actualState);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (input.scenario.stages && input.scenario.stages.length > 0) {
|
|
32
|
+
const expectedPath = input.scenario.stages.map((stage) => stage.name);
|
|
33
|
+
if (!input.resolveTransitions) {
|
|
34
|
+
assertions.transition = missingResolver("transition");
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
const actualPath = await input.resolveTransitions(input.scenario);
|
|
38
|
+
assertions.transition = assertTransition(expectedPath, actualPath);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (input.scenario.not_present && input.scenario.not_present.length > 0) {
|
|
42
|
+
assertions.negative = assertNot(input.scenario.not_present, actualUi);
|
|
43
|
+
}
|
|
44
|
+
if (input.scenario.timing_ms !== undefined) {
|
|
45
|
+
if (!input.resolveElapsedMs) {
|
|
46
|
+
assertions.timing = missingResolver("timing");
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const elapsedMs = await input.resolveElapsedMs(input.scenario);
|
|
50
|
+
assertions.timing = assertTiming(input.scenario.timing_ms, elapsedMs);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const pass = Object.values(assertions).every((result) => result.pass);
|
|
54
|
+
return {
|
|
55
|
+
scenarioName: input.scenario.name,
|
|
56
|
+
pass,
|
|
57
|
+
diff: collectFailureDiff(assertions),
|
|
58
|
+
expectedUi: input.scenario.then_ui,
|
|
59
|
+
actualUi,
|
|
60
|
+
expectedState: input.scenario.then_state,
|
|
61
|
+
actualState,
|
|
62
|
+
assertions,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export async function runAll(items) {
|
|
66
|
+
const results = await Promise.all(items.map((item) => runScenario(item)));
|
|
67
|
+
const passed = results.filter((result) => result.pass).length;
|
|
68
|
+
return {
|
|
69
|
+
total: results.length,
|
|
70
|
+
passed,
|
|
71
|
+
failed: results.length - passed,
|
|
72
|
+
results,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function typeName(value) {
|
|
2
|
+
if (value === null) {
|
|
3
|
+
return "null";
|
|
4
|
+
}
|
|
5
|
+
if (Array.isArray(value)) {
|
|
6
|
+
return "array";
|
|
7
|
+
}
|
|
8
|
+
return typeof value;
|
|
9
|
+
}
|
|
10
|
+
function isPlainObject(value) {
|
|
11
|
+
return (typeof value === "object" &&
|
|
12
|
+
value !== null &&
|
|
13
|
+
!Array.isArray(value) &&
|
|
14
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
15
|
+
}
|
|
16
|
+
function cloneSupported(value) {
|
|
17
|
+
if (value === null ||
|
|
18
|
+
typeof value === "string" ||
|
|
19
|
+
typeof value === "number" ||
|
|
20
|
+
typeof value === "boolean") {
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return value.map((item) => cloneSupported(item));
|
|
25
|
+
}
|
|
26
|
+
if (isPlainObject(value)) {
|
|
27
|
+
const cloned = {};
|
|
28
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
29
|
+
if (nested === undefined) {
|
|
30
|
+
throw new Error(`Unsupported value type at ${key}: undefined`);
|
|
31
|
+
}
|
|
32
|
+
Object.defineProperty(cloned, key, {
|
|
33
|
+
value: cloneSupported(nested),
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return cloned;
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Unsupported value type: ${typeName(value)}`);
|
|
42
|
+
}
|
|
43
|
+
export function toLuaArgs(value) {
|
|
44
|
+
return cloneSupported(value);
|
|
45
|
+
}
|
|
46
|
+
export function fromLuaValue(value) {
|
|
47
|
+
return cloneSupported(value);
|
|
48
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { LuaFactory } from "wasmoon";
|
|
2
|
+
import { fromLuaValue, toLuaArgs } from "./bridge.js";
|
|
3
|
+
import { applySandbox } from "./sandbox.js";
|
|
4
|
+
import { RuntimeOptionsSchema, } from "./types.js";
|
|
5
|
+
const FUNCTION_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6
|
+
const DEFAULT_MEMORY_LIMIT = 16 * 1024 * 1024;
|
|
7
|
+
function normalizeFunctionName(name) {
|
|
8
|
+
if (!FUNCTION_NAME_RE.test(name)) {
|
|
9
|
+
throw new Error(`Invalid function name: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
return name;
|
|
12
|
+
}
|
|
13
|
+
export async function createRuntime(rawOptions = {}) {
|
|
14
|
+
const options = RuntimeOptionsSchema.parse(rawOptions);
|
|
15
|
+
const factory = new LuaFactory();
|
|
16
|
+
const hostFunctions = new Map();
|
|
17
|
+
let instructionLimit = options.instructionLimit;
|
|
18
|
+
let memoryLimit = options.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
|
|
19
|
+
let engine;
|
|
20
|
+
const bindHostFunction = (name, hostFn) => {
|
|
21
|
+
engine.global.set(name, (...values) => {
|
|
22
|
+
const normalizedValues = fromLuaValue(values);
|
|
23
|
+
const result = hostFn(...normalizedValues);
|
|
24
|
+
return toLuaArgs(result);
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
const bootstrapEngine = async () => {
|
|
28
|
+
if (engine) {
|
|
29
|
+
engine.global.close();
|
|
30
|
+
}
|
|
31
|
+
engine = await factory.createEngine({
|
|
32
|
+
functionTimeout: instructionLimit,
|
|
33
|
+
traceAllocations: true,
|
|
34
|
+
});
|
|
35
|
+
engine.global.setMemoryMax(memoryLimit);
|
|
36
|
+
await applySandbox(engine, {
|
|
37
|
+
...options,
|
|
38
|
+
instructionLimit,
|
|
39
|
+
memoryLimit,
|
|
40
|
+
});
|
|
41
|
+
for (const [name, hostFn] of hostFunctions.entries()) {
|
|
42
|
+
bindHostFunction(name, hostFn);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await bootstrapEngine();
|
|
46
|
+
return {
|
|
47
|
+
async eval(code) {
|
|
48
|
+
await engine.doString(code);
|
|
49
|
+
},
|
|
50
|
+
async call(name, args = {}) {
|
|
51
|
+
const targetName = normalizeFunctionName(name);
|
|
52
|
+
const target = engine.global.get(targetName);
|
|
53
|
+
if (typeof target !== "function") {
|
|
54
|
+
throw new Error(`Function not found: ${targetName}`);
|
|
55
|
+
}
|
|
56
|
+
const normalizedArgs = toLuaArgs(args);
|
|
57
|
+
const orderedArgs = Object.values(normalizedArgs);
|
|
58
|
+
const result = await Promise.resolve(target(...orderedArgs));
|
|
59
|
+
return fromLuaValue(result);
|
|
60
|
+
},
|
|
61
|
+
register(name, hostFn) {
|
|
62
|
+
const targetName = normalizeFunctionName(name);
|
|
63
|
+
hostFunctions.set(targetName, hostFn);
|
|
64
|
+
bindHostFunction(targetName, hostFn);
|
|
65
|
+
},
|
|
66
|
+
async getState(keys = []) {
|
|
67
|
+
const snapshot = {};
|
|
68
|
+
for (const key of keys) {
|
|
69
|
+
snapshot[key] = fromLuaValue(engine.global.get(key));
|
|
70
|
+
}
|
|
71
|
+
return snapshot;
|
|
72
|
+
},
|
|
73
|
+
setInstructionLimit(n) {
|
|
74
|
+
instructionLimit = Math.max(1, Math.trunc(n));
|
|
75
|
+
engine.global.setTimeout(instructionLimit);
|
|
76
|
+
},
|
|
77
|
+
setMemoryLimit(bytes) {
|
|
78
|
+
memoryLimit = Math.max(1, Math.trunc(bytes));
|
|
79
|
+
engine.global.setMemoryMax(memoryLimit);
|
|
80
|
+
},
|
|
81
|
+
async reset() {
|
|
82
|
+
await bootstrapEngine();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
function createDeterministicRandom(seed) {
|
|
2
|
+
let cursor = seed;
|
|
3
|
+
return () => {
|
|
4
|
+
const nextValue = Math.sin(cursor++) * 10000;
|
|
5
|
+
const fractional = nextValue - Math.floor(nextValue);
|
|
6
|
+
return Number(fractional.toFixed(6));
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
function toInteger(value) {
|
|
10
|
+
return Number.isFinite(value) ? Math.trunc(value) : 0;
|
|
11
|
+
}
|
|
12
|
+
export async function applySandbox(engine, options = {}) {
|
|
13
|
+
const seed = options.seed ?? 1;
|
|
14
|
+
const now = toInteger(options.now ?? 0);
|
|
15
|
+
const instructionLimit = options.instructionLimit;
|
|
16
|
+
engine.global.set("__lunatest_next_random", createDeterministicRandom(seed));
|
|
17
|
+
await engine.doString(`
|
|
18
|
+
math.random = function()
|
|
19
|
+
return __lunatest_next_random()
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
os.time = function()
|
|
23
|
+
return ${now}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
os.date = function()
|
|
27
|
+
return "1970-01-01T00:00:00Z"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
io = {
|
|
31
|
+
open = function()
|
|
32
|
+
error("io.open is blocked in sandbox")
|
|
33
|
+
end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
os.execute = function()
|
|
37
|
+
error("os.execute is blocked in sandbox")
|
|
38
|
+
end
|
|
39
|
+
`);
|
|
40
|
+
if (instructionLimit !== undefined) {
|
|
41
|
+
const normalizedLimit = Math.max(1, toInteger(instructionLimit));
|
|
42
|
+
await engine.doString(`
|
|
43
|
+
__lunatest_instruction_count = 0
|
|
44
|
+
__lunatest_instruction_limit = ${normalizedLimit}
|
|
45
|
+
|
|
46
|
+
debug.sethook(function()
|
|
47
|
+
__lunatest_instruction_count = __lunatest_instruction_count + 1
|
|
48
|
+
if __lunatest_instruction_count > __lunatest_instruction_limit then
|
|
49
|
+
error("instruction limit exceeded")
|
|
50
|
+
end
|
|
51
|
+
end, "", 1)
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type RouteMock } from "@lunatest/contracts";
|
|
3
|
+
export type { RouteMock } from "@lunatest/contracts";
|
|
4
|
+
export declare const LuaConfigSchema: z.ZodObject<{
|
|
5
|
+
name: z.ZodOptional<z.ZodString>;
|
|
6
|
+
mode: z.ZodDefault<z.ZodEnum<{
|
|
7
|
+
strict: "strict";
|
|
8
|
+
permissive: "permissive";
|
|
9
|
+
}>>;
|
|
10
|
+
given: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
11
|
+
when: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
12
|
+
then_ui: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
13
|
+
then_state: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
14
|
+
intercept: z.ZodOptional<z.ZodObject<{
|
|
15
|
+
routes: z.ZodOptional<z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
16
|
+
endpointType: z.ZodLiteral<"ethereum">;
|
|
17
|
+
method: z.ZodString;
|
|
18
|
+
responseKey: z.ZodString;
|
|
19
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
20
|
+
endpointType: z.ZodLiteral<"rpc">;
|
|
21
|
+
urlPattern: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<RegExp, RegExp>]>;
|
|
22
|
+
methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
23
|
+
responseKey: z.ZodString;
|
|
24
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
25
|
+
endpointType: z.ZodLiteral<"http">;
|
|
26
|
+
urlPattern: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<RegExp, RegExp>]>;
|
|
27
|
+
method: z.ZodOptional<z.ZodString>;
|
|
28
|
+
responseKey: z.ZodString;
|
|
29
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
30
|
+
endpointType: z.ZodLiteral<"ws">;
|
|
31
|
+
urlPattern: z.ZodUnion<readonly [z.ZodString, z.ZodCustom<RegExp, RegExp>]>;
|
|
32
|
+
responseKey: z.ZodString;
|
|
33
|
+
match: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodCustom<RegExp, RegExp>]>>;
|
|
34
|
+
}, z.core.$strip>], "endpointType">>>;
|
|
35
|
+
routing: z.ZodOptional<z.ZodObject<{
|
|
36
|
+
ethereumMethods: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
37
|
+
method: z.ZodString;
|
|
38
|
+
responseKey: z.ZodString;
|
|
39
|
+
}, z.core.$strip>>>>;
|
|
40
|
+
rpcEndpoints: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
41
|
+
urlPattern: z.ZodString;
|
|
42
|
+
methods: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
43
|
+
responseKey: z.ZodString;
|
|
44
|
+
}, z.core.$strip>>>>;
|
|
45
|
+
httpEndpoints: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
46
|
+
urlPattern: z.ZodString;
|
|
47
|
+
method: z.ZodOptional<z.ZodString>;
|
|
48
|
+
responseKey: z.ZodString;
|
|
49
|
+
}, z.core.$strip>>>>;
|
|
50
|
+
wsEndpoints: z.ZodOptional<z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
51
|
+
urlPattern: z.ZodString;
|
|
52
|
+
responseKey: z.ZodString;
|
|
53
|
+
match: z.ZodOptional<z.ZodString>;
|
|
54
|
+
}, z.core.$strip>>>>;
|
|
55
|
+
}, z.core.$strip>>;
|
|
56
|
+
mockResponses: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
57
|
+
state: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
58
|
+
}, z.core.$strip>>;
|
|
59
|
+
}, z.core.$loose>;
|
|
60
|
+
export type LuaConfig = z.infer<typeof LuaConfigSchema>;
|
|
61
|
+
export type ScenarioRuntime = {
|
|
62
|
+
getConfig: () => LuaConfig;
|
|
63
|
+
getRouteMocks: () => RouteMock[];
|
|
64
|
+
setRouteMocks: (routes: RouteMock[]) => RouteMock[];
|
|
65
|
+
getInterceptState: () => Record<string, unknown>;
|
|
66
|
+
applyInterceptState: (partialState: Record<string, unknown>) => Record<string, unknown>;
|
|
67
|
+
};
|
|
68
|
+
export declare function createScenarioRuntime(input: LuaConfig): ScenarioRuntime;
|
|
69
|
+
export declare function applyInterceptState(runtime: ScenarioRuntime, partialState: Record<string, unknown>): Record<string, unknown>;
|
|
70
|
+
export declare function setRouteMocks(runtime: ScenarioRuntime, routes: RouteMock[]): RouteMock[];
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { deepClone, deepMerge } from "@lunatest/contracts";
|
|
3
|
+
const StringRecordSchema = z.record(z.string(), z.unknown());
|
|
4
|
+
const EthereumRouteSchema = z.object({
|
|
5
|
+
endpointType: z.literal("ethereum"),
|
|
6
|
+
method: z.string().min(1),
|
|
7
|
+
responseKey: z.string().min(1),
|
|
8
|
+
});
|
|
9
|
+
const RpcRouteSchema = z.object({
|
|
10
|
+
endpointType: z.literal("rpc"),
|
|
11
|
+
urlPattern: z.union([z.string().min(1), z.instanceof(RegExp)]),
|
|
12
|
+
methods: z.array(z.string().min(1)).optional(),
|
|
13
|
+
responseKey: z.string().min(1),
|
|
14
|
+
});
|
|
15
|
+
const HttpRouteSchema = z.object({
|
|
16
|
+
endpointType: z.literal("http"),
|
|
17
|
+
urlPattern: z.union([z.string().min(1), z.instanceof(RegExp)]),
|
|
18
|
+
method: z.string().min(1).optional(),
|
|
19
|
+
responseKey: z.string().min(1),
|
|
20
|
+
});
|
|
21
|
+
const WsRouteSchema = z.object({
|
|
22
|
+
endpointType: z.literal("ws"),
|
|
23
|
+
urlPattern: z.union([z.string().min(1), z.instanceof(RegExp)]),
|
|
24
|
+
responseKey: z.string().min(1),
|
|
25
|
+
match: z.union([z.string().min(1), z.instanceof(RegExp)]).optional(),
|
|
26
|
+
});
|
|
27
|
+
const RouteMockSchema = z.discriminatedUnion("endpointType", [
|
|
28
|
+
EthereumRouteSchema,
|
|
29
|
+
RpcRouteSchema,
|
|
30
|
+
HttpRouteSchema,
|
|
31
|
+
WsRouteSchema,
|
|
32
|
+
]);
|
|
33
|
+
const LegacyRoutingSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
ethereumMethods: z
|
|
36
|
+
.array(z.object({
|
|
37
|
+
method: z.string().min(1),
|
|
38
|
+
responseKey: z.string().min(1),
|
|
39
|
+
}))
|
|
40
|
+
.optional(),
|
|
41
|
+
rpcEndpoints: z
|
|
42
|
+
.array(z.object({
|
|
43
|
+
urlPattern: z.string().min(1),
|
|
44
|
+
methods: z.array(z.string().min(1)).optional(),
|
|
45
|
+
responseKey: z.string().min(1),
|
|
46
|
+
}))
|
|
47
|
+
.optional(),
|
|
48
|
+
httpEndpoints: z
|
|
49
|
+
.array(z.object({
|
|
50
|
+
urlPattern: z.string().min(1),
|
|
51
|
+
method: z.string().min(1).optional(),
|
|
52
|
+
responseKey: z.string().min(1),
|
|
53
|
+
}))
|
|
54
|
+
.optional(),
|
|
55
|
+
wsEndpoints: z
|
|
56
|
+
.array(z.object({
|
|
57
|
+
urlPattern: z.string().min(1),
|
|
58
|
+
responseKey: z.string().min(1),
|
|
59
|
+
match: z.string().min(1).optional(),
|
|
60
|
+
}))
|
|
61
|
+
.optional(),
|
|
62
|
+
})
|
|
63
|
+
.partial();
|
|
64
|
+
export const LuaConfigSchema = z
|
|
65
|
+
.object({
|
|
66
|
+
name: z.string().min(1).optional(),
|
|
67
|
+
mode: z.enum(["strict", "permissive"]).default("strict"),
|
|
68
|
+
given: StringRecordSchema.default({}),
|
|
69
|
+
when: StringRecordSchema.optional(),
|
|
70
|
+
then_ui: StringRecordSchema.optional(),
|
|
71
|
+
then_state: StringRecordSchema.optional(),
|
|
72
|
+
intercept: z
|
|
73
|
+
.object({
|
|
74
|
+
routes: z.array(RouteMockSchema).optional(),
|
|
75
|
+
routing: LegacyRoutingSchema.optional(),
|
|
76
|
+
mockResponses: StringRecordSchema.optional(),
|
|
77
|
+
state: StringRecordSchema.optional(),
|
|
78
|
+
})
|
|
79
|
+
.optional(),
|
|
80
|
+
})
|
|
81
|
+
.passthrough();
|
|
82
|
+
function normalizeLegacyRoutes(routing) {
|
|
83
|
+
if (!routing) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const routes = [];
|
|
87
|
+
for (const route of routing.ethereumMethods ?? []) {
|
|
88
|
+
routes.push({
|
|
89
|
+
endpointType: "ethereum",
|
|
90
|
+
method: route.method,
|
|
91
|
+
responseKey: route.responseKey,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
for (const route of routing.rpcEndpoints ?? []) {
|
|
95
|
+
routes.push({
|
|
96
|
+
endpointType: "rpc",
|
|
97
|
+
urlPattern: route.urlPattern,
|
|
98
|
+
methods: route.methods,
|
|
99
|
+
responseKey: route.responseKey,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const route of routing.httpEndpoints ?? []) {
|
|
103
|
+
routes.push({
|
|
104
|
+
endpointType: "http",
|
|
105
|
+
urlPattern: route.urlPattern,
|
|
106
|
+
method: route.method,
|
|
107
|
+
responseKey: route.responseKey,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const route of routing.wsEndpoints ?? []) {
|
|
111
|
+
routes.push({
|
|
112
|
+
endpointType: "ws",
|
|
113
|
+
urlPattern: route.urlPattern,
|
|
114
|
+
responseKey: route.responseKey,
|
|
115
|
+
match: route.match,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return routes;
|
|
119
|
+
}
|
|
120
|
+
export function createScenarioRuntime(input) {
|
|
121
|
+
const parsed = LuaConfigSchema.parse(input);
|
|
122
|
+
let routeMocks = parsed.intercept?.routes
|
|
123
|
+
? parsed.intercept.routes.map((route) => ({ ...route }))
|
|
124
|
+
: normalizeLegacyRoutes(parsed.intercept?.routing);
|
|
125
|
+
let interceptState = deepClone(parsed.intercept?.state ?? {});
|
|
126
|
+
return {
|
|
127
|
+
getConfig() {
|
|
128
|
+
const cloned = deepClone(parsed);
|
|
129
|
+
cloned.intercept = cloned.intercept ?? {};
|
|
130
|
+
cloned.intercept.routes = routeMocks.map((route) => ({ ...route }));
|
|
131
|
+
cloned.intercept.state = deepClone(interceptState);
|
|
132
|
+
return cloned;
|
|
133
|
+
},
|
|
134
|
+
getRouteMocks() {
|
|
135
|
+
return routeMocks.map((route) => ({ ...route }));
|
|
136
|
+
},
|
|
137
|
+
setRouteMocks(routes) {
|
|
138
|
+
routeMocks = z.array(RouteMockSchema).parse(routes).map((route) => ({ ...route }));
|
|
139
|
+
return routeMocks.map((route) => ({ ...route }));
|
|
140
|
+
},
|
|
141
|
+
getInterceptState() {
|
|
142
|
+
return deepClone(interceptState);
|
|
143
|
+
},
|
|
144
|
+
applyInterceptState(partialState) {
|
|
145
|
+
const normalized = StringRecordSchema.parse(partialState);
|
|
146
|
+
interceptState = deepMerge(interceptState, normalized);
|
|
147
|
+
return deepClone(interceptState);
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function applyInterceptState(runtime, partialState) {
|
|
152
|
+
return runtime.applyInterceptState(partialState);
|
|
153
|
+
}
|
|
154
|
+
export function setRouteMocks(runtime, routes) {
|
|
155
|
+
return runtime.setRouteMocks(routes);
|
|
156
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const RuntimeOptionsSchema: z.ZodObject<{
|
|
3
|
+
seed: z.ZodOptional<z.ZodNumber>;
|
|
4
|
+
now: z.ZodOptional<z.ZodNumber>;
|
|
5
|
+
instructionLimit: z.ZodOptional<z.ZodNumber>;
|
|
6
|
+
memoryLimit: z.ZodOptional<z.ZodNumber>;
|
|
7
|
+
}, z.core.$strict>;
|
|
8
|
+
export type RuntimeOptions = z.infer<typeof RuntimeOptionsSchema>;
|
|
9
|
+
export interface Runtime {
|
|
10
|
+
eval(code: string): Promise<void>;
|
|
11
|
+
call(name: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
12
|
+
register(name: string, hostFn: (...args: unknown[]) => unknown): void;
|
|
13
|
+
getState(keys?: string[]): Promise<Record<string, unknown>>;
|
|
14
|
+
setInstructionLimit(n: number): void;
|
|
15
|
+
setMemoryLimit(bytes: number): void;
|
|
16
|
+
reset(): Promise<void>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const RuntimeOptionsSchema = z
|
|
3
|
+
.object({
|
|
4
|
+
seed: z.number().finite().optional(),
|
|
5
|
+
now: z.number().finite().optional(),
|
|
6
|
+
instructionLimit: z.number().int().positive().optional(),
|
|
7
|
+
memoryLimit: z.number().int().positive().optional(),
|
|
8
|
+
})
|
|
9
|
+
.strict();
|