Files
2026-07-13 12:29:49 +08:00

184 lines
6.9 KiB
TypeScript

// The end-to-end fixture core: a small status poller exercising every
// v2 effect record — an init-command request, keyed replace and cancel,
// a fire-and-forget bytes command, Cmd.now, a model-gated timer
// subscription, the named engine ops (readFile/writeFile/fetch/
// clipboard) plus the one-shot delay, and the streaming ops (a real
// subprocess spawn with line/exit routing and mid-stream cancel, and
// the audio event stream with its control verbs). Transpiled at build
// time by the repo's own transpiler (never committed as Zig) and driven
// through the real runtime by tests/ts-core/host_e2e_tests.zig.
import { Cmd, Sub, asciiBytes } from "@native-sdk/core";
export type AudioState = "loaded" | "position" | "completed" | "failed" | "rejected" | "spectrum";
export interface Model {
readonly polling: boolean;
readonly ticks: number;
readonly lastTickAt: number;
readonly stampMs: number;
readonly failures: number;
readonly status: Uint8Array;
readonly lastErr: Uint8Array;
readonly saved: number;
readonly code: number;
readonly firedAt: number;
readonly lines: number;
readonly lastLine: Uint8Array;
readonly exitCode: number;
readonly audioState: AudioState;
readonly posMs: number;
readonly durMs: number;
readonly playing: boolean;
readonly bands: Uint8Array;
readonly audioEvents: number;
}
export type Msg =
| { readonly kind: "toggle" }
| { readonly kind: "refresh" }
| { readonly kind: "abort" }
| { readonly kind: "stamp" }
| { readonly kind: "note" }
| { readonly kind: "loaded"; readonly body: Uint8Array }
| { readonly kind: "failed"; readonly why: Uint8Array }
| { readonly kind: "tick"; readonly at: number }
| { readonly kind: "stamped"; readonly at: number }
| { readonly kind: "save" }
| { readonly kind: "load" }
| { readonly kind: "wrote" }
| { readonly kind: "get" }
| { readonly kind: "fetched"; readonly status: number; readonly body: Uint8Array }
| { readonly kind: "share" }
| { readonly kind: "paste" }
| { readonly kind: "later" }
| { readonly kind: "halt" }
| { readonly kind: "boomed"; readonly at: number }
| { readonly kind: "run" }
| { readonly kind: "hang" }
| { readonly kind: "kill" }
| { readonly kind: "lined"; readonly text: Uint8Array }
| { readonly kind: "ended"; readonly code: number }
| { readonly kind: "play" }
| { readonly kind: "pause_music" }
| { readonly kind: "set_volume" }
| { readonly kind: "stop_music" }
| { readonly kind: "audio_evt"; readonly state: AudioState; readonly positionMs: number; readonly durationMs: number; readonly playing: boolean; readonly buffering: boolean; readonly bands: Uint8Array };
export function initialModel(): [Model, Cmd<Msg>] {
return [
{
polling: true,
ticks: 0,
lastTickAt: -1,
stampMs: -1,
failures: 0,
status: new Uint8Array(0),
lastErr: new Uint8Array(0),
saved: 0,
code: -1,
firedAt: -1,
lines: 0,
lastLine: new Uint8Array(0),
exitCode: -1,
audioState: "rejected",
posMs: -1,
durMs: -1,
playing: false,
bands: new Uint8Array(0),
audioEvents: 0,
},
Cmd.request("status.read", asciiBytes("boot"), { key: "status", ok: "loaded", err: "failed" }),
];
}
export function update(model: Model, msg: Msg): Model | [Model, Cmd<Msg>] {
switch (msg.kind) {
case "toggle":
return { ...model, polling: !model.polling };
case "refresh":
return [model, Cmd.request("status.read", model.status, { key: "status", ok: "loaded", err: "failed" })];
case "abort":
return [model, Cmd.cancel("status")];
case "stamp":
return [model, Cmd.now("stamped")];
case "note":
return [model, Cmd.host("blob.put", asciiBytes("hi"))];
case "loaded":
return { ...model, status: msg.body };
case "failed":
return { ...model, failures: model.failures + 1, lastErr: msg.why };
case "tick":
return { ...model, ticks: model.ticks + 1, lastTickAt: msg.at };
case "stamped":
return { ...model, stampMs: msg.at };
case "save":
// The e2e suite runs with the repo root as cwd; the store lives
// under the zig cache like every tmp-dir test artifact.
return [model, Cmd.writeFile(asciiBytes(".zig-cache/tmp/ts-core-e2e/store.bin"), model.status, { key: "file", ok: "wrote", err: "failed" })];
case "load":
return [model, Cmd.readFile(asciiBytes(".zig-cache/tmp/ts-core-e2e/store.bin"), { key: "file", ok: "loaded", err: "failed" })];
case "wrote":
return { ...model, saved: model.saved + 1 };
case "get":
return [
model,
Cmd.fetch(
{ url: asciiBytes("https://status.test/feed"), method: "POST", headers: { accept: "text/plain" }, body: model.status, timeoutMs: 750 },
{ key: "get", ok: "fetched", err: "failed" },
),
];
case "fetched":
return { ...model, code: msg.status, status: msg.body };
case "share":
return [model, Cmd.clipboardWrite(model.status)];
case "paste":
return [model, Cmd.clipboardRead({ key: "clip", ok: "loaded", err: "failed" })];
case "later":
return [model, Cmd.delay("boom", 150, "boomed")];
case "halt":
return [model, Cmd.cancel("boom")];
case "boomed":
return { ...model, firedAt: msg.at };
case "run":
// A real, hermetic child: two stdout lines, then a clean exit.
return [model, Cmd.spawn([asciiBytes("/bin/sh"), asciiBytes("-c"), asciiBytes("printf 'one\\ntwo\\n'")], { key: "job", line: "lined", exit: "ended", err: "failed" })];
case "hang":
// A child that outlives the test unless cancelled mid-stream.
return [model, Cmd.spawn([asciiBytes("/bin/sh"), asciiBytes("-c"), asciiBytes("sleep 30")], { key: "job", line: "lined", exit: "ended", err: "failed" })];
case "kill":
return [model, Cmd.cancel("job")];
case "lined":
return { ...model, lines: model.lines + 1, lastLine: msg.text };
case "ended":
return { ...model, exitCode: msg.code };
case "play":
return [model, Cmd.audioPlay("track", { path: asciiBytes("music/track.mp3") }, { event: "audio_evt" })];
case "pause_music":
return [model, Cmd.audioPause("track")];
case "set_volume":
// The engine's remembered volume verb (0..1), on the fixture's
// test-only path: the soundboard example carries no volume control
// (parity with its Zig original), so the capability stays proven
// here.
return [model, Cmd.audioSetVolume("track", 0.8)];
case "stop_music":
return [model, Cmd.audioStop("track")];
case "audio_evt":
return {
...model,
audioState: msg.state,
posMs: msg.positionMs,
durMs: msg.durationMs,
playing: msg.playing,
bands: msg.bands,
audioEvents: model.audioEvents + 1,
};
}
}
export function subscriptions(model: Model): Sub<Msg> {
if (!model.polling) return Sub.none;
return Sub.timer("tick", 100, "tick");
}