Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 100 additions & 6 deletions src/validation/layer2.ts
Original file line number Diff line number Diff line change
@@ -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<Layer2Result> {
void input;
throw new Error("runLayer2 not implemented: pick random port, spawn bin/rails server -p <port> -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<RunResult> {
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<boolean> {
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<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}

function tail(s: string, lines: number): string {
return s.split("\n").slice(-lines).join("\n");
}
10 changes: 5 additions & 5 deletions tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading