From 2bd882e0b0ec6458249c169a21e5554b76f2bf0e Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Sun, 12 Jul 2026 21:40:52 +0200 Subject: [PATCH] feat(core-testing): git-serving fixture helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK --- packages/core-testing/AGENTS.md | 1 + packages/core-testing/package.json | 1 + packages/core-testing/src/git/index.ts | 5 + .../src/git/serve-fixture-repo.test.ts | 147 ++++++++++ .../src/git/serve-fixture-repo.ts | 255 ++++++++++++++++++ 5 files changed, 409 insertions(+) create mode 100644 packages/core-testing/src/git/index.ts create mode 100644 packages/core-testing/src/git/serve-fixture-repo.test.ts create mode 100644 packages/core-testing/src/git/serve-fixture-repo.ts diff --git a/packages/core-testing/AGENTS.md b/packages/core-testing/AGENTS.md index 9aeb97b..594d1c6 100644 --- a/packages/core-testing/AGENTS.md +++ b/packages/core-testing/AGENTS.md @@ -6,6 +6,7 @@ Shared testing utilities. Tag: `tooling`. May be depended on by any package as a - `@repo/core-testing/factory` — `defineFactory(builder)` for test data factories - `@repo/core-testing/contract` — `defineContractSuite(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` diff --git a/packages/core-testing/package.json b/packages/core-testing/package.json index 3103cee..0330489 100644 --- a/packages/core-testing/package.json +++ b/packages/core-testing/package.json @@ -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", diff --git a/packages/core-testing/src/git/index.ts b/packages/core-testing/src/git/index.ts new file mode 100644 index 0000000..2e0d2cf --- /dev/null +++ b/packages/core-testing/src/git/index.ts @@ -0,0 +1,5 @@ +export { + serveFixtureRepo, + type ServedFixtureRepo, + type ServeFixtureRepoOptions, +} from "./serve-fixture-repo"; diff --git a/packages/core-testing/src/git/serve-fixture-repo.test.ts b/packages/core-testing/src/git/serve-fixture-repo.test.ts new file mode 100644 index 0000000..ada902d --- /dev/null +++ b/packages/core-testing/src/git/serve-fixture-repo.test.ts @@ -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[1]) { + const repo = await serveFixtureRepo(VITE_KITCHEN, options); + served.push(repo); + return repo; + } + + async function makeCloneDest(): Promise { + 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/); + }, + ); +}); diff --git a/packages/core-testing/src/git/serve-fixture-repo.ts b/packages/core-testing/src/git/serve-fixture-repo.ts new file mode 100644 index 0000000..d871bc4 --- /dev/null +++ b/packages/core-testing/src/git/serve-fixture-repo.ts @@ -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; +} + +/** 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 { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Ask the OS for a currently-free localhost port. */ +async function findFreePort(): Promise { + 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 { + 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 { + 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): () => Promise { + let done: Promise | 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 { + 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 { + 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; + } +}