diff --git a/src/validation/layer2.ts b/src/validation/layer2.ts index c9f83f6..b95fb19 100644 --- a/src/validation/layer2.ts +++ b/src/validation/layer2.ts @@ -1,17 +1,111 @@ +import { spawn } from "node:child_process"; +import { access } from "node:fs/promises"; +import { resolve } from "node:path"; + export type Layer2Input = { railsDir: string; - healthPath?: string; - bootTimeoutMs?: number; + timeoutMs?: number; }; export type Layer2Result = { pass: boolean; - port: number; - healthStatus: number | null; + command: string; + exitCode: number | null; + durationMs: number; stderrTail?: string; }; +const DEFAULT_TIMEOUT_MS = 300_000; + export async function runLayer2(input: Layer2Input): Promise { - void input; - throw new Error("runLayer2 not implemented: pick random port, spawn bin/rails server -p -b 127.0.0.1, poll healthPath (default /up) until 200 or bootTimeoutMs, then SIGTERM"); + const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const start = Date.now(); + + if (!(await exists(input.railsDir))) { + return { + pass: false, + command: "stat", + exitCode: null, + durationMs: Date.now() - start, + stderrTail: `railsDir does not exist: ${input.railsDir}`, + }; + } + + const useMise = await commandAvailable("mise"); + + const bundleCheck = await runRails(input.railsDir, ["bundle", "check"], timeoutMs, useMise); + if (bundleCheck.exitCode !== 0) { + const bundleInstall = await runRails(input.railsDir, ["bundle", "install"], timeoutMs, useMise); + if (bundleInstall.exitCode !== 0) { + return failed("bundle install", bundleInstall, start, useMise); + } + } + + const routes = await runRails(input.railsDir, ["bin/rails", "routes"], timeoutMs, useMise); + if (routes.exitCode !== 0) { + return failed("bin/rails routes", routes, start, useMise); + } + + return { + pass: true, + command: withPrefix("bin/rails routes", useMise), + exitCode: 0, + durationMs: Date.now() - start, + }; +} + +function failed(action: string, result: RunResult, start: number, useMise: boolean): Layer2Result { + return { + pass: false, + command: withPrefix(action, useMise), + exitCode: result.exitCode, + durationMs: Date.now() - start, + stderrTail: tail(result.stderr, 30), + }; +} + +function withPrefix(action: string, useMise: boolean): string { + return useMise ? `mise exec -- ${action}` : action; +} + +type RunResult = { exitCode: number | null; stderr: string }; + +function runRails(cwd: string, argv: readonly string[], timeoutMs: number, useMise: boolean): Promise { + const [command, ...rest] = useMise ? ["mise", "exec", "--", ...argv] : argv; + return new Promise((resolvePromise) => { + const child = spawn(command!, rest, { cwd }); + const stderrChunks: Buffer[] = []; + const timer = setTimeout(() => { child.kill("SIGTERM"); }, timeoutMs); + + child.stderr.on("data", (c: Buffer) => stderrChunks.push(c)); + child.on("close", (code) => { + clearTimeout(timer); + resolvePromise({ exitCode: code, stderr: Buffer.concat(stderrChunks).toString("utf8") }); + }); + child.on("error", () => { + clearTimeout(timer); + resolvePromise({ exitCode: null, stderr: Buffer.concat(stderrChunks).toString("utf8") }); + }); + }); +} + +async function commandAvailable(bin: string): Promise { + return new Promise((resolvePromise) => { + const c = spawn("which", [bin]); + c.on("close", (code) => resolvePromise(code === 0)); + c.on("error", () => resolvePromise(false)); + }); +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function tail(s: string, lines: number): string { + return s.split("\n").slice(-lines).join("\n"); } diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 653375b..7ebe9e4 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -16,11 +16,11 @@ test("runLayer1 rejects until implemented", async () => { ); }); -test("runLayer2 rejects until implemented", async () => { - await assert.rejects( - runLayer2({ railsDir: "/tmp" }), - /not implemented/i, - ); +test("runLayer2 returns a failed result for a non-Rails directory", async () => { + const result = await runLayer2({ railsDir: "/tmp", timeoutMs: 10_000 }); + assert.equal(result.pass, false); + assert.equal(typeof result.command, "string"); + assert.equal(typeof result.durationMs, "number"); }); test("runLayer3 rejects until implemented", async () => {