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:
@@ -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/factory` — `defineFactory<T>(builder)` for test data factories
|
||||||
- `@repo/core-testing/contract` — `defineContractSuite<T>(name, suite)` for cross-impl contract tests
|
- `@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`
|
- `@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`.
|
- `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`
|
- `@repo/core-testing/payload` — `stubPayloadConfig`, `mockPayloadModule`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./factory": "./src/factory/index.ts",
|
"./factory": "./src/factory/index.ts",
|
||||||
"./contract": "./src/contract/index.ts",
|
"./contract": "./src/contract/index.ts",
|
||||||
|
"./git": "./src/git/index.ts",
|
||||||
"./instrumentation": "./src/instrumentation/index.ts",
|
"./instrumentation": "./src/instrumentation/index.ts",
|
||||||
"./react": "./src/react/index.ts",
|
"./react": "./src/react/index.ts",
|
||||||
"./payload": "./src/payload/index.ts",
|
"./payload": "./src/payload/index.ts",
|
||||||
|
|||||||
5
packages/core-testing/src/git/index.ts
Normal file
5
packages/core-testing/src/git/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export {
|
||||||
|
serveFixtureRepo,
|
||||||
|
type ServedFixtureRepo,
|
||||||
|
type ServeFixtureRepoOptions,
|
||||||
|
} from "./serve-fixture-repo";
|
||||||
147
packages/core-testing/src/git/serve-fixture-repo.test.ts
Normal file
147
packages/core-testing/src/git/serve-fixture-repo.test.ts
Normal 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/);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
255
packages/core-testing/src/git/serve-fixture-repo.ts
Normal file
255
packages/core-testing/src/git/serve-fixture-repo.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user