test(sandbox): add integration tooling
This commit is contained in:
parent
10c51ce55a
commit
68c1b9d6aa
@ -9,13 +9,14 @@ Host tooling (`just`/`npm.just` recipes, `sandbox-serve`, docs) and the cross-si
|
|||||||
**integration** (real craftos ↔ real gateway) plus the **full-boot e2e**.
|
**integration** (real craftos ↔ real gateway) plus the **full-boot e2e**.
|
||||||
|
|
||||||
## Task checklist
|
## Task checklist
|
||||||
- [ ] `just/npm.just`: extend `install/build/check/test/test-integration/ci` to cover
|
- [x] `just/npm.just`: extend `install/build/check/test/test-integration/ci` to cover
|
||||||
`tools/trap-sandbox` alongside mcp-bridge; ensure `just install`/`just ci` include it.
|
`tools/trap-sandbox` alongside mcp-bridge; ensure `just install`/`just ci` include it.
|
||||||
- [ ] `just/opencode.just` (or new recipe): add **`just sandbox-serve`** mirroring `opencode-serve`.
|
(Symmetric per-tool recipes; `npm-trap-sandbox-ci` removed.)
|
||||||
- [ ] `docs/public-ports.md`: add a `4444` row.
|
- [x] **`just sandbox-serve`** added in new `just/sandbox.just` (warn-only on missing secret).
|
||||||
- [ ] TS integration harness + cases (`tools/trap-sandbox/test-integration/`).
|
- [x] `docs/public-ports.md`: added a `4444` row.
|
||||||
- [ ] Full-boot e2e.
|
- [x] TS integration harness + cases (`tools/trap-sandbox/test-integration/`).
|
||||||
- [ ] Gate: `npm run test:integration` green; `just ci` covers both tools.
|
- [x] Full-boot e2e.
|
||||||
|
- [x] Gate: `npm run test:integration` green; `just ci` covers both tools.
|
||||||
|
|
||||||
## Tooling
|
## Tooling
|
||||||
- `just/npm.just`: extend recipes (install/build/check/test/test-integration/ci) to cover
|
- `just/npm.just`: extend recipes (install/build/check/test/test-integration/ci) to cover
|
||||||
@ -44,6 +45,7 @@ contains `sandboxOk: true`. Bounded by generous timeouts.
|
|||||||
## Verification gate (also the project-level acceptance)
|
## Verification gate (also the project-level acceptance)
|
||||||
- `npm test`, `npm run test:integration`, `npm run lint`/`check` clean in `tools/trap-sandbox`.
|
- `npm test`, `npm run test:integration`, `npm run lint`/`check` clean in `tools/trap-sandbox`.
|
||||||
- `just _craftos-test` runs `tests/sandbox.lua`; `just ci` covers both tools.
|
- `just _craftos-test` runs `tests/sandbox.lua`; `just ci` covers both tools.
|
||||||
- Manual: `just sandbox-serve`; in-game `set sandbox.url ws://<host>:4444/gateway` (+
|
- Manual: `just sandbox-serve`; in-game `set sandbox.url ws://<host>:4444` (origin only —
|
||||||
|
libsandbox appends `/gateway`) (+
|
||||||
`set sandbox.password <secret>` if `SANDBOX_PASSWORD` set), reboot, `sandbox health` →
|
`set sandbox.password <secret>` if `SANDBOX_PASSWORD` set), reboot, `sandbox health` →
|
||||||
`sandboxOk: true` / `opencodeOk: …`.
|
`sandboxOk: true` / `opencodeOk: …`.
|
||||||
|
|||||||
@ -38,7 +38,7 @@ verification gate is green.
|
|||||||
in place, Lua unit tests green via `just _craftos-test`. *Implements the Protocol contract.*
|
in place, Lua unit tests green via `just _craftos-test`. *Implements the Protocol contract.*
|
||||||
- [x] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox`
|
- [x] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox`
|
||||||
package + meta bump wired; `packages/index.json` consistent.
|
package + meta bump wired; `packages/index.json` consistent.
|
||||||
- [ ] **M4 — Integration & tooling** ([04-integration-tooling](./04-integration-tooling.md)):
|
- [x] **M4 — Integration & tooling** ([04-integration-tooling](./04-integration-tooling.md)):
|
||||||
`just` recipes, `docs/public-ports.md`, TS integration harness + full-boot e2e green.
|
`just` recipes, `docs/public-ports.md`, TS integration harness + full-boot e2e green.
|
||||||
*Requires both sides — build last.*
|
*Requires both sides — build last.*
|
||||||
|
|
||||||
|
|||||||
1
Justfile
1
Justfile
@ -6,6 +6,7 @@ import 'just/install.just'
|
|||||||
import 'just/npm.just'
|
import 'just/npm.just'
|
||||||
import 'just/craftos.just'
|
import 'just/craftos.just'
|
||||||
import 'just/opencode.just'
|
import 'just/opencode.just'
|
||||||
|
import 'just/sandbox.just'
|
||||||
import 'just/test.just'
|
import 'just/test.just'
|
||||||
import 'just/check.just'
|
import 'just/check.just'
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ TrapOS public production services use the `4242-4244` TCP range. Keep local deve
|
|||||||
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
| `4242` | opencode server HTTP API | Production/public port for `opencode serve`. Local/dev examples may still use opencode's default `4096`. |
|
||||||
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
|
| `4243` | ComputerCraft bridge WebSocket | Production/public equivalent of the local bridge link default `3001`. Use `CC_LINK_PORT=4243` for production bridge deployments. |
|
||||||
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
| `4244` | Reserved | Free public production port reserved for a future service. Do not assign it casually in local tooling. |
|
||||||
|
| `4444` | trap-sandbox gateway | Fastify service (`tools/trap-sandbox`) exposing the `trapos-cloud-gateway` WebSocket at `/gateway`. Sits **outside** the `4242-4244` range on purpose (4244 stays reserved). Default `SANDBOX_PORT`; serve with `just sandbox-serve`. |
|
||||||
|
|
||||||
## Local Vs Production
|
## Local Vs Production
|
||||||
|
|
||||||
|
|||||||
@ -10,28 +10,40 @@ npm-install-trap-sandbox:
|
|||||||
npm install --prefix tools/trap-sandbox/
|
npm install --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Build Node-based repository tools.
|
# Build Node-based repository tools.
|
||||||
npm-build:
|
npm-build: npm-build-mcp-bridge npm-build-trap-sandbox
|
||||||
|
|
||||||
|
npm-build-mcp-bridge:
|
||||||
npm run build --prefix tools/mcp-bridge
|
npm run build --prefix tools/mcp-bridge
|
||||||
|
|
||||||
|
npm-build-trap-sandbox:
|
||||||
|
npm run build --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Check Node-based repository tools.
|
# Check Node-based repository tools.
|
||||||
npm-check:
|
npm-check: npm-check-mcp-bridge npm-check-trap-sandbox
|
||||||
|
|
||||||
|
npm-check-mcp-bridge:
|
||||||
npm run check --prefix tools/mcp-bridge
|
npm run check --prefix tools/mcp-bridge
|
||||||
|
|
||||||
|
npm-check-trap-sandbox:
|
||||||
|
npm run check --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Run Node-based tool tests.
|
# Run Node-based tool tests.
|
||||||
npm-test:
|
npm-test: npm-test-mcp-bridge npm-test-trap-sandbox
|
||||||
|
|
||||||
|
npm-test-mcp-bridge:
|
||||||
npm test --prefix tools/mcp-bridge
|
npm test --prefix tools/mcp-bridge
|
||||||
|
|
||||||
|
npm-test-trap-sandbox:
|
||||||
|
npm test --prefix tools/trap-sandbox/
|
||||||
|
|
||||||
# Run Node-based tool integration tests.
|
# Run Node-based tool integration tests.
|
||||||
npm-test-integration:
|
npm-test-integration: npm-test-integration-mcp-bridge npm-test-integration-trap-sandbox
|
||||||
|
|
||||||
|
npm-test-integration-mcp-bridge:
|
||||||
npm run test:integration --prefix tools/mcp-bridge
|
npm run test:integration --prefix tools/mcp-bridge
|
||||||
|
|
||||||
# Run Node-based tool CI.
|
npm-test-integration-trap-sandbox:
|
||||||
npm-ci:
|
npm run test:integration --prefix tools/trap-sandbox/
|
||||||
npm run test:ci --prefix tools/mcp-bridge
|
|
||||||
|
|
||||||
# Run trap-sandbox CI.
|
|
||||||
npm-trap-sandbox-ci:
|
|
||||||
npm --prefix tools/trap-sandbox/ run test:ci
|
|
||||||
|
|
||||||
# Build generated artifacts.
|
# Build generated artifacts.
|
||||||
build: npm-build
|
build: npm-build
|
||||||
|
|||||||
18
just/sandbox.just
Normal file
18
just/sandbox.just
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Serve the trap-sandbox gateway (port 4444) for remote/in-game attachment using
|
||||||
|
# repo-local .env secrets. Mirrors `opencode-serve`, but auth is optional: an unset
|
||||||
|
# SANDBOX_PASSWORD means the gateway runs with auth disabled (warn, do not fail).
|
||||||
|
[positional-arguments]
|
||||||
|
sandbox-serve *args:
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
repo='{{justfile_directory()}}'
|
||||||
|
if [ -f "$repo/.env" ]; then set -a; . "$repo/.env"; set +a; fi
|
||||||
|
if [ -z "${SANDBOX_PASSWORD:-}" ]; then
|
||||||
|
printf '%s\n' 'sandbox-serve: SANDBOX_PASSWORD unset in .env — gateway auth disabled' >&2
|
||||||
|
fi
|
||||||
|
export SANDBOX_PASSWORD="${SANDBOX_PASSWORD:-}"
|
||||||
|
export OPENCODE_SERVER_PASSWORD="${OPENCODE_SERVER_PASSWORD:-}"
|
||||||
|
export OPENCODE_URL="${OPENCODE_URL:-http://127.0.0.1:4242}"
|
||||||
|
export SANDBOX_HOST="${SANDBOX_HOST:-0.0.0.0}"
|
||||||
|
export SANDBOX_PORT="${SANDBOX_PORT:-4444}"
|
||||||
|
exec npm run dev --prefix tools/trap-sandbox -- "$@"
|
||||||
@ -1,5 +1,5 @@
|
|||||||
# Local CI entry point used by Git hooks. Pass args through to CraftOS tests.
|
# Local CI entry point used by Git hooks. Pass args through to CraftOS tests.
|
||||||
ci *args: check-craftos check check-packages npm-build npm-test npm-trap-sandbox-ci
|
ci *args: check-craftos check check-packages npm-build npm-test
|
||||||
@just _craftos-test {{args}}
|
@just _craftos-test {{args}}
|
||||||
@just test-integration
|
@just test-integration
|
||||||
|
|
||||||
|
|||||||
47
tools/trap-sandbox/test-integration/auth.test.ts
Normal file
47
tools/trap-sandbox/test-integration/auth.test.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startSandbox } from "./harness.js";
|
||||||
|
|
||||||
|
const SECRET = "s3cr3t-canary";
|
||||||
|
|
||||||
|
test("hello with the wrong secret is rejected in-band (unauthorized)", async () => {
|
||||||
|
const sandbox = await startSandbox({ sandboxPassword: SECRET });
|
||||||
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "auth-bad",
|
||||||
|
shellArgs: [sandbox.baseUrl, "wrong-secret", "auth"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await craftos.done;
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/__SANDBOX_HELLO__ ok=false code=unauthorized/,
|
||||||
|
formatFailure("expected hello ok=false code=unauthorized", result.output),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hello with the correct secret succeeds", async () => {
|
||||||
|
const sandbox = await startSandbox({ sandboxPassword: SECRET });
|
||||||
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "auth-ok",
|
||||||
|
shellArgs: [sandbox.baseUrl, SECRET, "auth"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await craftos.done;
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/__SANDBOX_HELLO__ ok=true/,
|
||||||
|
formatFailure("expected hello ok=true", result.output),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
52
tools/trap-sandbox/test-integration/duplicate.test.ts
Normal file
52
tools/trap-sandbox/test-integration/duplicate.test.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js";
|
||||||
|
|
||||||
|
// Two real computers with the same traposId (id:label): newest-wins. After both
|
||||||
|
// complete hello, exactly one registration exists; aborting the newest clears it
|
||||||
|
// (the displaced first computer is in its 5s reconnect backoff), proving the newest
|
||||||
|
// connection is the one that's registered.
|
||||||
|
test("duplicate traposId is resolved newest-wins", async () => {
|
||||||
|
const sandbox = await startSandbox();
|
||||||
|
const TRAPOS_ID = "9:dup";
|
||||||
|
|
||||||
|
const first = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 9,
|
||||||
|
computerLabel: "dup",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-", "idle"],
|
||||||
|
});
|
||||||
|
const second = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 9,
|
||||||
|
computerLabel: "dup",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-", "idle"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(() => first.snapshot().includes("__SANDBOX_READY__"), 12_000, "first connected");
|
||||||
|
assert.ok(sandbox.registry.has("local", TRAPOS_ID), "first should be registered");
|
||||||
|
|
||||||
|
await waitFor(() => second.snapshot().includes("__SANDBOX_READY__"), 12_000, "second connected");
|
||||||
|
// Newest-wins: still exactly one registration for the shared id.
|
||||||
|
assert.equal(sandbox.registry.count(), 1, "exactly one registration after the duplicate");
|
||||||
|
assert.ok(sandbox.registry.has("local", TRAPOS_ID));
|
||||||
|
|
||||||
|
// Aborting the newest clears the entry; the displaced first is in reconnect
|
||||||
|
// backoff (~5s) and has not re-registered yet.
|
||||||
|
second.abort();
|
||||||
|
await second.done.catch(() => undefined);
|
||||||
|
await waitFor(() => sandbox.registry.count() === 0, 3_000, "newest was the live registration");
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
formatFailure(
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
`first:\n${first.snapshot()}\nsecond:\n${second.snapshot()}`,
|
||||||
|
),
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
first.abort();
|
||||||
|
second.abort();
|
||||||
|
await Promise.all([first.done.catch(() => undefined), second.done.catch(() => undefined)]);
|
||||||
|
await sandbox.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
27
tools/trap-sandbox/test-integration/full-boot.test.ts
Normal file
27
tools/trap-sandbox/test-integration/full-boot.test.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js";
|
||||||
|
|
||||||
|
// Full-boot e2e: a real CraftOS computer runs the real servers/sandbox.lua daemon
|
||||||
|
// (driven by sandbox.* settings) and the real `sandbox health` program against the
|
||||||
|
// real gateway, with a healthy fake-opencode behind it.
|
||||||
|
test("full boot: settings -> daemon -> sandbox health prints sandboxOk/opencodeOk true", async () => {
|
||||||
|
const opencode = await startFakeOpencode({ status: 200 });
|
||||||
|
const sandbox = await startSandbox({ sandboxPassword: "boot-secret", opencodeUrl: opencode.url });
|
||||||
|
const craftos = startCraftos("full-boot.lua", {
|
||||||
|
computerId: 11,
|
||||||
|
computerLabel: "boot",
|
||||||
|
shellArgs: [sandbox.baseUrl, "boot-secret"],
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await craftos.done;
|
||||||
|
assert.match(result.output, /sandboxOk:\s*true/, formatFailure("expected sandboxOk: true", result.output));
|
||||||
|
assert.match(result.output, /opencodeOk:\s*true/, formatFailure("expected opencodeOk: true", result.output));
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
await opencode.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
172
tools/trap-sandbox/test-integration/harness.ts
Normal file
172
tools/trap-sandbox/test-integration/harness.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { createApp, type Config } from "../src/app.js";
|
||||||
|
import type { GatewayRegistry } from "../src/modules/trapos-cloud-gateway/registry.js";
|
||||||
|
|
||||||
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LUA_DIR = join(HERE, "lua");
|
||||||
|
const REPO_ROOT = join(HERE, "../../..");
|
||||||
|
|
||||||
|
export type Sandbox = {
|
||||||
|
registry: GatewayRegistry;
|
||||||
|
// Gateway origin (no path). This is what `sandbox.url` expects: libsandbox appends
|
||||||
|
// `/gateway` itself. Pass this to the Lua scripts.
|
||||||
|
baseUrl: string;
|
||||||
|
// Full WS endpoint (origin + `/gateway`), for direct ws clients.
|
||||||
|
socketUrl: string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SandboxOptions = {
|
||||||
|
sandboxPassword?: string;
|
||||||
|
opencodeUrl?: string;
|
||||||
|
requestTimeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Real Fastify gateway on an ephemeral port. Mirrors index.ts's wiring but with a
|
||||||
|
// silent logger and test-supplied config (never reads process.env).
|
||||||
|
export async function startSandbox(opts: SandboxOptions = {}): Promise<Sandbox> {
|
||||||
|
const config: Config = {
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 0,
|
||||||
|
sandboxPassword: opts.sandboxPassword,
|
||||||
|
requestTimeoutMs: opts.requestTimeoutMs ?? 5_000,
|
||||||
|
opencodeUrl: opts.opencodeUrl,
|
||||||
|
opencodeUsername: "opencode",
|
||||||
|
opencodeServerPassword: undefined,
|
||||||
|
logger: { level: "silent" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { app, registry } = await createApp(config);
|
||||||
|
await app.listen({ host: config.host, port: config.port });
|
||||||
|
const { port } = app.server.address() as AddressInfo;
|
||||||
|
|
||||||
|
const baseUrl = `ws://127.0.0.1:${port}`;
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
baseUrl,
|
||||||
|
socketUrl: `${baseUrl}/gateway`,
|
||||||
|
close: () => app.close(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FakeOpencode = {
|
||||||
|
url: string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tiny stand-in for the opencode server. The probe issues `GET /config`; we reply
|
||||||
|
// with `status` (default 200) on every path so opencodeOk can be steered per test.
|
||||||
|
export async function startFakeOpencode(opts: { status?: number } = {}): Promise<FakeOpencode> {
|
||||||
|
const status = opts.status ?? 200;
|
||||||
|
const server = createServer((_req, res) => {
|
||||||
|
res.writeHead(status, { "content-type": "application/json" });
|
||||||
|
res.end("{}");
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
return {
|
||||||
|
url: `http://127.0.0.1:${port}`,
|
||||||
|
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitFor(predicate: () => boolean, timeoutMs: number, label: string): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (predicate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error(`waitFor timed out: ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CraftosResult = {
|
||||||
|
status: number | null;
|
||||||
|
signal: NodeJS.Signals | null;
|
||||||
|
output: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CraftosHandle = {
|
||||||
|
done: Promise<CraftosResult>;
|
||||||
|
abort: () => void;
|
||||||
|
// Stdout/stderr accumulated so far; lets a test await a marker line while the
|
||||||
|
// process is still running (e.g. wait for "__SANDBOX_READY__" before acting).
|
||||||
|
snapshot: () => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spawn headless CraftOS-PC running a Lua script from test-integration/lua, with the
|
||||||
|
// repo mounted read-only (/apis, /servers, /programs, /trapos) so the scripts can
|
||||||
|
// require the real libsandbox / servers / programs.
|
||||||
|
export function startCraftos(
|
||||||
|
luaName: string,
|
||||||
|
opts: { computerId?: number; computerLabel?: string; shellArgs?: string[]; timeoutMs?: number } = {},
|
||||||
|
): CraftosHandle {
|
||||||
|
const timeoutMs = opts.timeoutMs ?? 15_000;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
const done = (async (): Promise<CraftosResult> => {
|
||||||
|
const dataDir = await mkdtemp(join(tmpdir(), "trap-sandbox-it-"));
|
||||||
|
const watchdog = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const args: string[] = [
|
||||||
|
"--directory", dataDir,
|
||||||
|
"--headless",
|
||||||
|
"--mount-ro", `/staging=${LUA_DIR}`,
|
||||||
|
"--mount-ro", `/trapos=${REPO_ROOT}`,
|
||||||
|
"--mount-ro", `/apis=${join(REPO_ROOT, "apis")}`,
|
||||||
|
"--mount-ro", `/servers=${join(REPO_ROOT, "servers")}`,
|
||||||
|
"--mount-ro", `/programs=${join(REPO_ROOT, "programs")}`,
|
||||||
|
];
|
||||||
|
if (opts.computerId !== undefined) {
|
||||||
|
args.push("--id", String(opts.computerId));
|
||||||
|
}
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
args.push("--rom", "/Applications/CraftOS-PC.app/Contents/Resources");
|
||||||
|
}
|
||||||
|
args.push("--exec", buildExecCode(luaName, opts.shellArgs ?? [], opts.computerLabel));
|
||||||
|
|
||||||
|
const child = spawn("craftos", args, { signal: controller.signal });
|
||||||
|
child.stdout.on("data", (d: Buffer) => chunks.push(d));
|
||||||
|
child.stderr.on("data", (d: Buffer) => chunks.push(d));
|
||||||
|
|
||||||
|
const result = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||||
|
child.once("close", (code, signal) => resolve({ status: code, signal }));
|
||||||
|
child.once("error", () => resolve({ status: null, signal: null }));
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: result.status, signal: result.signal, output: Buffer.concat(chunks).toString("utf8") };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(watchdog);
|
||||||
|
await rm(dataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return { done, abort: () => controller.abort(), snapshot: () => Buffer.concat(chunks).toString("utf8") };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFailure(message: string, craftosOutput: string): string {
|
||||||
|
return ["\x1b[31mFAIL\x1b[0m " + message, "--- craftos output ---", craftosOutput.trimEnd(), "----------------------"].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExecCode(luaName: string, shellArgs: string[], computerLabel?: string): string {
|
||||||
|
const programPath = luaName.startsWith("/") ? luaName : `/staging/${luaName}`;
|
||||||
|
const parts = [luaQuote(programPath), ...shellArgs.map(luaQuote)];
|
||||||
|
const setup = computerLabel === undefined ? "" : `os.setComputerLabel(${luaQuote(computerLabel)}); `;
|
||||||
|
return `${setup}shell.run(${parts.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function luaQuote(value: string): string {
|
||||||
|
return `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
49
tools/trap-sandbox/test-integration/health.test.ts
Normal file
49
tools/trap-sandbox/test-integration/health.test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startFakeOpencode, startSandbox } from "./harness.js";
|
||||||
|
|
||||||
|
test("probe_gateway reports opencodeOk=true when opencode is healthy", async () => {
|
||||||
|
const opencode = await startFakeOpencode({ status: 200 });
|
||||||
|
const sandbox = await startSandbox({ opencodeUrl: opencode.url });
|
||||||
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "health-ok",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-", "health"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await craftos.done;
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=true/,
|
||||||
|
formatFailure("expected sandboxOk=true opencodeOk=true", result.output),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
await opencode.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("probe_gateway reports opencodeOk=false when opencode is unhealthy", async () => {
|
||||||
|
const opencode = await startFakeOpencode({ status: 500 });
|
||||||
|
const sandbox = await startSandbox({ opencodeUrl: opencode.url });
|
||||||
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "health-bad",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-", "health"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await craftos.done;
|
||||||
|
assert.match(
|
||||||
|
result.output,
|
||||||
|
/__SANDBOX_HEALTH__ sandboxOk=true opencodeOk=false/,
|
||||||
|
formatFailure("expected sandboxOk=true opencodeOk=false", result.output),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
await opencode.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
30
tools/trap-sandbox/test-integration/lua/full-boot.lua
Normal file
30
tools/trap-sandbox/test-integration/lua/full-boot.lua
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
-- Full-boot e2e: replicate the real boot path (startup/servers.lua) closely enough
|
||||||
|
-- to prove the whole stack works end to end. Sets the sandbox.* settings, creates
|
||||||
|
-- the global event loop, runs the REAL servers/sandbox.lua daemon, then under
|
||||||
|
-- parallel waits for the connection and runs the REAL `sandbox health` program.
|
||||||
|
--
|
||||||
|
-- Args (via shell.run): url, secret ('' => none).
|
||||||
|
-- NOTE: shell.run drops empty-string args; the harness passes '-' for "no secret".
|
||||||
|
local args = { ... };
|
||||||
|
local url = args[1];
|
||||||
|
local secret = args[2];
|
||||||
|
|
||||||
|
settings.set('sandbox.url', url);
|
||||||
|
if secret and secret ~= '' and secret ~= '-' then
|
||||||
|
settings.set('sandbox.password', secret);
|
||||||
|
end
|
||||||
|
|
||||||
|
_G.bootEventLoop = require('/apis/eventloop')();
|
||||||
|
|
||||||
|
-- Start the real daemon (reads sandbox.url/sandbox.password, opens the WS).
|
||||||
|
shell.run('/servers/sandbox.lua');
|
||||||
|
|
||||||
|
parallel.waitForAny(
|
||||||
|
function() _G.bootEventLoop.runLoop(true); end,
|
||||||
|
function()
|
||||||
|
os.pullEvent('trapos_sandbox_connected');
|
||||||
|
shell.run('/programs/sandbox.lua', 'health');
|
||||||
|
end
|
||||||
|
);
|
||||||
|
|
||||||
|
os.shutdown();
|
||||||
77
tools/trap-sandbox/test-integration/lua/gateway-client.lua
Normal file
77
tools/trap-sandbox/test-integration/lua/gateway-client.lua
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
-- Integration-test driver: exercises the REAL trapos-sandbox client (libsandbox)
|
||||||
|
-- against a real gateway. Reuses startSession/request/onMessage so the test covers
|
||||||
|
-- production client code + CraftOS JSON serialization quirks.
|
||||||
|
--
|
||||||
|
-- Args (via shell.run): url, secret, scenario.
|
||||||
|
-- url gateway origin, e.g. ws://127.0.0.1:PORT (daemon appends /gateway)
|
||||||
|
-- secret shared secret ('' => none)
|
||||||
|
-- scenario health | auth | idle (default: health)
|
||||||
|
--
|
||||||
|
-- Each scenario prints a single sentinel line the TS test asserts on.
|
||||||
|
-- NOTE: shell.run re-tokenizes its arguments, so empty-string args are dropped and
|
||||||
|
-- positions shift. The harness passes '-' as the "no secret" sentinel instead of ''.
|
||||||
|
local args = { ... };
|
||||||
|
local url = args[1];
|
||||||
|
local secret = args[2];
|
||||||
|
if secret == '' or secret == '-' then secret = nil; end
|
||||||
|
local scenario = args[3] or 'health';
|
||||||
|
|
||||||
|
local createSandbox = require('/apis/libsandbox');
|
||||||
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
|
||||||
|
_G.bootEventLoop = createEventLoop();
|
||||||
|
local sandbox = createSandbox();
|
||||||
|
local session = sandbox.startSession({
|
||||||
|
eventloop = _G.bootEventLoop,
|
||||||
|
url = url,
|
||||||
|
secret = secret,
|
||||||
|
os = os,
|
||||||
|
});
|
||||||
|
|
||||||
|
-- Connect, probe the gateway, report opencodeOk.
|
||||||
|
local function runHealth()
|
||||||
|
os.pullEvent('trapos_sandbox_connected');
|
||||||
|
local ok, payload = sandbox.request('probe_gateway');
|
||||||
|
if ok then
|
||||||
|
print('__SANDBOX_HEALTH__ sandboxOk=' .. tostring(payload.sandboxOk)
|
||||||
|
.. ' opencodeOk=' .. tostring(payload.opencodeOk));
|
||||||
|
else
|
||||||
|
print('__SANDBOX_HEALTH__ error=' .. tostring(type(payload) == 'table' and payload.code));
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Poll the hello outcome: ready => ok:true, terminal lastError => ok:false code.
|
||||||
|
local function runAuth()
|
||||||
|
local deadline = os.epoch('utc') + 8000;
|
||||||
|
while os.epoch('utc') < deadline do
|
||||||
|
if session.isReady() then
|
||||||
|
print('__SANDBOX_HELLO__ ok=true');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
local err = session.lastError();
|
||||||
|
if err then
|
||||||
|
print('__SANDBOX_HELLO__ ok=false code=' .. tostring(err));
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
os.sleep(0.1);
|
||||||
|
end
|
||||||
|
print('__SANDBOX_HELLO__ ok=false code=timeout');
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Connect and stay registered so the TS side can drive the gateway->computer
|
||||||
|
-- direction (reverse ping) or assert registry state (duplicate newest-wins).
|
||||||
|
local function runIdle()
|
||||||
|
os.pullEvent('trapos_sandbox_connected');
|
||||||
|
print('__SANDBOX_READY__ ' .. sandbox.traposId(os));
|
||||||
|
os.sleep(10);
|
||||||
|
end
|
||||||
|
|
||||||
|
local scenarios = { health = runHealth, auth = runAuth, idle = runIdle };
|
||||||
|
local run = scenarios[scenario] or runHealth;
|
||||||
|
|
||||||
|
parallel.waitForAny(
|
||||||
|
function() _G.bootEventLoop.runLoop(true); end,
|
||||||
|
run
|
||||||
|
);
|
||||||
|
|
||||||
|
os.shutdown();
|
||||||
28
tools/trap-sandbox/test-integration/reverse-ping.test.ts
Normal file
28
tools/trap-sandbox/test-integration/reverse-ping.test.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { formatFailure, startCraftos, startSandbox, waitFor } from "./harness.js";
|
||||||
|
|
||||||
|
// Exercises the gateway -> computer direction: the gateway sends a `ping`
|
||||||
|
// gateway_request and the real libsandbox client answers with a computer_response.
|
||||||
|
test("gateway->computer reverse ping is answered by the client", async () => {
|
||||||
|
const sandbox = await startSandbox();
|
||||||
|
const craftos = startCraftos("gateway-client.lua", {
|
||||||
|
computerId: 7,
|
||||||
|
computerLabel: "ping",
|
||||||
|
shellArgs: [sandbox.baseUrl, "-", "idle"],
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await waitFor(() => sandbox.registry.has("local", "7:ping"), 12_000, "computer registered");
|
||||||
|
const payload = await sandbox.registry.request("local", "7:ping", "ping");
|
||||||
|
assert.deepEqual(payload, { traposId: "7:ping" });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
formatFailure(error instanceof Error ? error.message : String(error), craftos.snapshot()),
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
craftos.abort();
|
||||||
|
await craftos.done.catch(() => undefined);
|
||||||
|
await sandbox.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user