dungbeetle 0.1.1
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 +105 -0
- package/NOTICE +19 -0
- package/README.md +139 -0
- package/dist/api/capture.d.ts +24 -0
- package/dist/api/capture.js +61 -0
- package/dist/baselines.d.ts +7 -0
- package/dist/baselines.js +38 -0
- package/dist/brand.d.ts +2 -0
- package/dist/brand.js +9 -0
- package/dist/capture.d.ts +15 -0
- package/dist/capture.js +7 -0
- package/dist/captures/api.d.ts +2 -0
- package/dist/captures/api.js +114 -0
- package/dist/captures/check.d.ts +2 -0
- package/dist/captures/check.js +116 -0
- package/dist/captures/desktop.d.ts +2 -0
- package/dist/captures/desktop.js +97 -0
- package/dist/captures/game.d.ts +4 -0
- package/dist/captures/game.js +266 -0
- package/dist/captures/performance.d.ts +2 -0
- package/dist/captures/performance.js +47 -0
- package/dist/captures/registry.d.ts +4 -0
- package/dist/captures/registry.js +23 -0
- package/dist/captures/terminal.d.ts +2 -0
- package/dist/captures/terminal.js +65 -0
- package/dist/captures/types.d.ts +18 -0
- package/dist/captures/types.js +1 -0
- package/dist/captures/web.d.ts +3 -0
- package/dist/captures/web.js +248 -0
- package/dist/check/capture.d.ts +15 -0
- package/dist/check/capture.js +76 -0
- package/dist/check/junit.d.ts +9 -0
- package/dist/check/junit.js +51 -0
- package/dist/check/laravel.d.ts +2 -0
- package/dist/check/laravel.js +44 -0
- package/dist/check/parsers.d.ts +12 -0
- package/dist/check/parsers.js +278 -0
- package/dist/check/schema.d.ts +2 -0
- package/dist/check/schema.js +114 -0
- package/dist/cloud.d.ts +42 -0
- package/dist/cloud.js +334 -0
- package/dist/compare/shared.d.ts +42 -0
- package/dist/compare/shared.js +115 -0
- package/dist/compare.d.ts +3 -0
- package/dist/compare.js +33 -0
- package/dist/config.d.ts +146 -0
- package/dist/config.js +382 -0
- package/dist/desktop/a11y.d.ts +18 -0
- package/dist/desktop/a11y.js +74 -0
- package/dist/desktop/capture.d.ts +13 -0
- package/dist/desktop/capture.js +80 -0
- package/dist/desktop/macos.d.ts +8 -0
- package/dist/desktop/macos.js +98 -0
- package/dist/desktop/ocr.d.ts +17 -0
- package/dist/desktop/ocr.js +99 -0
- package/dist/diff/lcs.d.ts +5 -0
- package/dist/diff/lcs.js +42 -0
- package/dist/diff/numeric.d.ts +6 -0
- package/dist/diff/numeric.js +24 -0
- package/dist/diff/pixel.d.ts +23 -0
- package/dist/diff/pixel.js +97 -0
- package/dist/diff/structural.d.ts +11 -0
- package/dist/diff/structural.js +38 -0
- package/dist/diff/text.d.ts +7 -0
- package/dist/diff/text.js +64 -0
- package/dist/diff/tree.d.ts +46 -0
- package/dist/diff/tree.js +188 -0
- package/dist/doctor.d.ts +18 -0
- package/dist/doctor.js +57 -0
- package/dist/game/capture.d.ts +24 -0
- package/dist/game/capture.js +51 -0
- package/dist/game/protocol.d.ts +30 -0
- package/dist/game/protocol.js +146 -0
- package/dist/game/walkthrough.d.ts +45 -0
- package/dist/game/walkthrough.js +85 -0
- package/dist/guards.d.ts +2 -0
- package/dist/guards.js +15 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +504 -0
- package/dist/json.d.ts +2 -0
- package/dist/json.js +40 -0
- package/dist/lifecycle.d.ts +14 -0
- package/dist/lifecycle.js +190 -0
- package/dist/normalization.d.ts +4 -0
- package/dist/normalization.js +27 -0
- package/dist/perf/ab.d.ts +6 -0
- package/dist/perf/ab.js +89 -0
- package/dist/perf/autocannon.d.ts +6 -0
- package/dist/perf/autocannon.js +101 -0
- package/dist/perf/capture.d.ts +7 -0
- package/dist/perf/capture.js +6 -0
- package/dist/perf/k6.d.ts +9 -0
- package/dist/perf/k6.js +44 -0
- package/dist/perf/parsers.d.ts +15 -0
- package/dist/perf/parsers.js +69 -0
- package/dist/perf/run.d.ts +8 -0
- package/dist/perf/run.js +45 -0
- package/dist/perf/toolOutput.d.ts +3 -0
- package/dist/perf/toolOutput.js +24 -0
- package/dist/reporters.d.ts +11 -0
- package/dist/reporters.js +314 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +352 -0
- package/dist/snapshot.d.ts +48 -0
- package/dist/snapshot.js +37 -0
- package/dist/terminal/ansi.d.ts +21 -0
- package/dist/terminal/ansi.js +144 -0
- package/dist/terminal/capture.d.ts +30 -0
- package/dist/terminal/capture.js +91 -0
- package/dist/tty.d.ts +72 -0
- package/dist/tty.js +175 -0
- package/dist/web/domSnapshot.d.ts +27 -0
- package/dist/web/domSnapshot.js +55 -0
- package/dist/web/playwrightCapture.d.ts +16 -0
- package/dist/web/playwrightCapture.js +64 -0
- package/package.json +79 -0
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DungbeetleConfig } from "./config.js";
|
|
2
|
+
export type DoctorSeverity = "pass" | "warn" | "fail";
|
|
3
|
+
export type DoctorCheck = {
|
|
4
|
+
name: string;
|
|
5
|
+
severity: DoctorSeverity;
|
|
6
|
+
message: string;
|
|
7
|
+
target?: string;
|
|
8
|
+
};
|
|
9
|
+
export type DoctorReport = {
|
|
10
|
+
kind: "dungbeetle-doctor";
|
|
11
|
+
passed: boolean;
|
|
12
|
+
project: string;
|
|
13
|
+
checks: DoctorCheck[];
|
|
14
|
+
};
|
|
15
|
+
export declare function runDoctor(options: {
|
|
16
|
+
config: DungbeetleConfig;
|
|
17
|
+
cwd?: string;
|
|
18
|
+
}): Promise<DoctorReport>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { captureTypes } from "./captures/registry.js";
|
|
3
|
+
export async function runDoctor(options) {
|
|
4
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
5
|
+
const checks = [
|
|
6
|
+
validateCaptureTargets(options.config),
|
|
7
|
+
validateBaselinePath(options.config),
|
|
8
|
+
validateArtifactsPath(options.config),
|
|
9
|
+
...(await validateTargets(options.config, cwd))
|
|
10
|
+
];
|
|
11
|
+
return {
|
|
12
|
+
kind: "dungbeetle-doctor",
|
|
13
|
+
passed: checks.every((check) => check.severity !== "fail"),
|
|
14
|
+
project: options.config.project.name,
|
|
15
|
+
checks
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function validateCaptureTargets(config) {
|
|
19
|
+
return config.lifecycle.capture.length > 0
|
|
20
|
+
? {
|
|
21
|
+
name: "capture-targets",
|
|
22
|
+
severity: "pass",
|
|
23
|
+
message: `${config.lifecycle.capture.length} capture target(s) configured.`
|
|
24
|
+
}
|
|
25
|
+
: {
|
|
26
|
+
name: "capture-targets",
|
|
27
|
+
severity: "fail",
|
|
28
|
+
message: "No capture targets configured. Add terminal or web targets to lifecycle.capture."
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function validateBaselinePath(config) {
|
|
32
|
+
return config.baselinesDir.startsWith(".dungbeetle")
|
|
33
|
+
? {
|
|
34
|
+
name: "baseline-path",
|
|
35
|
+
severity: "warn",
|
|
36
|
+
message: "baselinesDir is inside .dungbeetle, which is usually ignored. Use a reviewable path such as dungbeetle.snapshots."
|
|
37
|
+
}
|
|
38
|
+
: {
|
|
39
|
+
name: "baseline-path",
|
|
40
|
+
severity: "pass",
|
|
41
|
+
message: `Baselines will be stored in ${config.baselinesDir}.`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function validateArtifactsPath(config) {
|
|
45
|
+
return {
|
|
46
|
+
name: "artifact-path",
|
|
47
|
+
severity: "pass",
|
|
48
|
+
message: `Run artifacts will be stored in ${config.artifactsDir}.`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function validateTargets(config, cwd) {
|
|
52
|
+
const checks = [];
|
|
53
|
+
for (const target of config.lifecycle.capture) {
|
|
54
|
+
checks.push(...(await captureTypes[target.kind].doctorChecks(target, cwd)));
|
|
55
|
+
}
|
|
56
|
+
return checks;
|
|
57
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CaptureTarget } from "../config.js";
|
|
2
|
+
export type GameTarget = Extract<CaptureTarget, {
|
|
3
|
+
kind: "game";
|
|
4
|
+
}>;
|
|
5
|
+
export type GameSnapshot = {
|
|
6
|
+
kind: "game";
|
|
7
|
+
engine: string;
|
|
8
|
+
engineVersion: string;
|
|
9
|
+
adapterVersion: string;
|
|
10
|
+
ticks: number;
|
|
11
|
+
markers: Record<string, {
|
|
12
|
+
tick: number;
|
|
13
|
+
state: unknown;
|
|
14
|
+
}>;
|
|
15
|
+
screenshots?: Record<string, {
|
|
16
|
+
mimeType: "image/png";
|
|
17
|
+
data: string;
|
|
18
|
+
}>;
|
|
19
|
+
};
|
|
20
|
+
export declare function resolveEnginePath(target: GameTarget): string;
|
|
21
|
+
export declare function captureGame(target: GameTarget, options: {
|
|
22
|
+
cwd: string;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
}): Promise<GameSnapshot>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { runWalkthrough } from "./protocol.js";
|
|
5
|
+
import { loadWalkthrough } from "./walkthrough.js";
|
|
6
|
+
export function resolveEnginePath(target) {
|
|
7
|
+
return target.enginePath ?? process.env.DUNGBEETLE_GODOT_PATH ?? "godot";
|
|
8
|
+
}
|
|
9
|
+
export async function captureGame(target, options) {
|
|
10
|
+
const walkthrough = await loadWalkthrough(path.resolve(options.cwd, target.walkthrough));
|
|
11
|
+
const project = path.resolve(options.cwd, target.project);
|
|
12
|
+
const visual = target.mode === "visual";
|
|
13
|
+
const outDir = await mkdtemp(path.join(tmpdir(), "dungbeetle-game-"));
|
|
14
|
+
try {
|
|
15
|
+
const run = await runWalkthrough({
|
|
16
|
+
command: resolveEnginePath(target),
|
|
17
|
+
args: visual ? ["--path", project] : ["--headless", "--path", project],
|
|
18
|
+
cwd: project,
|
|
19
|
+
timeoutMs: target.timeoutMs ?? options.timeoutMs,
|
|
20
|
+
handshakeTimeoutMs: 10_000,
|
|
21
|
+
seed: target.seed ?? 0,
|
|
22
|
+
physicsFps: target.physicsFps ?? 60,
|
|
23
|
+
screenshots: visual,
|
|
24
|
+
outDir,
|
|
25
|
+
steps: walkthrough.steps
|
|
26
|
+
});
|
|
27
|
+
const markers = {};
|
|
28
|
+
const screenshots = {};
|
|
29
|
+
for (const [name, marker] of Object.entries(run.markers)) {
|
|
30
|
+
markers[name] = { tick: marker.tick, state: marker.state };
|
|
31
|
+
if (marker.screenshot) {
|
|
32
|
+
screenshots[name] = {
|
|
33
|
+
mimeType: "image/png",
|
|
34
|
+
data: (await readFile(marker.screenshot)).toString("base64")
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
kind: "game",
|
|
40
|
+
engine: run.engine.name,
|
|
41
|
+
engineVersion: run.engine.version,
|
|
42
|
+
adapterVersion: run.adapter.version,
|
|
43
|
+
ticks: run.ticks,
|
|
44
|
+
markers,
|
|
45
|
+
...(Object.keys(screenshots).length > 0 ? { screenshots } : {})
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await rm(outDir, { recursive: true, force: true });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { WalkthroughStep } from "./walkthrough.js";
|
|
2
|
+
export declare const PROTOCOL_VERSION = 1;
|
|
3
|
+
export type GameRunResult = {
|
|
4
|
+
engine: {
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
adapter: {
|
|
9
|
+
version: string;
|
|
10
|
+
};
|
|
11
|
+
ticks: number;
|
|
12
|
+
markers: Record<string, {
|
|
13
|
+
tick: number;
|
|
14
|
+
state: unknown;
|
|
15
|
+
screenshot?: string;
|
|
16
|
+
}>;
|
|
17
|
+
};
|
|
18
|
+
export type GameRunOptions = {
|
|
19
|
+
command: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
cwd: string;
|
|
22
|
+
timeoutMs: number;
|
|
23
|
+
handshakeTimeoutMs: number;
|
|
24
|
+
seed: number;
|
|
25
|
+
physicsFps: number;
|
|
26
|
+
screenshots: boolean;
|
|
27
|
+
outDir: string;
|
|
28
|
+
steps: WalkthroughStep[];
|
|
29
|
+
};
|
|
30
|
+
export declare function runWalkthrough(options: GameRunOptions): Promise<GameRunResult>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { isRecord } from "../guards.js";
|
|
5
|
+
export const PROTOCOL_VERSION = 1;
|
|
6
|
+
// Drive one walkthrough over the v1 stdio JSON-lines protocol: spawn the
|
|
7
|
+
// engine, handshake, configure determinism, send the whole script, collect
|
|
8
|
+
// marker events until `done`. See docs/design/game-capture-protocol-v1.md.
|
|
9
|
+
export function runWalkthrough(options) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const child = spawn(options.command, options.args, {
|
|
12
|
+
cwd: options.cwd,
|
|
13
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
14
|
+
// The adapter autoload is inert unless this is set, so a shipped game
|
|
15
|
+
// never accidentally speaks protocol on players' machines.
|
|
16
|
+
env: { ...process.env, DUNGBEETLE_PROTOCOL: "1" }
|
|
17
|
+
});
|
|
18
|
+
const markers = {};
|
|
19
|
+
const stderrTail = [];
|
|
20
|
+
let engine;
|
|
21
|
+
let adapter;
|
|
22
|
+
let settled = false;
|
|
23
|
+
let requestId = 0;
|
|
24
|
+
const fail = (message) => {
|
|
25
|
+
if (settled) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
settled = true;
|
|
29
|
+
clearTimeout(watchdog);
|
|
30
|
+
clearTimeout(handshakeDeadline);
|
|
31
|
+
child.kill("SIGKILL");
|
|
32
|
+
const tail = stderrTail.length > 0 ? `\nEngine stderr:\n${stderrTail.join("\n")}` : "";
|
|
33
|
+
reject(new Error(`${message}${tail}`));
|
|
34
|
+
};
|
|
35
|
+
const finish = (result) => {
|
|
36
|
+
if (settled) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(watchdog);
|
|
41
|
+
clearTimeout(handshakeDeadline);
|
|
42
|
+
send({ type: "quit" });
|
|
43
|
+
// Grace period, then make sure the child is gone.
|
|
44
|
+
const grace = setTimeout(() => child.kill("SIGKILL"), 2000);
|
|
45
|
+
child.once("exit", () => clearTimeout(grace));
|
|
46
|
+
resolve(result);
|
|
47
|
+
};
|
|
48
|
+
const send = (message) => {
|
|
49
|
+
requestId += 1;
|
|
50
|
+
child.stdin.write(`${JSON.stringify({ ...message, id: requestId })}\n`);
|
|
51
|
+
};
|
|
52
|
+
const watchdog = setTimeout(() => fail(`Game run timed out after ${options.timeoutMs}ms.`), options.timeoutMs);
|
|
53
|
+
const handshakeDeadline = setTimeout(() => fail(`Engine adapter did not respond to the handshake within ${options.handshakeTimeoutMs}ms. Is the Dungbeetle adapter installed in the project?`), options.handshakeTimeoutMs);
|
|
54
|
+
child.on("error", (error) => fail(`Failed to launch engine "${options.command}": ${error.message}`));
|
|
55
|
+
child.on("exit", (code) => {
|
|
56
|
+
if (!settled) {
|
|
57
|
+
fail(`Engine exited (code ${code}) before the walkthrough finished.`);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
createInterface({ input: child.stderr }).on("line", (line) => {
|
|
61
|
+
stderrTail.push(line);
|
|
62
|
+
if (stderrTail.length > 20) {
|
|
63
|
+
stderrTail.shift();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
createInterface({ input: child.stdout }).on("line", (line) => {
|
|
67
|
+
let message;
|
|
68
|
+
try {
|
|
69
|
+
message = JSON.parse(line);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return; // Engine banners and stray prints are not protocol.
|
|
73
|
+
}
|
|
74
|
+
if (!isRecord(message) || typeof message.type !== "string") {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
switch (message.type) {
|
|
78
|
+
case "hello": {
|
|
79
|
+
clearTimeout(handshakeDeadline);
|
|
80
|
+
const protocol = isRecord(message.protocol) ? message.protocol : {};
|
|
81
|
+
const min = typeof protocol.min === "number" ? protocol.min : NaN;
|
|
82
|
+
const max = typeof protocol.max === "number" ? protocol.max : NaN;
|
|
83
|
+
if (!(min <= PROTOCOL_VERSION && PROTOCOL_VERSION <= max)) {
|
|
84
|
+
fail(`Protocol mismatch: CLI speaks v${PROTOCOL_VERSION}, adapter supports v${min}–v${max}. Upgrade the ${min > PROTOCOL_VERSION ? "CLI" : "adapter"}.`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
engine = isRecord(message.engine)
|
|
88
|
+
? { name: String(message.engine.name), version: String(message.engine.version) }
|
|
89
|
+
: { name: "unknown", version: "unknown" };
|
|
90
|
+
adapter = isRecord(message.adapter)
|
|
91
|
+
? { version: String(message.adapter.version) }
|
|
92
|
+
: { version: "unknown" };
|
|
93
|
+
send({
|
|
94
|
+
type: "configure",
|
|
95
|
+
seed: options.seed,
|
|
96
|
+
physicsFps: options.physicsFps,
|
|
97
|
+
screenshots: options.screenshots,
|
|
98
|
+
outDir: options.outDir
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
case "ready":
|
|
103
|
+
send({ type: "run", steps: options.steps });
|
|
104
|
+
return;
|
|
105
|
+
case "marker": {
|
|
106
|
+
// The adapter is game code, so it doesn't get to steer filesystem
|
|
107
|
+
// paths: marker names become artifact filenames and the screenshot
|
|
108
|
+
// path is read back by the CLI. Enforce the walkthrough's name
|
|
109
|
+
// shape and confine the screenshot to this run's outDir.
|
|
110
|
+
const name = String(message.name);
|
|
111
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
|
|
112
|
+
fail(`Engine adapter reported an invalid marker name: ${JSON.stringify(name)}`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const screenshot = typeof message.screenshot === "string" ? path.resolve(message.screenshot) : undefined;
|
|
116
|
+
if (screenshot && !screenshot.startsWith(path.resolve(options.outDir) + path.sep)) {
|
|
117
|
+
fail(`Engine adapter reported a screenshot outside the run directory: ${screenshot}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
markers[name] = {
|
|
121
|
+
tick: typeof message.tick === "number" ? message.tick : 0,
|
|
122
|
+
state: message.state,
|
|
123
|
+
...(screenshot ? { screenshot } : {})
|
|
124
|
+
};
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
case "error": {
|
|
128
|
+
const step = typeof message.step === "number" ? ` at steps[${message.step}]` : "";
|
|
129
|
+
fail(`Engine adapter error${step}: ${String(message.message)} (${String(message.code)})`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
case "done":
|
|
133
|
+
finish({
|
|
134
|
+
engine: engine ?? { name: "unknown", version: "unknown" },
|
|
135
|
+
adapter: adapter ?? { version: "unknown" },
|
|
136
|
+
ticks: typeof message.ticks === "number" ? message.ticks : 0,
|
|
137
|
+
markers
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
default:
|
|
141
|
+
return; // Unknown types are ignored — additive-only within a major.
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
send({ type: "hello", protocolVersion: PROTOCOL_VERSION });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const stepSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
3
|
+
input: z.ZodString;
|
|
4
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
5
|
+
down: "down";
|
|
6
|
+
up: "up";
|
|
7
|
+
tap: "tap";
|
|
8
|
+
}>>;
|
|
9
|
+
ticks: z.ZodOptional<z.ZodNumber>;
|
|
10
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
11
|
+
wait: z.ZodNumber;
|
|
12
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
13
|
+
waitFor: z.ZodString;
|
|
14
|
+
timeoutTicks: z.ZodNumber;
|
|
15
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
16
|
+
screenshot: z.ZodString;
|
|
17
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
18
|
+
assert: z.ZodString;
|
|
19
|
+
}, z.core.$strict>]>;
|
|
20
|
+
declare const walkthroughSchema: z.ZodObject<{
|
|
21
|
+
description: z.ZodOptional<z.ZodString>;
|
|
22
|
+
steps: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
|
|
23
|
+
input: z.ZodString;
|
|
24
|
+
mode: z.ZodOptional<z.ZodEnum<{
|
|
25
|
+
down: "down";
|
|
26
|
+
up: "up";
|
|
27
|
+
tap: "tap";
|
|
28
|
+
}>>;
|
|
29
|
+
ticks: z.ZodOptional<z.ZodNumber>;
|
|
30
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
31
|
+
wait: z.ZodNumber;
|
|
32
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
33
|
+
waitFor: z.ZodString;
|
|
34
|
+
timeoutTicks: z.ZodNumber;
|
|
35
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
36
|
+
screenshot: z.ZodString;
|
|
37
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
38
|
+
assert: z.ZodString;
|
|
39
|
+
}, z.core.$strict>]>>;
|
|
40
|
+
}, z.core.$strict>;
|
|
41
|
+
export type WalkthroughStep = z.infer<typeof stepSchema>;
|
|
42
|
+
export type Walkthrough = z.infer<typeof walkthroughSchema>;
|
|
43
|
+
export declare function walkthroughIssues(value: unknown): string[];
|
|
44
|
+
export declare function loadWalkthrough(filePath: string): Promise<Walkthrough>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
// The walkthrough script format (protocol v1 wire shape for `run.steps`, see
|
|
4
|
+
// docs/design/game-capture-protocol-v1.md). Inputs are input-map ACTIONS, not
|
|
5
|
+
// raw key events — raw injection measured flaky against physics catch-up
|
|
6
|
+
// bursts, while action-level injection was deterministic.
|
|
7
|
+
const inputStep = z
|
|
8
|
+
.object({
|
|
9
|
+
input: z.string().min(1),
|
|
10
|
+
mode: z.enum(["down", "up", "tap"]).optional(),
|
|
11
|
+
ticks: z.number().int().min(1).optional()
|
|
12
|
+
})
|
|
13
|
+
.strict();
|
|
14
|
+
const waitStep = z.object({ wait: z.number().int().min(1) }).strict();
|
|
15
|
+
// Predicate waits carry a mandatory tick budget so a walkthrough can never
|
|
16
|
+
// hang the run — the engine watchdog is the backstop, not the plan.
|
|
17
|
+
const waitForStep = z
|
|
18
|
+
.object({ waitFor: z.string().min(1), timeoutTicks: z.number().int().min(1) })
|
|
19
|
+
.strict();
|
|
20
|
+
const screenshotStep = z.object({ screenshot: z.string().regex(/^[a-z0-9][a-z0-9-]*$/) }).strict();
|
|
21
|
+
const assertStep = z.object({ assert: z.string().min(1) }).strict();
|
|
22
|
+
const stepSchema = z.union([inputStep, waitStep, waitForStep, screenshotStep, assertStep]);
|
|
23
|
+
const walkthroughSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
description: z.string().optional(),
|
|
26
|
+
steps: z.array(stepSchema).min(1)
|
|
27
|
+
})
|
|
28
|
+
.strict();
|
|
29
|
+
// Validation issues beyond the schema shape, phrased for the config/doctor
|
|
30
|
+
// surface: each issue names the step index so the user can jump to it.
|
|
31
|
+
export function walkthroughIssues(value) {
|
|
32
|
+
const parsed = walkthroughSchema.safeParse(value);
|
|
33
|
+
if (!parsed.success) {
|
|
34
|
+
return parsed.error.issues.map((issue) => {
|
|
35
|
+
const at = issue.path.length > 0 ? ` at ${issue.path.join(".")}` : "";
|
|
36
|
+
return `${issue.message}${at}`;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
const issues = [];
|
|
40
|
+
const seen = new Map();
|
|
41
|
+
parsed.data.steps.forEach((step, index) => {
|
|
42
|
+
if ("screenshot" in step) {
|
|
43
|
+
const first = seen.get(step.screenshot);
|
|
44
|
+
if (first !== undefined) {
|
|
45
|
+
issues.push(`duplicate marker "${step.screenshot}" at steps[${index}] (first used at steps[${first}]) — markers name snapshot keys and must be unique.`);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
seen.set(step.screenshot, index);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if ("input" in step &&
|
|
52
|
+
step.ticks !== undefined &&
|
|
53
|
+
step.mode !== undefined &&
|
|
54
|
+
step.mode !== "tap") {
|
|
55
|
+
issues.push(`steps[${index}] sets "ticks" with mode "${step.mode}" — ticks is the tap hold duration and only applies to mode "tap".`);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
if (seen.size === 0) {
|
|
59
|
+
issues.push('walkthrough has no "screenshot" marker steps — the run would capture nothing.');
|
|
60
|
+
}
|
|
61
|
+
return issues;
|
|
62
|
+
}
|
|
63
|
+
// Parse + fully validate a walkthrough file. Throws one actionable error
|
|
64
|
+
// carrying every issue, not just the first.
|
|
65
|
+
export async function loadWalkthrough(filePath) {
|
|
66
|
+
let raw;
|
|
67
|
+
try {
|
|
68
|
+
raw = await readFile(filePath, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new Error(`Walkthrough file is missing at ${filePath}.`);
|
|
72
|
+
}
|
|
73
|
+
let value;
|
|
74
|
+
try {
|
|
75
|
+
value = JSON.parse(raw);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`Walkthrough at ${filePath} is not valid JSON: ${error.message}`);
|
|
79
|
+
}
|
|
80
|
+
const issues = walkthroughIssues(value);
|
|
81
|
+
if (issues.length > 0) {
|
|
82
|
+
throw new Error(`Walkthrough at ${filePath} is invalid:\n - ${issues.join("\n - ")}`);
|
|
83
|
+
}
|
|
84
|
+
return walkthroughSchema.parse(value);
|
|
85
|
+
}
|
package/dist/guards.d.ts
ADDED
package/dist/guards.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Shared runtime type guards.
|
|
2
|
+
//
|
|
3
|
+
// `isRecord` was previously copy-pasted into six modules, and the copies had
|
|
4
|
+
// drifted (one omitted the array check). Keep the single definition here so the
|
|
5
|
+
// snapshot canonicalizer, comparer, and adapters all agree on what "a plain
|
|
6
|
+
// object" means.
|
|
7
|
+
export function isRecord(value) {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
// True when `error` is a Node system error carrying the given errno code (e.g.
|
|
11
|
+
// "ENOENT" for a missing file or binary). Previously hand-rolled in several
|
|
12
|
+
// modules; kept here so the ENOENT check is written once.
|
|
13
|
+
export function isErrno(error, code) {
|
|
14
|
+
return (error instanceof Error && "code" in error && error.code === code);
|
|
15
|
+
}
|
package/dist/index.d.ts
ADDED