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
69 changes: 23 additions & 46 deletions web/tests/client-config-env.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { spawn } from "node:child_process";
import { type ChildResult, runChild } from "./helpers/run-child";

const requiredEnv = {
PATH: process.env.PATH ?? "",
Expand Down Expand Up @@ -44,7 +44,7 @@ describe("client config env validation", () => {
VERCEL_PREVIEW_COMMENTS_ENABLED: "0",
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("CMUX_CLIENT_CONFIG_RATE_LIMIT_ID is required");
});

Expand All @@ -59,7 +59,7 @@ describe("client config env validation", () => {
...requiredIrohProductionEnv,
...requiredRelayProductionEnv,
});
expect(without.exitCode).toBe(0);
expect(without.status).toBe(0);
expect(without.stderr).not.toContain("CODEROUTER_HOSTED_PRO_REQUIRED");
expect(without.stderr).not.toContain("SUBROUTER_ENFORCE_STACK_PERMISSIONS");
expect(without.stderr).not.toContain("SUBROUTER_ALLOWED_TEAM_IDS");
Expand All @@ -75,7 +75,7 @@ describe("client config env validation", () => {
SUBROUTER_ENFORCE_STACK_PERMISSIONS: "1",
SUBROUTER_ALLOWED_TEAM_IDS: "team-a",
});
expect(withStale.exitCode).toBe(0);
expect(withStale.status).toBe(0);
});

