t3-code-android-nightly/scripts/smoke-cli-archive.ts
Julius Marminge 194c73f3f9
chore(deps): upgrade Effect to stable 4.0.1 (#16138)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:15:14 -07:00

210 lines
7.7 KiB
JavaScript

#!/usr/bin/env node
/**
* Unpacks a CLI archive into a scratch directory and runs the executable the
* way an installer would: no repo, no node_modules, no Node on PATH. Catches
* the failures that only show inside the single-executable, such as an
* external package reached through `import` or a native addon the hardened
* runtime refuses to load.
*/
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import * as Path from "effect/Path";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import { Command, Flag } from "effect/cli";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as NetService from "@t3tools/shared/Net";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
import { windowsSystemTar } from "./build-cli-archive.ts";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
export class CliArchiveSmokeError extends Schema.TaggedError<CliArchiveSmokeError>()(
"CliArchiveSmokeError",
{ step: Schema.String, detail: Schema.String },
) {
override get message(): string {
return `CLI archive smoke test failed while ${this.step}: ${this.detail}`;
}
}
const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
stream.pipe(
Stream.decodeText(),
Stream.runFold(
() => "",
(acc, chunk) => acc + chunk,
),
);
const runExecutable = Effect.fn("runExecutable")(function* (
executable: string,
args: ReadonlyArray<string>,
cwd: string,
) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const child = yield* spawner.spawn(
ChildProcess.make(executable, args, {
cwd,
// Empty PATH: the archive must not reach a system node, and the
// launcher context must not leak in from a developer shell.
env: { PATH: "", HOME: cwd, USERPROFILE: cwd, TMPDIR: cwd, TEMP: cwd },
extendEnv: false,
}),
);
const [stdout, stderr, exitCode] = yield* Effect.all(
[collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
{ concurrency: "unbounded" },
);
return { stdout, stderr, exitCode };
});
const smokeCliArchive = Effect.fn("smokeCliArchive")(function* (input: {
readonly archive: string;
readonly expectVersion: string;
}) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const scratch = yield* fs.makeTempDirectory({ prefix: "t3-cli-smoke-" });
// Windows can keep t3.exe locked (EBUSY) for a moment after the server
// exits. A leftover scratch directory on a CI runner is harmless, so
// cleanup retries briefly and never fails a smoke test that passed.
yield* Effect.addFinalizer(() =>
fs.remove(scratch, { recursive: true }).pipe(
Effect.retry({ times: 10, schedule: Schedule.spaced("500 millis") }),
Effect.catch((error) =>
Effect.logWarning(`[cli-smoke] could not remove ${scratch}: ${error.message}`),
),
),
);
// On Windows the archive is a zip and the Git Bash `tar` on PATH is GNU
// tar; use the bsdtar Windows ships, which reads both formats.
const tar = platform === "win32" ? windowsSystemTar() : "tar";
const extract = yield* spawner
.spawn(ChildProcess.make(tar, ["-xf", input.archive, "-C", scratch]))
.pipe(Effect.flatMap((child) => child.exitCode));
if (Number(extract) !== 0) {
return yield* new CliArchiveSmokeError({
step: "extracting the archive",
detail: `tar exited with ${String(extract)}`,
});
}
const [root] = yield* fs.readDirectory(scratch);
if (root === undefined) {
return yield* new CliArchiveSmokeError({
step: "extracting the archive",
detail: "the archive was empty",
});
}
const contentDir = path.join(scratch, root);
const executable = path.join(contentDir, platform === "win32" ? "t3.exe" : "t3");
for (const required of [executable, path.join(contentDir, "client/index.html")]) {
if (!(yield* fs.exists(required))) {
return yield* new CliArchiveSmokeError({
step: "checking the archive layout",
detail: `missing ${path.relative(contentDir, required)}`,
});
}
}
const version = yield* runExecutable(executable, ["--version"], contentDir);
if (version.exitCode !== 0 || !version.stdout.includes(input.expectVersion)) {
return yield* new CliArchiveSmokeError({
step: "running --version",
detail: `exit ${String(version.exitCode)}\n${version.stdout}${version.stderr}`,
});
}
// Starting the server is what actually opens sqlite, loads the terminal
// and search stacks (node-pty, fff, msgpackr-extract), and serves the
// client, so probe a real `serve` in a scratch home rather than a
// command that only reads package metadata.
const net = yield* NetService.NetService;
const port = yield* net.findAvailablePort(47700);
const home = path.join(scratch, "home");
const server = yield* spawner.spawn(
ChildProcess.make(
executable,
["serve", "--host", "127.0.0.1", "--port", String(port), "--no-browser"],
{
cwd: contentDir,
env: {
PATH: "",
HOME: home,
USERPROFILE: home,
TMPDIR: scratch,
TEMP: scratch,
T3CODE_HOME: home,
},
extendEnv: false,
},
),
);
const output = yield* Effect.forkScoped(
Effect.all([collect(server.stdout), collect(server.stderr)]),
);
const httpClient = yield* HttpClient.HttpClient;
// A request that connects while the server is still initializing can hang,
// so each probe gets its own deadline, like the SSH readiness probe.
const probe = httpClient.execute(HttpClientRequest.get(`http://127.0.0.1:${String(port)}/`)).pipe(
Effect.map((response) => response.status === 200),
Effect.timeout(Duration.seconds(2)),
Effect.orElseSucceed(() => false),
);
const pollUntilReady = Effect.gen(function* () {
while (!(yield* probe)) {
yield* Effect.sleep(Duration.millis(250));
}
return true;
});
const ready = yield* pollUntilReady.pipe(
Effect.timeout(Duration.seconds(30)),
Effect.orElseSucceed(() => false),
);
yield* server.kill({ killSignal: "SIGTERM" }).pipe(Effect.ignore);
yield* server.exitCode.pipe(Effect.timeout(Duration.seconds(10)), Effect.ignore);
const [stdout, stderr] = yield* Fiber.join(output).pipe(
Effect.timeout(Duration.seconds(5)),
Effect.orElseSucceed(() => ["", ""] as const),
);
if (!ready) {
return yield* new CliArchiveSmokeError({
step: "serving from the extracted archive",
detail: `no 200 from / within 30s\n${stdout}${stderr}`,
});
}
yield* Effect.log(`[cli-smoke] ${root}: --version passed and serve answered on ${String(port)}.`);
});
const command = Command.make(
"smoke-cli-archive",
{
archive: Flag.String("archive"),
expectVersion: Flag.String("expect-version"),
},
(input) => smokeCliArchive(input).pipe(Effect.scoped),
).pipe(Command.withDescription("Extract a CLI archive and run its executable."));
if (import.meta.main) {
Command.run(command, { version: "0.0.0" }).pipe(
Effect.provide(
Layer.mergeAll(
Logger.layer([Logger.consolePretty()]),
NodeServices.layer,
NetService.layer,
FetchHttpClient.layer,
),
),
NodeRuntime.runMain,
);
}