import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, test, type TestContext } from "node:test"; import { Runner } from "../src/runner/index.ts"; import type { ProcessResult, RunDefinition } from "../src/vocab/index.ts"; // 用真实 Docker 测(任务书 05 第五部分):没有 Docker 时整个文件失败,不跳过。 // 镜像、容器名一律 inforsi-test- 开头的固定名字;本机还有别的项目在建删容器,只动自己前缀的。 const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing"); const IMAGE_TAG = "inforsi-test-circle"; const MISSING_DIGEST = `sha256:${"0".repeat(64)}`; const runner = new Runner(null); const digest = await runner.buildImage(TASK_DIR, IMAGE_TAG); after(() => docker(["rmi", IMAGE_TAG])); function docker(args: string[]): { status: number | null; stdout: string; stderr: string } { return spawnSync("docker", args, { encoding: "utf8" }); } /** 删掉测试自己的容器;不存在不算错,别的错误照样报。 */ function forceRemove(name: string): void { const removed = docker(["rm", "-f", name]); if (removed.status !== 0 && !removed.stderr.includes("No such container")) throw new Error(removed.stderr); } /** 一个测试用的容器名:先清掉上次没删干净的同名容器,测试结束后再删。 */ function containerName(t: TestContext, suffix: string): string { const name = `inforsi-test-${suffix}`; forceRemove(name); t.after(() => forceRemove(name)); return name; } function dirs(t: TestContext): { runDir: string; logDir: string } { const root = mkdtempSync(join(tmpdir(), "inforsi-runner-")); t.after(() => rmSync(root, { recursive: true, force: true })); const runDir = join(root, "run"); const logDir = join(root, "log"); mkdirSync(runDir); mkdirSync(logDir); return { runDir, logDir }; } function definition(command: string[], wallSeconds: number, image: string): RunDefinition { return { command, workdir: "/work", image: { digest: image, runtime: "runc" }, wallSeconds, cores: 1, inheritEnv: false, env: {}, }; } const python = (code: string): string[] => ["python3", "-c", code]; /** 起一个容器并等它结束,交回结果和日志目录。 */ async function runToEnd(t: TestContext, suffix: string, command: string[], wallSeconds: number) { const name = containerName(t, suffix); const { runDir, logDir } = dirs(t); const outcome = await runner.start(name, definition(command, wallSeconds, digest), runDir); assert.deepEqual(outcome, { started: true }); const result = await runner.wait(name, logDir); return { name, result, logDir }; } function value(result: ProcessResult, key: "cpuSeconds" | "wallSeconds" | "peakMemoryBytes"): number { assert.notEqual(result.ending, "notStarted"); if (result.ending === "notStarted") throw new Error("unreachable"); const item = result[key]; assert.equal(item.status, "measured", `${key} 应当测到`); if (item.status !== "measured") throw new Error("unreachable"); return item.value; } test("建镜像命中参考项目的缓存;查镜像:有的答有,没有的答没有", async () => { assert.match(digest, /^sha256:7ed52793365c/); assert.equal(await runner.hasImage(digest), true); assert.equal(await runner.hasImage(MISSING_DIGEST), false); }); test("正常退出:带退出码和三项资源用量,标准输出、标准错误存成两个文件", async (t) => { const code = "import sys; print('out'); print('err', file=sys.stderr); sys.exit(3)"; const { result, logDir } = await runToEnd(t, "exit", python(code), 30); assert.equal(result.ending, "exited"); assert.equal(result.ending === "exited" && result.exitCode, 3); assert.ok(value(result, "cpuSeconds") > 0); assert.ok(value(result, "wallSeconds") > 0 && value(result, "wallSeconds") < 30); assert.ok(value(result, "peakMemoryBytes") > 0); assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "out\n"); assert.equal(readFileSync(join(logDir, "stderr.log"), "utf8"), "err\n"); }); test("超时:结局 timeout,CPU 秒接近时限,墙钟不短于时限", async (t) => { const { result } = await runToEnd(t, "timeout", python("while True: pass"), 2); assert.equal(result.ending, "timeout"); const cpu = value(result, "cpuSeconds"); assert.ok(cpu > 1.7 && cpu < 2.3, `CPU 秒 ${cpu}`); assert.ok(value(result, "wallSeconds") >= 2); }); test("候选伪造计量结果再死循环:伪造不成,结局仍是 timeout,资源用量是真实测到的;写出的文件归当前用户", async (t) => { // 审阅时实测过的手法:先写一份假的 usage,再把目录改成只读,然后死循环到超时。 const code = [ "import os", "for attempt in (lambda: open('/inforsi-record/usage', 'w').write('exited 0 1 1 1\\n'), lambda: os.chmod('/inforsi-record', 0o555)):", " try:", " attempt()", " except OSError as error:", " print(type(error).__name__, flush=True)", "open('out.txt', 'w').write('x')", "while True: pass", ].join("\n"); const name = containerName(t, "forge"); const { runDir, logDir } = dirs(t); assert.deepEqual(await runner.start(name, definition(python(code), 2, digest), runDir), { started: true }); const result = await runner.wait(name, logDir); assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "PermissionError\nPermissionError\n"); assert.equal(result.ending, "timeout"); const cpu = value(result, "cpuSeconds"); t.diagnostic(`结局 ${result.ending},CPU 秒 ${cpu},墙钟秒 ${value(result, "wallSeconds")}`); assert.ok(cpu > 1.7 && cpu < 2.3, `CPU 秒 ${cpu}`); assert.ok(value(result, "wallSeconds") >= 2); assert.equal(statSync(join(runDir, "out.txt")).uid, process.getuid?.()); }); test("命令自己以 124 或 137 退出,不判成超时", async (t) => { const own124 = await runToEnd(t, "exit124", python("import sys; sys.exit(124)"), 30); assert.equal(own124.result.ending, "exited"); assert.equal(own124.result.ending === "exited" && own124.result.exitCode, 124); const own137 = await runToEnd(t, "exit137", python("import os; os.kill(os.getpid(), 9)"), 30); assert.equal(own137.result.ending, "exited"); assert.equal(own137.result.ending === "exited" && own137.result.exitCode, 137); }); test("从外面 docker kill:结局 cancelled,三项资源都测不到并写明原因", async (t) => { const name = containerName(t, "killed"); const { runDir, logDir } = dirs(t); const outcome = await runner.start(name, definition(python("import time; time.sleep(60)"), 120, digest), runDir); assert.deepEqual(outcome, { started: true }); assert.equal(await runner.status(name), "running"); const killed = docker(["kill", name]); assert.equal(killed.status, 0, killed.stderr); const result = await runner.wait(name, logDir); assert.equal(result.ending, "cancelled"); if (result.ending !== "cancelled") return; for (const item of [result.cpuSeconds, result.wallSeconds, result.peakMemoryBytes]) { assert.equal(item.status, "unmeasured"); assert.match(item.status === "unmeasured" ? item.reason : "", /从外面被结束/); } }); test("镜像编号不存在:没启动,原因是 Docker 的报错原文,也没留下容器", async (t) => { const name = containerName(t, "noimage"); const { runDir } = dirs(t); const outcome = await runner.start(name, definition(python("pass"), 30, MISSING_DIGEST), runDir); assert.equal(outcome.started, false); assert.match(outcome.started ? "" : outcome.reason, /No such image/); assert.equal(await runner.status(name), "absent"); }); test("断网:容器里连不上外网", async (t) => { const code = [ "import socket, sys", "try:", " socket.create_connection(('1.1.1.1', 53), timeout=3)", "except OSError as error:", " print(type(error).__name__); sys.exit(0)", "sys.exit(1)", ].join("\n"); const { result } = await runToEnd(t, "network", python(code), 30); assert.equal(result.ending === "exited" && result.exitCode, 0); }); test("核数:容器里 os.cpu_count() 报 1", async (t) => { const { logDir } = await runToEnd(t, "cores", python("import os; print(os.cpu_count())"), 30); assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "1\n"); }); test("查状态:没有这个容器、建了没启动、在跑、已结束、问不到", async (t) => { assert.equal(await runner.status(containerName(t, "status-absent")), "absent"); const created = containerName(t, "status-created"); const create = docker(["create", "--name", created, digest, "true"]); assert.equal(create.status, 0, create.stderr); assert.equal(await runner.status(created), "created"); const name = containerName(t, "status-running"); const { runDir, logDir } = dirs(t); await runner.start(name, definition(python("import time; time.sleep(3)"), 30, digest), runDir); assert.equal(await runner.status(name), "running"); await runner.wait(name, logDir); assert.equal(await runner.status(name), "exited"); const nowhere = new Runner("unix:///nonexistent/docker.sock"); assert.equal(await nowhere.status(name), "unreachable"); await runner.remove(name); assert.equal(await runner.status(name), "absent"); }); test("等结束能接上另一个 Runner 实例启动的容器;已结束的容器再等一次,结果相同", async (t) => { const name = containerName(t, "resume"); const { runDir, logDir } = dirs(t); const starter = new Runner(null); await starter.start(name, definition(python("import time; time.sleep(2); print('done')"), 30, digest), runDir); const resumed = new Runner(null); const first = await resumed.wait(name, logDir); assert.equal(first.ending === "exited" && first.exitCode, 0); assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "done\n"); const again = await new Runner(null).wait(name, logDir); assert.deepEqual(again, first); }); test("容器名不符合 Docker 的要求时报错", async () => { await assert.rejects(runner.status("inforsi test"), /不符合 Docker 的要求/); await assert.rejects(runner.status("-x"), /不符合 Docker 的要求/); });