test("rejects every retired Stripe price override at startup", async () => {
Expand Down Expand Up @@ -108,7 +108,7 @@ describe("client config env validation", () => {
STRIPE_TEAM_YEARLY_576_PRICE_ID: "price_team_576",
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});

test("allows explicit Vercel production deployments with all rate-limit ids unset", async () => {
Expand All @@ -125,7 +125,7 @@ describe("client config env validation", () => {
...relayEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("RATE_LIMIT_ID");
});

Expand All @@ -141,7 +141,7 @@ describe("client config env validation", () => {
...requiredRelayProductionEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});

test("allows hosted-only production after the temporary legacy admin token is retired", async () => {
Expand All @@ -156,7 +156,7 @@ describe("client config env validation", () => {
...requiredRelayProductionEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("SUBROUTER_ADMIN_TOKEN");
});

Expand All @@ -169,7 +169,7 @@ describe("client config env validation", () => {
CMUX_DOCS_CHANNEL: "nightly",
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});

test("allows explicit Vercel production deployments without the analytics limiter id", async () => {
Expand All @@ -183,7 +183,7 @@ describe("client config env validation", () => {
...requiredRelayProductionEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("CMUX_ANALYTICS_RATE_LIMIT_ID");
});

Expand All @@ -198,7 +198,7 @@ describe("client config env validation", () => {
...requiredRelayProductionEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});
test("accepts the self-hosted relay path without the legacy hosted minter", async () => {
const result = await importEnv({
Expand All @@ -217,7 +217,7 @@ describe("client config env validation", () => {
...requiredRelayProductionEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});

test("allows explicit Vercel production without the optional Iroh limiter id", async () => {
Expand All @@ -232,7 +232,7 @@ describe("client config env validation", () => {
...requiredSubrouterDeploymentEnv,
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stderr).not.toContain("CMUX_IROH_RATE_LIMIT_ID is required");
});

Expand Down Expand Up @@ -278,7 +278,7 @@ describe("client config env validation", () => {
VERCEL_ENV: "preview",
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
});

test("allows an explicitly opted-in loopback HTTP relay minter only in local development", async () => {
Expand All @@ -289,7 +289,7 @@ describe("client config env validation", () => {
CMUX_IROH_MINT_URL: "http://localhost:49152/api/relay-token",
});

expect(result.exitCode).toBe(0);
expect(result.status).toBe(0);
expect(result.stdout).toBe("http://localhost:49152/api/relay-token");
});

Expand Down Expand Up @@ -338,55 +338,32 @@ describe("client config env validation", () => {
});
});

interface ChildResult {
exitCode: number;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
}

// A rejection must be the validation failure itself: a clean nonzero exit with
// the expected diagnostic. A child killed by the spawn timeout or a signal also
// exits nonzero, so it must never count as the rejection under test.
function expectRejected(result: ChildResult, ...diagnostics: string[]): void {
expect(result.signal).toBeNull();
expect(result.exitCode).not.toBe(0);
expect(result.status).not.toBe(0);
for (const diagnostic of diagnostics) {
expect(result.stderr).toContain(diagnostic);
}
}

// Async spawn rather than spawnSync: on Blacksmith runners Bun 1.3's spawnSync
// sometimes never observes the child's exit and spins the test process at 100%
// CPU until the job times out (manaflow-ai/cmux#14876). The async spawn waits
// through the event loop instead.
function runChild(
args: string[],
env: Record<string, string>,
): Promise<ChildResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--no-env-file", ...args], {
env: env as NodeJS.ProcessEnv,
stdio: ["ignore", "pipe", "pipe"],
timeout: 30_000,
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
child.on("error", reject);
child.on("close", (code, signal) => resolve({ exitCode: code ?? 1, signal, stdout, stderr }));
});
/** Runs this Bun binary without .env loading, under exactly the given environment. */
function runBun(args: string[], env: Record<string, string>): Promise<ChildResult> {
return runChild(process.execPath, ["--no-env-file", ...args], { env, timeout: 30_000 });
}

/** Imports app/env in a fresh Bun process so its startup validation runs. */
async function importEnv(env: Record<string, string>): Promise<ChildResult> {
return runChild(["-e", "await import('./app/env')"], env);
return runBun(["-e", "await import('./app/env')"], env);
}

/** Resolves the relay minter URL in a fresh Bun process and prints its href. */
async function inspectIrohMinterUrl(
env: Record<string, string>,
): Promise<ChildResult> {
const result = await runChild(["-e", `
const result = await runBun(["-e", `
const { irohTrustBrokerConfigFromEnv } = await import('./services/iroh/config');
const { parseMinterUrl } = await import('./services/iroh/relayMinter');
const config = irohTrustBrokerConfigFromEnv();
Expand Down
22 changes: 11 additions & 11 deletions web/tests/docs-search-cache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
appendFileSync,
Expand All @@ -14,6 +13,7 @@ import {
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { runChild } from "./helpers/run-child";

const webRoot = fileURLToPath(new URL("..", import.meta.url));
let fixture: string | undefined;
Expand All @@ -23,10 +23,10 @@ afterEach(() => {
fixture = undefined;
});

function build(web: string) {
const result = spawnSync(process.execPath, ["tools/build-docs-search.mjs"], {
/** Builds the docs search index in the fixture and returns its stdout; throws on failure. */
async function build(web: string) {
const result = await runChild(process.execPath, ["tools/build-docs-search.mjs"], {
cwd: web,
encoding: "utf8",
timeout: 60_000,
env: { ...process.env, CMUX_DOCS_CHANNEL: "release" },
});
Expand All @@ -48,7 +48,7 @@ function outputHashes(directory: string): Record<string, string> {
return hashes;
}

test("restores identical search assets and rebuilds after dependency or generator changes", () => {
test("restores identical search assets and rebuilds after dependency or generator changes", async () => {
fixture = mkdtempSync(path.join(tmpdir(), "cmux-docs-cache-"));
const web = path.join(fixture, "web");
mkdirSync(path.join(web, "tools"), { recursive: true });
Expand All @@ -62,21 +62,21 @@ test("restores identical search assets and rebuilds after dependency or generato

const restored = "Docs search index restored";
const output = path.join(web, "public", "pagefind");
expect(build(web)).not.toContain(restored);
expect(await build(web)).not.toContain(restored);
const original = outputHashes(output);
expect(Object.keys(original)).toContain("pagefind.js");
rmSync(output, { recursive: true });
expect(build(web)).toContain(restored);
expect(await build(web)).toContain(restored);
expect(outputHashes(output)).toEqual(original);

appendFileSync(path.join(web, "bun.lock"), "\n");
expect(build(web)).not.toContain(restored);
expect(build(web)).toContain(restored);
expect(await build(web)).not.toContain(restored);
expect(await build(web)).toContain(restored);

appendFileSync(script, "\n// Updated generator revision.\n");
expect(build(web)).not.toContain(restored);
expect(await build(web)).not.toContain(restored);

appendFileSync(path.join(fixture, "CHANGELOG.md"), "\nA new searchable release note.\n");
expect(build(web)).not.toContain(restored);
expect(await build(web)).not.toContain(restored);
expect(outputHashes(output)).not.toEqual(original);
}, 120_000);
20 changes: 15 additions & 5 deletions web/tests/fixtures/freestyleGuest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
Expand All @@ -11,14 +10,20 @@ import { installFreestyleGuestCli } from "../../services/vms/drivers/freestyleGu
import { rollbackFreestyleCreate } from "../../services/vms/drivers/providerCreateCleanup";
import { ProviderError, type CreateOptions } from "../../services/vms/drivers/types";
import type { GuestCliDistribution } from "../../services/vms/guestCliDistribution";
import { runChild } from "../helpers/run-child";

export type GuestExecRequest = {
command: string;
timeoutMs: number;
linuxUser: string;
};

const testDistribution = (() => {
// Built on first use, not with top-level await: several test files import this
// fixture, and a module that suspends during evaluation leaves its exports in
// the temporal dead zone for importers Bun evaluates concurrently.
let testDistribution: Promise<GuestCliDistribution> | undefined;
/** Builds the synthetic guest CLI archive once; a failed build is retried by the next caller. */
const syntheticDistribution = () => (testDistribution ??= (async () => {
const root = mkdtempSync(join(tmpdir(), "cmux-guest-cli-fixture-"));
const source = join(root, "source");
mkdirSync(source);
Expand All @@ -27,7 +32,7 @@ const testDistribution = (() => {
writeFileSync(join(source, "cmux-cloud-cli"), facade, { mode: 0o755 });
writeFileSync(join(source, "coderouter"), core, { mode: 0o755 });
const archive = join(root, "cli.tar.gz");
const tar = spawnSync("tar", ["-czf", archive, "-C", source, "cmux-cloud-cli", "coderouter"], {
const tar = await runChild("tar", ["-czf", archive, "-C", source, "cmux-cloud-cli", "coderouter"], {
env: { ...process.env, COPYFILE_DISABLE: "1" },
});
if (tar.status !== 0) throw new Error("could not create the synthetic guest CLI archive");
Expand All @@ -37,7 +42,10 @@ const testDistribution = (() => {
archiveSha256: digest(readFileSync(archive)),
binaries: { "cmux-cloud-cli": digest(facade), coderouter: digest(core) },
} satisfies GuestCliDistribution;
})();
})().catch((error: unknown) => {
testDistribution = undefined;
throw error;
}));

/** Real pinned SDK, synthetic HTTP only. No provider credentials or network. */
export function freestyleGuestFixture(options: {
Expand Down Expand Up @@ -106,12 +114,14 @@ export function freestyleGuestFixture(options: {
* create path follows snapshot-v2 and intentionally performs no guest setup.
*/
const createWithGuestInstall = async (createOptions: CreateOptions) => {
// Resolve the archive first: a failed build must not leave a live VM behind.
const distribution = options.guestCliDistribution ?? await syntheticDistribution();
const handle = await provider.create(createOptions);
const install = await Effect.runPromise(Effect.either(installFreestyleGuestCli(
client,
handle.providerVmId,
createOptions.promptIdentity,
options.guestCliDistribution ?? testDistribution,
distribution,
)));
if (install._tag === "Right") {
return handle;
Expand Down
Loading
Loading