@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Coco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { createRuntime } from "../runtime/engine.js";
|
|
2
|
+
import { LuaConfigSchema } from "../runtime/scenario-runtime.js";
|
|
3
|
+
import { isRecord } from "@lunatest/contracts";
|
|
4
|
+
function seemsInlineLua(input) {
|
|
5
|
+
return input.includes("\n") || input.includes("scenario {") || input.includes("scenario{");
|
|
6
|
+
}
|
|
7
|
+
async function readSource(source) {
|
|
8
|
+
if (source instanceof URL) {
|
|
9
|
+
if (source.protocol === "http:" || source.protocol === "https:") {
|
|
10
|
+
if (typeof fetch !== "function") {
|
|
11
|
+
throw new Error(`Fetch API is unavailable for URL source: ${source.toString()}`);
|
|
12
|
+
}
|
|
13
|
+
const response = await fetch(source);
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`Failed to load Lua config: ${source.toString()} (${response.status})`);
|
|
16
|
+
}
|
|
17
|
+
return response.text();
|
|
18
|
+
}
|
|
19
|
+
if (source.protocol !== "file:") {
|
|
20
|
+
throw new Error(`Unsupported Lua config URL protocol: ${source.protocol}`);
|
|
21
|
+
}
|
|
22
|
+
const [{ fileURLToPath }, { readFile }] = await Promise.all([
|
|
23
|
+
import("node:url"),
|
|
24
|
+
import("node:fs/promises"),
|
|
25
|
+
]);
|
|
26
|
+
return readFile(fileURLToPath(source), "utf8");
|
|
27
|
+
}
|
|
28
|
+
if (seemsInlineLua(source)) {
|
|
29
|
+
return source;
|
|
30
|
+
}
|
|
31
|
+
if (typeof document !== "undefined" && typeof fetch === "function") {
|
|
32
|
+
const response = await fetch(source);
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
throw new Error(`Failed to load Lua config: ${source} (${response.status})`);
|
|
35
|
+
}
|
|
36
|
+
return response.text();
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const { readFile } = await import("node:fs/promises");
|
|
40
|
+
return await readFile(source, "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return source;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function loadLunaConfig(source) {
|
|
47
|
+
const code = await readSource(source);
|
|
48
|
+
const runtime = await createRuntime();
|
|
49
|
+
let captured;
|
|
50
|
+
runtime.register("scenario", (table) => {
|
|
51
|
+
captured = table;
|
|
52
|
+
return table;
|
|
53
|
+
});
|
|
54
|
+
runtime.register("lunatest", (table) => {
|
|
55
|
+
captured = table;
|
|
56
|
+
return table;
|
|
57
|
+
});
|
|
58
|
+
await runtime.eval(code);
|
|
59
|
+
if (captured === undefined) {
|
|
60
|
+
const snapshot = await runtime.getState(["__lunatest_config"]);
|
|
61
|
+
captured = snapshot.__lunatest_config;
|
|
62
|
+
}
|
|
63
|
+
if (captured === undefined || captured === null) {
|
|
64
|
+
throw new Error("Lua config must declare scenario { ... } or lunatest({ ... })");
|
|
65
|
+
}
|
|
66
|
+
const parsed = LuaConfigSchema.parse(captured);
|
|
67
|
+
if (!isRecord(parsed.given)) {
|
|
68
|
+
throw new Error("Lua config given must be a table");
|
|
69
|
+
}
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const sdkName = "@lunatest/core";
|
|
2
|
+
export { LunaProvider } from "./provider/luna-provider.js";
|
|
3
|
+
export type { LunaProviderOptions } from "./provider/luna-provider.js";
|
|
4
|
+
export { loadLunaConfig } from "./config/lua-config.js";
|
|
5
|
+
export { applyInterceptState, createScenarioRuntime, LuaConfigSchema, setRouteMocks, type LuaConfig, type RouteMock, type ScenarioRuntime, } from "./runtime/scenario-runtime.js";
|
|
6
|
+
export { executeLuaScenario, type ExecuteLuaScenarioInput, type ExecuteLuaScenarioResult, } from "./runner/execute-scenario.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const sdkName = "@lunatest/core";
|
|
2
|
+
export { LunaProvider } from "./provider/luna-provider.js";
|
|
3
|
+
export { loadLunaConfig } from "./config/lua-config.js";
|
|
4
|
+
export { applyInterceptState, createScenarioRuntime, LuaConfigSchema, setRouteMocks, } from "./runtime/scenario-runtime.js";
|
|
5
|
+
export { executeLuaScenario, } from "./runner/execute-scenario.js";
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
type ProviderRequest = {
|
|
2
|
+
method: string;
|
|
3
|
+
params?: unknown[];
|
|
4
|
+
};
|
|
5
|
+
type TxStatus = "0x0" | "0x1";
|
|
6
|
+
type MockEvent = {
|
|
7
|
+
atMs: number;
|
|
8
|
+
type: "tx_submitted" | "tx_confirmed" | "tx_failed";
|
|
9
|
+
txHash: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
};
|
|
12
|
+
type MockProviderInput = {
|
|
13
|
+
chain?: {
|
|
14
|
+
id?: string;
|
|
15
|
+
blockNumber?: number;
|
|
16
|
+
};
|
|
17
|
+
wallet: {
|
|
18
|
+
address: string;
|
|
19
|
+
connected?: boolean;
|
|
20
|
+
chainId?: string;
|
|
21
|
+
balances: Record<string, string>;
|
|
22
|
+
allowances?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
events?: MockEvent[];
|
|
25
|
+
};
|
|
26
|
+
type MockProviderState = {
|
|
27
|
+
timeMs: number;
|
|
28
|
+
chain: {
|
|
29
|
+
id: string;
|
|
30
|
+
blockNumber: number;
|
|
31
|
+
};
|
|
32
|
+
wallet: {
|
|
33
|
+
address: string;
|
|
34
|
+
connected: boolean;
|
|
35
|
+
chainId: string;
|
|
36
|
+
balances: Record<string, string>;
|
|
37
|
+
allowances: Record<string, string>;
|
|
38
|
+
};
|
|
39
|
+
queue: MockEvent[];
|
|
40
|
+
receipts: Record<string, {
|
|
41
|
+
status: TxStatus;
|
|
42
|
+
transactionHash: string;
|
|
43
|
+
} | null>;
|
|
44
|
+
};
|
|
45
|
+
export type MockProvider = {
|
|
46
|
+
request: (payload: ProviderRequest) => Promise<unknown>;
|
|
47
|
+
given: MockProviderInput;
|
|
48
|
+
getState: () => MockProviderState;
|
|
49
|
+
advanceTime: (deltaMs: number) => void;
|
|
50
|
+
connect: (address?: string) => void;
|
|
51
|
+
disconnect: () => void;
|
|
52
|
+
approve: (token: string, amount: string) => void;
|
|
53
|
+
queueEvent: (event: MockEvent) => void;
|
|
54
|
+
};
|
|
55
|
+
export declare function createMockProvider(given: MockProviderInput): Promise<MockProvider>;
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
function parseTokenAmountToWeiHex(value) {
|
|
2
|
+
const [integerPartRaw, fractionalPartRaw = ""] = value.split(".");
|
|
3
|
+
const integerPart = integerPartRaw === "" ? "0" : integerPartRaw;
|
|
4
|
+
const fractionalPart = fractionalPartRaw.padEnd(18, "0").slice(0, 18);
|
|
5
|
+
const integerWei = BigInt(integerPart) * 10n ** 18n;
|
|
6
|
+
const fractionalWei = BigInt(fractionalPart || "0");
|
|
7
|
+
return `0x${(integerWei + fractionalWei).toString(16)}`;
|
|
8
|
+
}
|
|
9
|
+
function normalizeAddress(value) {
|
|
10
|
+
return value.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
function cloneState(state) {
|
|
13
|
+
return {
|
|
14
|
+
timeMs: state.timeMs,
|
|
15
|
+
chain: { ...state.chain },
|
|
16
|
+
wallet: {
|
|
17
|
+
...state.wallet,
|
|
18
|
+
balances: { ...state.wallet.balances },
|
|
19
|
+
allowances: { ...state.wallet.allowances },
|
|
20
|
+
},
|
|
21
|
+
queue: state.queue.map((event) => ({ ...event })),
|
|
22
|
+
receipts: Object.fromEntries(Object.entries(state.receipts).map(([key, value]) => [
|
|
23
|
+
key,
|
|
24
|
+
value ? { ...value } : null,
|
|
25
|
+
])),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function sortQueue(queue) {
|
|
29
|
+
queue.sort((left, right) => {
|
|
30
|
+
if (left.atMs === right.atMs) {
|
|
31
|
+
return left.type.localeCompare(right.type);
|
|
32
|
+
}
|
|
33
|
+
return left.atMs - right.atMs;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function createMockProvider(given) {
|
|
37
|
+
const state = {
|
|
38
|
+
timeMs: 0,
|
|
39
|
+
chain: {
|
|
40
|
+
id: given.chain?.id ?? given.wallet.chainId ?? "0x1",
|
|
41
|
+
blockNumber: given.chain?.blockNumber ?? 0,
|
|
42
|
+
},
|
|
43
|
+
wallet: {
|
|
44
|
+
address: given.wallet.address,
|
|
45
|
+
connected: given.wallet.connected ?? true,
|
|
46
|
+
chainId: given.wallet.chainId ?? given.chain?.id ?? "0x1",
|
|
47
|
+
balances: { ...given.wallet.balances },
|
|
48
|
+
allowances: { ...(given.wallet.allowances ?? {}) },
|
|
49
|
+
},
|
|
50
|
+
queue: [...(given.events ?? [])],
|
|
51
|
+
receipts: {},
|
|
52
|
+
};
|
|
53
|
+
sortQueue(state.queue);
|
|
54
|
+
let txCounter = 1n;
|
|
55
|
+
const applyEvent = (event) => {
|
|
56
|
+
if (event.type === "tx_submitted") {
|
|
57
|
+
state.receipts[event.txHash] = null;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (event.type === "tx_confirmed") {
|
|
61
|
+
state.receipts[event.txHash] = {
|
|
62
|
+
transactionHash: event.txHash,
|
|
63
|
+
status: "0x1",
|
|
64
|
+
};
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
state.receipts[event.txHash] = {
|
|
68
|
+
transactionHash: event.txHash,
|
|
69
|
+
status: "0x0",
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
const consumeDueEvents = () => {
|
|
73
|
+
const remaining = [];
|
|
74
|
+
for (const event of state.queue) {
|
|
75
|
+
if (event.atMs <= state.timeMs) {
|
|
76
|
+
applyEvent(event);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
remaining.push(event);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
state.queue = remaining;
|
|
83
|
+
};
|
|
84
|
+
const queueEvent = (event) => {
|
|
85
|
+
state.queue.push({ ...event });
|
|
86
|
+
sortQueue(state.queue);
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
given,
|
|
90
|
+
getState() {
|
|
91
|
+
return cloneState(state);
|
|
92
|
+
},
|
|
93
|
+
advanceTime(deltaMs) {
|
|
94
|
+
state.timeMs += Math.max(0, Math.trunc(deltaMs));
|
|
95
|
+
consumeDueEvents();
|
|
96
|
+
},
|
|
97
|
+
connect(address) {
|
|
98
|
+
state.wallet.connected = true;
|
|
99
|
+
if (address) {
|
|
100
|
+
state.wallet.address = address;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
disconnect() {
|
|
104
|
+
state.wallet.connected = false;
|
|
105
|
+
},
|
|
106
|
+
approve(token, amount) {
|
|
107
|
+
state.wallet.allowances[token] = amount;
|
|
108
|
+
},
|
|
109
|
+
queueEvent,
|
|
110
|
+
async request(payload) {
|
|
111
|
+
const method = payload.method;
|
|
112
|
+
if (method === "eth_chainId") {
|
|
113
|
+
return state.chain.id;
|
|
114
|
+
}
|
|
115
|
+
if (method === "eth_accounts") {
|
|
116
|
+
return state.wallet.connected ? [state.wallet.address] : [];
|
|
117
|
+
}
|
|
118
|
+
if (method === "eth_getBalance") {
|
|
119
|
+
const [requestedAddress] = payload.params ?? [];
|
|
120
|
+
const addressMatches = typeof requestedAddress === "string" &&
|
|
121
|
+
normalizeAddress(requestedAddress) === normalizeAddress(state.wallet.address);
|
|
122
|
+
if (!addressMatches) {
|
|
123
|
+
return "0x0";
|
|
124
|
+
}
|
|
125
|
+
const amount = state.wallet.balances.ETH ?? "0";
|
|
126
|
+
return parseTokenAmountToWeiHex(amount);
|
|
127
|
+
}
|
|
128
|
+
if (method === "eth_sendTransaction") {
|
|
129
|
+
const txHash = `0x${txCounter.toString(16).padStart(64, "0")}`;
|
|
130
|
+
txCounter += 1n;
|
|
131
|
+
queueEvent({
|
|
132
|
+
atMs: state.timeMs,
|
|
133
|
+
type: "tx_submitted",
|
|
134
|
+
txHash,
|
|
135
|
+
});
|
|
136
|
+
queueEvent({
|
|
137
|
+
atMs: state.timeMs + 3000,
|
|
138
|
+
type: "tx_confirmed",
|
|
139
|
+
txHash,
|
|
140
|
+
});
|
|
141
|
+
consumeDueEvents();
|
|
142
|
+
return txHash;
|
|
143
|
+
}
|
|
144
|
+
if (method === "eth_getTransactionReceipt") {
|
|
145
|
+
const [txHash] = payload.params ?? [];
|
|
146
|
+
if (typeof txHash !== "string") {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return state.receipts[txHash] ?? null;
|
|
150
|
+
}
|
|
151
|
+
if (method === "wallet_switchEthereumChain") {
|
|
152
|
+
const [target] = payload.params ?? [];
|
|
153
|
+
const chainId = target && typeof target === "object"
|
|
154
|
+
? target.chainId
|
|
155
|
+
: undefined;
|
|
156
|
+
if (typeof chainId !== "string") {
|
|
157
|
+
throw new Error("wallet_switchEthereumChain requires chainId");
|
|
158
|
+
}
|
|
159
|
+
state.chain.id = chainId;
|
|
160
|
+
state.wallet.chainId = chainId;
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type Eip1193Request = {
|
|
2
|
+
method: string;
|
|
3
|
+
params?: unknown[];
|
|
4
|
+
};
|
|
5
|
+
export type LunaProviderOptions = {
|
|
6
|
+
chainId?: string;
|
|
7
|
+
accounts?: string[];
|
|
8
|
+
balances?: Record<string, string>;
|
|
9
|
+
callHandler?: (input: Record<string, unknown>) => Promise<string> | string;
|
|
10
|
+
};
|
|
11
|
+
type ProviderListener = (...args: unknown[]) => void;
|
|
12
|
+
export declare class LunaProvider {
|
|
13
|
+
private chainId;
|
|
14
|
+
private accounts;
|
|
15
|
+
private balances;
|
|
16
|
+
private callHandler?;
|
|
17
|
+
private txCounter;
|
|
18
|
+
private subCounter;
|
|
19
|
+
private receipts;
|
|
20
|
+
private listeners;
|
|
21
|
+
constructor(options: LunaProviderOptions);
|
|
22
|
+
on(event: string, listener: ProviderListener): this;
|
|
23
|
+
removeListener(event: string, listener: ProviderListener): this;
|
|
24
|
+
private emit;
|
|
25
|
+
request(payload: Eip1193Request): Promise<unknown>;
|
|
26
|
+
}
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
function normalizeAddress(value) {
|
|
2
|
+
return value.toLowerCase();
|
|
3
|
+
}
|
|
4
|
+
export class LunaProvider {
|
|
5
|
+
chainId;
|
|
6
|
+
accounts;
|
|
7
|
+
balances;
|
|
8
|
+
callHandler;
|
|
9
|
+
txCounter;
|
|
10
|
+
subCounter;
|
|
11
|
+
receipts;
|
|
12
|
+
listeners;
|
|
13
|
+
constructor(options) {
|
|
14
|
+
this.chainId = options.chainId ?? "0x1";
|
|
15
|
+
this.accounts = options.accounts ?? [];
|
|
16
|
+
this.balances = Object.fromEntries(Object.entries(options.balances ?? {}).map(([key, value]) => [
|
|
17
|
+
normalizeAddress(key),
|
|
18
|
+
value,
|
|
19
|
+
]));
|
|
20
|
+
this.callHandler = options.callHandler;
|
|
21
|
+
this.txCounter = 1n;
|
|
22
|
+
this.subCounter = 1;
|
|
23
|
+
this.receipts = new Map();
|
|
24
|
+
this.listeners = new Map();
|
|
25
|
+
}
|
|
26
|
+
on(event, listener) {
|
|
27
|
+
const bucket = this.listeners.get(event) ?? new Set();
|
|
28
|
+
bucket.add(listener);
|
|
29
|
+
this.listeners.set(event, bucket);
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
removeListener(event, listener) {
|
|
33
|
+
const bucket = this.listeners.get(event);
|
|
34
|
+
if (!bucket) {
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
bucket.delete(listener);
|
|
38
|
+
if (bucket.size === 0) {
|
|
39
|
+
this.listeners.delete(event);
|
|
40
|
+
}
|
|
41
|
+
return this;
|
|
42
|
+
}
|
|
43
|
+
emit(event, ...args) {
|
|
44
|
+
const bucket = this.listeners.get(event);
|
|
45
|
+
if (!bucket) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const listener of bucket) {
|
|
49
|
+
listener(...args);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async request(payload) {
|
|
53
|
+
const method = payload.method;
|
|
54
|
+
if (method === "eth_chainId") {
|
|
55
|
+
return this.chainId;
|
|
56
|
+
}
|
|
57
|
+
if (method === "eth_accounts") {
|
|
58
|
+
return this.accounts;
|
|
59
|
+
}
|
|
60
|
+
if (method === "eth_getBalance") {
|
|
61
|
+
const [address] = payload.params ?? [];
|
|
62
|
+
if (typeof address !== "string") {
|
|
63
|
+
return "0x0";
|
|
64
|
+
}
|
|
65
|
+
return this.balances[normalizeAddress(address)] ?? "0x0";
|
|
66
|
+
}
|
|
67
|
+
if (method === "eth_call") {
|
|
68
|
+
const [callInput] = payload.params ?? [];
|
|
69
|
+
const normalized = callInput && typeof callInput === "object"
|
|
70
|
+
? callInput
|
|
71
|
+
: {};
|
|
72
|
+
if (!this.callHandler) {
|
|
73
|
+
return "0x";
|
|
74
|
+
}
|
|
75
|
+
return this.callHandler(normalized);
|
|
76
|
+
}
|
|
77
|
+
if (method === "eth_sendTransaction") {
|
|
78
|
+
const txHash = `0x${this.txCounter.toString(16).padStart(64, "0")}`;
|
|
79
|
+
this.txCounter += 1n;
|
|
80
|
+
this.receipts.set(txHash, {
|
|
81
|
+
transactionHash: txHash,
|
|
82
|
+
status: "0x1",
|
|
83
|
+
blockNumber: "0x1",
|
|
84
|
+
});
|
|
85
|
+
this.emit("message", {
|
|
86
|
+
type: "tx_submitted",
|
|
87
|
+
data: { transactionHash: txHash },
|
|
88
|
+
});
|
|
89
|
+
return txHash;
|
|
90
|
+
}
|
|
91
|
+
if (method === "eth_getTransactionReceipt") {
|
|
92
|
+
const [txHash] = payload.params ?? [];
|
|
93
|
+
if (typeof txHash !== "string") {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
return this.receipts.get(txHash) ?? null;
|
|
97
|
+
}
|
|
98
|
+
if (method === "wallet_switchEthereumChain") {
|
|
99
|
+
const [target] = payload.params ?? [];
|
|
100
|
+
const chainId = target && typeof target === "object"
|
|
101
|
+
? target.chainId
|
|
102
|
+
: undefined;
|
|
103
|
+
if (typeof chainId !== "string") {
|
|
104
|
+
throw new Error("wallet_switchEthereumChain requires chainId");
|
|
105
|
+
}
|
|
106
|
+
this.chainId = chainId;
|
|
107
|
+
this.emit("chainChanged", chainId);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (method === "eth_subscribe") {
|
|
111
|
+
const id = `0xsub${this.subCounter.toString(16).padStart(4, "0")}`;
|
|
112
|
+
this.subCounter += 1;
|
|
113
|
+
return id;
|
|
114
|
+
}
|
|
115
|
+
throw new Error(`Unsupported method: ${method}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type AssertionResult = {
|
|
2
|
+
pass: boolean;
|
|
3
|
+
diff: string;
|
|
4
|
+
expected: unknown;
|
|
5
|
+
actual: unknown;
|
|
6
|
+
};
|
|
7
|
+
export declare function assertUI(expected: unknown, actual: unknown): AssertionResult;
|
|
8
|
+
export declare function assertState(expected: unknown, actual: unknown): AssertionResult;
|
|
9
|
+
export declare function assertTransition(expectedPath: string[], actualPath: string[]): AssertionResult;
|
|
10
|
+
export declare function assertNot(forbiddenPaths: string[], actual: Record<string, unknown>): AssertionResult;
|
|
11
|
+
export declare function assertTiming(targetMs: number, actualMs: number): AssertionResult;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
function format(value) {
|
|
2
|
+
return JSON.stringify(value, null, 2);
|
|
3
|
+
}
|
|
4
|
+
function deepEqual(left, right) {
|
|
5
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
6
|
+
}
|
|
7
|
+
function buildResult(expected, actual) {
|
|
8
|
+
const pass = deepEqual(expected, actual);
|
|
9
|
+
return {
|
|
10
|
+
pass,
|
|
11
|
+
diff: pass ? "" : `expected ${format(expected)} but got ${format(actual)}`,
|
|
12
|
+
expected,
|
|
13
|
+
actual,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function hasPath(input, path) {
|
|
17
|
+
const parts = path.split(".").filter(Boolean);
|
|
18
|
+
let cursor = input;
|
|
19
|
+
for (const part of parts) {
|
|
20
|
+
if (!cursor || typeof cursor !== "object" || !(part in cursor)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
cursor = cursor[part];
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
export function assertUI(expected, actual) {
|
|
28
|
+
return buildResult(expected, actual);
|
|
29
|
+
}
|
|
30
|
+
export function assertState(expected, actual) {
|
|
31
|
+
return buildResult(expected, actual);
|
|
32
|
+
}
|
|
33
|
+
export function assertTransition(expectedPath, actualPath) {
|
|
34
|
+
return buildResult(expectedPath, actualPath);
|
|
35
|
+
}
|
|
36
|
+
export function assertNot(forbiddenPaths, actual) {
|
|
37
|
+
const found = forbiddenPaths.filter((path) => hasPath(actual, path));
|
|
38
|
+
const pass = found.length === 0;
|
|
39
|
+
return {
|
|
40
|
+
pass,
|
|
41
|
+
diff: pass
|
|
42
|
+
? ""
|
|
43
|
+
: `forbidden paths found: ${found.join(", ")} in ${format(actual)}`,
|
|
44
|
+
expected: forbiddenPaths,
|
|
45
|
+
actual,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function assertTiming(targetMs, actualMs) {
|
|
49
|
+
const pass = actualMs <= targetMs;
|
|
50
|
+
return {
|
|
51
|
+
pass,
|
|
52
|
+
diff: pass
|
|
53
|
+
? ""
|
|
54
|
+
: `expected timing <= ${targetMs}ms but got ${actualMs}ms`,
|
|
55
|
+
expected: targetMs,
|
|
56
|
+
actual: actualMs,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { RunScenarioResult } from "./runner.js";
|
|
2
|
+
import { type LuaConfig, type ScenarioRuntime } from "../runtime/scenario-runtime.js";
|
|
3
|
+
type LuaScenarioSource = string | URL | LuaConfig;
|
|
4
|
+
type ExecuteAdapter = {
|
|
5
|
+
runWhen?: (context: {
|
|
6
|
+
config: LuaConfig;
|
|
7
|
+
runtime: ScenarioRuntime;
|
|
8
|
+
}) => Promise<void> | void;
|
|
9
|
+
resolveUi?: (context: {
|
|
10
|
+
config: LuaConfig;
|
|
11
|
+
runtime: ScenarioRuntime;
|
|
12
|
+
}) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
13
|
+
resolveState?: (context: {
|
|
14
|
+
config: LuaConfig;
|
|
15
|
+
runtime: ScenarioRuntime;
|
|
16
|
+
}) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
17
|
+
resolveTransitions?: (context: {
|
|
18
|
+
config: LuaConfig;
|
|
19
|
+
runtime: ScenarioRuntime;
|
|
20
|
+
}) => Promise<string[]> | string[];
|
|
21
|
+
resolveElapsedMs?: (context: {
|
|
22
|
+
config: LuaConfig;
|
|
23
|
+
runtime: ScenarioRuntime;
|
|
24
|
+
}) => Promise<number> | number;
|
|
25
|
+
};
|
|
26
|
+
export type ExecuteLuaScenarioInput = {
|
|
27
|
+
source: LuaScenarioSource;
|
|
28
|
+
adapter?: ExecuteAdapter;
|
|
29
|
+
};
|
|
30
|
+
export type ExecuteLuaScenarioResult = {
|
|
31
|
+
scenarioName: string;
|
|
32
|
+
pass: boolean;
|
|
33
|
+
error?: string;
|
|
34
|
+
result?: RunScenarioResult;
|
|
35
|
+
config: LuaConfig;
|
|
36
|
+
};
|
|
37
|
+
export declare function executeLuaScenario(input: ExecuteLuaScenarioInput): Promise<ExecuteLuaScenarioResult>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { runScenario } from "./runner.js";
|
|
2
|
+
import { loadLunaConfig } from "../config/lua-config.js";
|
|
3
|
+
import { createScenarioRuntime, LuaConfigSchema, } from "../runtime/scenario-runtime.js";
|
|
4
|
+
async function resolveConfig(source) {
|
|
5
|
+
if (typeof source === "string" || source instanceof URL) {
|
|
6
|
+
return loadLunaConfig(source);
|
|
7
|
+
}
|
|
8
|
+
return LuaConfigSchema.parse(source);
|
|
9
|
+
}
|
|
10
|
+
function normalizeScenario(config) {
|
|
11
|
+
const whenAction = config.when && typeof config.when.action === "string" ? config.when.action : "run";
|
|
12
|
+
return {
|
|
13
|
+
name: config.name ?? "unnamed",
|
|
14
|
+
given: config.given,
|
|
15
|
+
when: {
|
|
16
|
+
action: whenAction,
|
|
17
|
+
...(config.when ?? {}),
|
|
18
|
+
},
|
|
19
|
+
then_ui: config.then_ui ?? {},
|
|
20
|
+
then_state: config.then_state,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export async function executeLuaScenario(input) {
|
|
24
|
+
const config = await resolveConfig(input.source);
|
|
25
|
+
const runtime = createScenarioRuntime(config);
|
|
26
|
+
const scenario = normalizeScenario(config);
|
|
27
|
+
if (!input.adapter?.resolveUi) {
|
|
28
|
+
return {
|
|
29
|
+
scenarioName: scenario.name,
|
|
30
|
+
pass: false,
|
|
31
|
+
error: "executor_not_configured",
|
|
32
|
+
config,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (input.adapter.runWhen) {
|
|
37
|
+
await input.adapter.runWhen({
|
|
38
|
+
config,
|
|
39
|
+
runtime,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const result = await runScenario({
|
|
43
|
+
scenario,
|
|
44
|
+
resolveUi: () => input.adapter.resolveUi({ config, runtime }),
|
|
45
|
+
resolveState: input.adapter.resolveState
|
|
46
|
+
? () => input.adapter.resolveState({ config, runtime })
|
|
47
|
+
: undefined,
|
|
48
|
+
resolveTransitions: input.adapter.resolveTransitions
|
|
49
|
+
? () => input.adapter.resolveTransitions({ config, runtime })
|
|
50
|
+
: undefined,
|
|
51
|
+
resolveElapsedMs: input.adapter.resolveElapsedMs
|
|
52
|
+
? () => input.adapter.resolveElapsedMs({ config, runtime })
|
|
53
|
+
: undefined,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
scenarioName: scenario.name,
|
|
57
|
+
pass: result.pass,
|
|
58
|
+
result,
|
|
59
|
+
config,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch (cause) {
|
|
63
|
+
return {
|
|
64
|
+
scenarioName: scenario.name,
|
|
65
|
+
pass: false,
|
|
66
|
+
error: cause instanceof Error ? cause.message : String(cause),
|
|
67
|
+
config,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type RunScenarioInput } from "./runner.js";
|
|
2
|
+
export type ScenarioPerformanceInput = {
|
|
3
|
+
iterations: number;
|
|
4
|
+
scenario: RunScenarioInput["scenario"];
|
|
5
|
+
resolveUi: RunScenarioInput["resolveUi"];
|
|
6
|
+
};
|
|
7
|
+
export type ScenarioPerformanceResult = {
|
|
8
|
+
iterations: number;
|
|
9
|
+
totalMs: number;
|
|
10
|
+
averageMs: number;
|
|
11
|
+
p95Ms: number;
|
|
12
|
+
samplesMs: number[];
|
|
13
|
+
};
|
|
14
|
+
export declare function measureScenarioPerformance(input: ScenarioPerformanceInput): Promise<ScenarioPerformanceResult>;
|