feat(core-testing): git-serving fixture helpers

serveFixtureRepo(fixtureDir) copies a fixture into a temp dir, commits
it as a fresh single-commit repo, bare-clones it, and serves it over
`git daemon --export-all` on an ephemeral localhost port (default) or
as a file:// URL (fallback for daemon-less environments). Returns
{ cloneUrl, bareRepoPath, protocol, stop } with idempotent teardown.
Tests exercise a real `git clone` of fixtures/vite-kitchen over both
protocols. New subpath export @repo/core-testing/git — node-only, so
deliberately not on the jsdom root barrel. No new runtime deps: node
built-ins + system git (present in git's exec-path on macOS and the
ubuntu CI image).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
2026-07-12 21:40:52 +02:00
parent 1ae4a2385c
commit 2bd882e0b0
5 changed files with 409 additions and 0 deletions

View File

@@ -6,6 +6,7 @@ Shared testing utilities. Tag: `tooling`. May be depended on by any package as a
- `@repo/core-testing/factory``defineFactory<T>(builder)` for test data factories
- `@repo/core-testing/contract``defineContractSuite<T>(name, suite)` for cross-impl contract tests
- `@repo/core-testing/git``serveFixtureRepo(fixtureDir, { protocol? })` serves a fixture directory (e.g. `fixtures/vite-kitchen`) as a real git remote for integration tests: copies it to a temp dir, commits it, bare-clones it, and exposes it via `git daemon` on an ephemeral localhost port (default) or a `file://` URL (fallback). Returns `{ cloneUrl, bareRepoPath, protocol, stop }`; always `await stop()` in `afterEach`. Node-only (`node:child_process` + system git) — intentionally not re-exported from the root barrel
- `@repo/core-testing/react``renderWithProviders`, `createMockTrpcClient`
- `renderWithProviders` does NOT include a tRPC provider. Consumers needing tRPC should wire their own TRPCProvider (from their app's tRPC client setup) and use `createMockTrpcClient` as the client. This constraint exists because tooling packages cannot import `AppRouter` from `@repo/core-api`.
- `@repo/core-testing/payload``stubPayloadConfig`, `mockPayloadModule`

View File

@@ -7,6 +7,7 @@
".": "./src/index.ts",
"./factory": "./src/factory/index.ts",
"./contract": "./src/contract/index.ts",
"./git": "./src/git/index.ts",
"./instrumentation": "./src/instrumentation/index.ts",
"./react": "./src/react/index.ts",
"./payload": "./src/payload/index.ts",

View File

@@ -0,0 +1,5 @@
export {
serveFixtureRepo,
type ServedFixtureRepo,
type ServeFixtureRepoOptions,
} from "./serve-fixture-repo";

View File

@@ -0,0 +1,147 @@
// @vitest-environment node
import { execFile } from "node:child_process";
import { access, mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { serveFixtureRepo, type ServedFixtureRepo } from "@/git/index";
const execFileAsync = promisify(execFile);
/** repo-root/fixtures/vite-kitchen, resolved from this file's location. */
const VITE_KITCHEN = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../../fixtures/vite-kitchen",
);
const GIT_TIMEOUT = 30_000;
describe("serveFixtureRepo", () => {
const served: ServedFixtureRepo[] = [];
const tmpDirs: string[] = [];
async function serve(options?: Parameters<typeof serveFixtureRepo>[1]) {
const repo = await serveFixtureRepo(VITE_KITCHEN, options);
served.push(repo);
return repo;
}
async function makeCloneDest(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "veect-clone-test-"));
tmpDirs.push(dir);
return path.join(dir, "clone");
}
afterEach(async () => {
await Promise.all(served.splice(0).map((repo) => repo.stop()));
await Promise.all(
tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
it(
"serves vite-kitchen over the git protocol and a real git clone succeeds",
{ timeout: GIT_TIMEOUT },
async () => {
const repo = await serve();
expect(repo.protocol).toBe("git");
expect(repo.cloneUrl).toMatch(
/^git:\/\/127\.0\.0\.1:\d+\/vite-kitchen\.git$/,
);
const isBare = await execFileAsync(
"git",
["rev-parse", "--is-bare-repository"],
{ cwd: repo.bareRepoPath },
);
expect(isBare.stdout.trim()).toBe("true");
const dest = await makeCloneDest();
await execFileAsync("git", ["clone", repo.cloneUrl, dest]);
const button = await readFile(
path.join(dest, "src", "components", "Button.tsx"),
"utf8",
);
expect(button).toContain("ButtonProps");
const pkg = JSON.parse(
await readFile(path.join(dest, "package.json"), "utf8"),
) as { name: string };
expect(pkg.name).toBe("vite-kitchen");
const log = await execFileAsync("git", ["log", "--oneline"], {
cwd: dest,
});
expect(log.stdout.trim().split("\n")).toHaveLength(1);
},
);
it(
"serves over the file protocol without a daemon",
{ timeout: GIT_TIMEOUT },
async () => {
const repo = await serve({ protocol: "file" });
expect(repo.protocol).toBe("file");
expect(repo.cloneUrl.startsWith("file://")).toBe(true);
const dest = await makeCloneDest();
await execFileAsync("git", ["clone", repo.cloneUrl, dest]);
await expect(
access(path.join(dest, "src", "components", "Button.tsx")),
).resolves.toBeUndefined();
},
);
it(
"never copies install artifacts into the served repo",
{ timeout: GIT_TIMEOUT },
async () => {
const repo = await serve({ protocol: "file" });
const dest = await makeCloneDest();
await execFileAsync("git", ["clone", repo.cloneUrl, dest]);
const files = await execFileAsync("git", ["ls-files"], { cwd: dest });
const listed = files.stdout.trim().split("\n");
expect(listed).toContain("src/components/Button.tsx");
expect(
listed.filter(
(file) =>
file.startsWith("node_modules/") || file.startsWith("dist/"),
),
).toEqual([]);
},
);
it(
"stop() tears down the daemon and temp state, and is idempotent",
{ timeout: GIT_TIMEOUT },
async () => {
const repo = await serve();
await repo.stop();
await repo.stop(); // second call must be a no-op, not an error
await expect(access(repo.bareRepoPath)).rejects.toThrow();
await expect(
execFileAsync("git", ["ls-remote", repo.cloneUrl]),
).rejects.toThrow();
},
);
it(
"rejects when the fixture directory does not exist",
{ timeout: GIT_TIMEOUT },
async () => {
await expect(
serveFixtureRepo(path.join(VITE_KITCHEN, "does-not-exist")),
).rejects.toThrow(/fixture directory not found/);
},
);
});

View File

@@ -0,0 +1,255 @@
/**
* Serve a fixture directory (e.g. `fixtures/vite-kitchen`) as a real git
* remote for integration tests, without any cloud resources.
*
* The fixture is copied to a temp dir, committed as a fresh single-commit
* repository, cloned `--bare`, and exposed one of two ways:
*
* - **`git` protocol (default):** `git daemon --export-all` on an ephemeral
* 127.0.0.1 port. This is the shape the runner protocol suite needs — a
* network remote a spawned runner process can `git clone`. `git daemon`
* ships inside git's exec-path on both macOS (Apple git) and the ubuntu CI
* image's git package, so it is the default; readiness is detected by
* polling a TCP connect on the chosen port, and a port collision is
* retried with a fresh ephemeral port.
* - **`file` protocol (fallback):** a `file://` URL to the bare repo, no
* daemon process. For environments where `git daemon` is unavailable or
* the port is firewalled.
*
* No runtime dependencies — node built-ins plus the system `git` binary.
*/
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { cp, mkdir, mkdtemp, rm, stat } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
export interface ServeFixtureRepoOptions {
/**
* How the repo is exposed. `"git"` (default) spawns `git daemon` on an
* ephemeral localhost port; `"file"` returns a `file://` URL and spawns
* nothing.
*/
protocol?: "git" | "file";
}
export interface ServedFixtureRepo {
/** URL a real `git clone` can use. */
cloneUrl: string;
/** Absolute path of the bare repository backing the remote. */
bareRepoPath: string;
/** The protocol actually served. */
protocol: "git" | "file";
/** Tear down the daemon (if any) and delete all temp state. Idempotent. */
stop: () => Promise<void>;
}
/** Never copy build/install artifacts into the served repo. */
const COPY_EXCLUDES = new Set(["node_modules", "dist", ".git", ".turbo"]);
/** Commit identity for the fixture's single synthetic commit. */
const GIT_IDENTITY = [
"-c",
"user.name=veect-fixture",
"-c",
"user.email=fixtures@veect.invalid",
"-c",
"commit.gpgsign=false",
];
const DAEMON_START_ATTEMPTS = 3;
const DAEMON_READY_TIMEOUT_MS = 10_000;
const DAEMON_KILL_TIMEOUT_MS = 2_000;
async function runGit(args: string[], cwd: string): Promise<string> {
const { stdout } = await execFileAsync("git", args, { cwd });
return stdout;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Ask the OS for a currently-free localhost port. */
async function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (address === null || typeof address === "string") {
server.close();
reject(new Error("findFreePort: could not determine a port"));
return;
}
const { port } = address;
server.close((err) => (err ? reject(err) : resolve(port)));
});
});
}
function canConnect(port: number): Promise<boolean> {
return new Promise((resolve) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.unref();
socket.once("connect", () => {
socket.destroy();
resolve(true);
});
socket.once("error", () => {
socket.destroy();
resolve(false);
});
});
}
/** SIGTERM the child, escalating to SIGKILL; resolves once it has exited. */
function stopProcess(child: ChildProcess): Promise<void> {
return new Promise((resolve) => {
if (child.exitCode !== null || child.signalCode !== null) {
resolve();
return;
}
const killTimer = setTimeout(
() => child.kill("SIGKILL"),
DAEMON_KILL_TIMEOUT_MS,
);
killTimer.unref();
child.once("exit", () => {
clearTimeout(killTimer);
resolve();
});
child.kill("SIGTERM");
});
}
/** Wrap an async teardown so concurrent/repeat calls share one execution. */
function once(fn: () => Promise<void>): () => Promise<void> {
let done: Promise<void> | null = null;
return () => (done ??= fn());
}
interface RunningDaemon {
daemon: ChildProcess;
port: number;
}
/**
* Spawn `git daemon` serving `basePath` on an ephemeral port. Polls a TCP
* connect for readiness; a dead daemon (port race, missing binary) is
* retried on a fresh port up to {@link DAEMON_START_ATTEMPTS} times.
*/
async function startGitDaemon(basePath: string): Promise<RunningDaemon> {
let lastError = new Error("git daemon failed to start");
for (let attempt = 0; attempt < DAEMON_START_ATTEMPTS; attempt++) {
const port = await findFreePort();
const daemon = spawn(
"git",
[
"daemon",
"--export-all",
`--base-path=${basePath}`,
"--listen=127.0.0.1",
`--port=${port}`,
"--reuseaddr",
],
{ stdio: "ignore" },
);
// Don't let a leaked daemon hold the test process open.
daemon.unref();
let spawnError: Error | null = null;
daemon.once("error", (error) => {
spawnError = error;
});
const deadline = Date.now() + DAEMON_READY_TIMEOUT_MS;
while (Date.now() < deadline) {
if (spawnError !== null || daemon.exitCode !== null) break;
if (await canConnect(port)) return { daemon, port };
await delay(50);
}
await stopProcess(daemon);
lastError =
spawnError ??
new Error(
`git daemon did not become ready on 127.0.0.1:${port} ` +
`(attempt ${attempt + 1}/${DAEMON_START_ATTEMPTS})`,
);
}
throw lastError;
}
/**
* Copy `fixtureDir` into a temp dir, commit it, bare-clone it, and serve it.
* Callers own teardown: always `await served.stop()` (afterEach) — it kills
* the daemon and removes every temp path this call created.
*/
export async function serveFixtureRepo(
fixtureDir: string,
options: ServeFixtureRepoOptions = {},
): Promise<ServedFixtureRepo> {
const protocol = options.protocol ?? "git";
const source = path.resolve(fixtureDir);
const sourceStat = await stat(source).catch(() => null);
if (!sourceStat?.isDirectory()) {
throw new Error(`serveFixtureRepo: fixture directory not found: ${source}`);
}
const repoName = path.basename(source);
const tmpRoot = await mkdtemp(
path.join(os.tmpdir(), `veect-fixture-${repoName}-`),
);
try {
// 1. Copy the fixture into a scratch worktree (artifacts excluded).
const worktree = path.join(tmpRoot, "worktree");
await cp(source, worktree, {
recursive: true,
filter: (src) => !COPY_EXCLUDES.has(path.basename(src)),
});
// 2. Commit it as a fresh standalone repository.
await runGit(["init", "--initial-branch=main"], worktree);
await runGit(["add", "-A"], worktree);
await runGit(
[...GIT_IDENTITY, "commit", "-m", `fixture: ${repoName}`],
worktree,
);
// 3. Bare-clone it — the repository actually served.
const reposRoot = path.join(tmpRoot, "repos");
await mkdir(reposRoot);
const bareRepoPath = path.join(reposRoot, `${repoName}.git`);
await runGit(["clone", "--bare", worktree, bareRepoPath], tmpRoot);
if (protocol === "file") {
return {
cloneUrl: pathToFileURL(bareRepoPath).href,
bareRepoPath,
protocol,
stop: once(() => rm(tmpRoot, { recursive: true, force: true })),
};
}
// 4. Serve reposRoot over the git protocol.
const { daemon, port } = await startGitDaemon(reposRoot);
return {
cloneUrl: `git://127.0.0.1:${port}/${repoName}.git`,
bareRepoPath,
protocol,
stop: once(async () => {
await stopProcess(daemon);
await rm(tmpRoot, { recursive: true, force: true });
}),
};
} catch (error) {
await rm(tmpRoot, { recursive: true, force: true });
throw error;
}
}