End-to-end tests: push the real histories side by side, each to a server of its own (#240)
* End-to-end tests: push the real histories side by side, each to a server of its own
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)Failed
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)Failed
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)Failed
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
1 file changed+123−63
modifiedapps/diffinery/test/e2e/e2e.test.mjs+123−63View file
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | import { fileURLToPath } from "node:url"; |
| 17 | 17 | import { createServer } from "node:net"; |
| 18 | 18 | import { crc32, deflateSync } from "node:zlib"; |
| 19 | -import { test, before, after } from "node:test"; | |
| 19 | +import { test, describe, before, after } from "node:test"; | |
| 20 | 20 | import assert from "node:assert/strict"; |
| 21 | 21 | import { fakeGitHub, lfsObject } from "./fake-github.mjs"; |
| 22 | 22 | import { migratedState } from "./d1-state.mjs"; |
| @@ -25,7 +25,6 @@ | ||
| 25 | 25 | const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| 26 | 26 | const work = mkdtempSync(join(tmpdir(), "diffinery-e2e-")); |
| 27 | 27 | let base; |
| 28 | -let dev; | |
| 29 | 28 | // The dev server writes to a file, not a pipe: a long synchronous git command keeps this process |
| 30 | 29 | // from reading a pipe, and a full one (retried webhooks log a stack each) stalls the server mid-push. |
| 31 | 30 | const devLogFile = join(work, "wrangler.log"); |
| @@ -174,36 +173,51 @@ | ||
| 174 | 173 | return { claim, out }; |
| 175 | 174 | } |
| 176 | 175 | |
| 177 | -before(async () => { | |
| 178 | - assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); | |
| 176 | +/** | |
| 177 | + * A `wrangler dev` server of its own with an admin token. Most tests share the one before() starts; | |
| 178 | + * the real histories each push to their own, so they run side by side on separate processes. | |
| 179 | + */ | |
| 180 | +const servers = []; | |
| 181 | +async function startServer(name = "") { | |
| 179 | 182 | const port = await freePort(); |
| 180 | - base = `http://127.0.0.1:${port}`; | |
| 181 | - const persist = join(work, "state"); | |
| 183 | + const url = `http://127.0.0.1:${port}`; | |
| 184 | + const persist = join(work, name ? `state-${name}` : "state"); | |
| 185 | + const logFile = name ? join(work, `wrangler-${name}.log`) : devLogFile; | |
| 186 | + const log = () => (existsSync(logFile) ? readFileSync(logFile, "utf8") : ""); | |
| 182 | 187 | migratedState(APP, persist); |
| 183 | 188 | // Until the web UI exists, run the same app through the API-only entry. |
| 184 | 189 | const entry = existsSync(join(APP, "src/web/routes.tsx")) ? [] : ["test/e2e/entry.ts"]; |
| 185 | - const out = openSync(devLogFile, "a"); | |
| 186 | - dev = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", "SCHEDULE_TICK_MINUTES:1", "--show-interactive-dev-session=false"], { | |
| 190 | + const out = openSync(logFile, "a"); | |
| 191 | + const child = spawn("pnpm", ["exec", "wrangler", "dev", ...entry, "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${url}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", "SCHEDULE_TICK_MINUTES:1", "--show-interactive-dev-session=false"], { | |
| 187 | 192 | cwd: APP, |
| 188 | 193 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| 189 | 194 | stdio: ["ignore", out, out], |
| 190 | 195 | detached: true, |
| 191 | 196 | }); |
| 192 | - await until("wrangler dev", async () => { | |
| 193 | - try { | |
| 194 | - return (await fetch(`${base}/healthz`)).ok; | |
| 195 | - } catch { | |
| 196 | - return false; | |
| 197 | - } | |
| 198 | - }, 120_000); | |
| 197 | + servers.push(child); | |
| 198 | + const end = Date.now() + 120_000; | |
| 199 | + while (!(await fetch(`${url}/healthz`).then((r) => r.ok, () => false))) { | |
| 200 | + if (Date.now() > end) assert.fail(`timed out waiting for wrangler dev${name ? ` (${name})` : ""}\n--- wrangler log tail ---\n${log().slice(-4000)}`); | |
| 201 | + await sleep(500); | |
| 202 | + } | |
| 199 | 203 | // DEV_AUTH signs requests without credentials in as dev@diffinery.local; mint a real token for everything else. |
| 200 | - const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) }); | |
| 204 | + const res = await fetch(`${url}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "e2e", scopes: ["admin"] }) }); | |
| 201 | 205 | assert.equal(res.status, 201, await res.clone().text()); |
| 202 | - token = (await res.json()).token; | |
| 206 | + const stop = () => { | |
| 207 | + try { | |
| 208 | + process.kill(-child.pid, "SIGTERM"); | |
| 209 | + } catch {} | |
| 210 | + }; | |
| 211 | + return { base: url, token: (await res.json()).token, log, stop }; | |
| 212 | +} | |
| 213 | + | |
| 214 | +before(async () => { | |
| 215 | + assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); | |
| 216 | + ({ base, token } = await startServer()); | |
| 203 | 217 | }); |
| 204 | 218 | |
| 205 | 219 | after(() => { |
| 206 | - if (dev?.pid) { | |
| 220 | + for (const dev of servers) { | |
| 207 | 221 | try { |
| 208 | 222 | process.kill(-dev.pid, "SIGTERM"); |
| 209 | 223 | } catch {} |
| @@ -2793,58 +2807,104 @@ | ||
| 2793 | 2807 | }); |
| 2794 | 2808 | |
| 2795 | 2809 | /** |
| 2796 | - * A real project's history, pushed whole to a new repository and cloned back. Pinned commits, so | |
| 2797 | - * the packs are the same each run; cloned from GitHub without the Diffinery token. | |
| 2810 | + * `git` without blocking this process, so the histories below run side by side; it rejects with | |
| 2811 | + * git's output and, on a server error, the tail of `log`. A `tok` of null sends no credentials (for GitHub). | |
| 2798 | 2812 | */ |
| 2799 | -async function pushHistory(name, upstream) { | |
| 2800 | - const me = await ok("GET", "/user"); | |
| 2801 | - await ok("POST", "/user/repos", { name }); | |
| 2802 | - const remote = `${base}/${me.login}/${name}.git`; | |
| 2813 | +function gitAsync(cwd, args, tok = token, log = devLog) { | |
| 2814 | + const auth = tok === null ? [] : ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`x-token:${tok}`).toString("base64")}`]; | |
| 2815 | + return new Promise((done, fail) => { | |
| 2816 | + const child = spawn("git", [...auth, "-c", "protocol.version=2", ...args], { | |
| 2817 | + cwd, | |
| 2818 | + stdio: ["ignore", "pipe", "pipe"], | |
| 2819 | + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" }, | |
| 2820 | + }); | |
| 2821 | + let stdout = ""; | |
| 2822 | + let stderr = ""; | |
| 2823 | + child.stdout.on("data", (d) => (stdout += d)); | |
| 2824 | + child.stderr.on("data", (d) => (stderr += d)); | |
| 2825 | + child.on("error", fail); | |
| 2826 | + child.on("close", (status) => { | |
| 2827 | + if (status === 0) return done(stdout); | |
| 2828 | + let message = `git ${args.join(" ")} exited ${status}\n${stderr}`; | |
| 2829 | + if (/HTTP 5\d\d/.test(stderr)) message += `\n--- wrangler log tail ---\n${log().slice(-4000)}`; | |
| 2830 | + fail(Object.assign(new Error(message), { stdout, stderr })); | |
| 2831 | + }); | |
| 2832 | + }); | |
| 2833 | +} | |
| 2834 | + | |
| 2835 | +/** | |
| 2836 | + * A real project's history, pushed whole to a new repository on `server` and cloned back. Pinned | |
| 2837 | + * commits, so the packs are the same each run; cloned from GitHub without the Diffinery token. | |
| 2838 | + */ | |
| 2839 | +async function pushHistory(server, name, upstream) { | |
| 2840 | + const call = async (method, path, body) => { | |
| 2841 | + const send = () => fetch(`${server.base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${server.token}`, "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 2842 | + // A GET goes again on a kept-alive connection the server closed meanwhile, as in api(). | |
| 2843 | + const r = await send().catch((e) => (method === "GET" ? sleep(1000).then(send) : Promise.reject(e))); | |
| 2844 | + const text = await r.text(); | |
| 2845 | + assert.ok(r.ok, `${method} ${path} -> ${r.status} ${text}`); | |
| 2846 | + return JSON.parse(text); | |
| 2847 | + }; | |
| 2848 | + const g = (cwd, args) => gitAsync(cwd, args, server.token, server.log); | |
| 2849 | + const me = await call("GET", "/user"); | |
| 2850 | + await call("POST", "/user/repos", { name }); | |
| 2851 | + const remote = `${server.base}/${me.login}/${name}.git`; | |
| 2803 | 2852 | // Test fixtures in these histories look like credentials (tornado has a test private key): pushed as an admin's bypass. |
| 2804 | - git(upstream, ["-c", "http.postBuffer=1048576", "push", "-q", "-o", "allow-secrets=e2e of a public history", remote, "main"], token, { maxBuffer: 16 * 1024 * 1024 }); | |
| 2853 | + await g(upstream, ["-c", "http.postBuffer=1048576", "push", "-q", "-o", "allow-secrets=e2e of a public history", remote, "main"]); | |
| 2805 | 2854 | const out = join(work, `${name}-clone`); |
| 2806 | - git(work, ["clone", "-q", "--bare", remote, out]); | |
| 2807 | - assert.equal(git(out, ["rev-parse", "main"]).trim(), git(upstream, ["rev-parse", "main"]).trim()); | |
| 2808 | - assert.equal(git(out, ["rev-list", "--count", "main"]).trim(), git(upstream, ["rev-list", "--count", "main"]).trim()); | |
| 2809 | - git(out, ["fsck", "--full"]); | |
| 2855 | + await g(work, ["clone", "-q", "--bare", remote, out]); | |
| 2856 | + assert.equal((await g(out, ["rev-parse", "main"])).trim(), (await g(upstream, ["rev-parse", "main"])).trim()); | |
| 2857 | + assert.equal((await g(out, ["rev-list", "--count", "main"])).trim(), (await g(upstream, ["rev-list", "--count", "main"])).trim()); | |
| 2858 | + await g(out, ["fsck", "--full"]); | |
| 2810 | 2859 | } |
| 2811 | 2860 | |
| 2812 | -for (const [name, url, sha] of [ | |
| 2813 | - // 37 MB: staged in R2 and unpacked in pieces. | |
| 2814 | - ["jquery", "https://github.com/jquery/jquery", "c2f27864e4ffc2d1409c289fcb9a42b6165dfeaf"], | |
| 2815 | - // About 12 MB each: too many objects for parsing whole in memory, so unpacked in pieces too. | |
| 2816 | - ["tornado", "https://github.com/tornadoweb/tornado", "056cacaefc7de31bda28363378c4ffc86b97d7e4"], | |
| 2817 | - ["flask", "https://github.com/pallets/flask", "d086db856be187255b8ec61ef409357393020f32"], | |
| 2818 | -]) { | |
| 2819 | - test(`real histories: ${name}'s whole history goes in one push and clones back intact`, async () => { | |
| 2820 | - const upstream = join(work, `${name}-upstream.git`); | |
| 2821 | - execFileSync("git", ["clone", "-q", "--bare", "--no-tags", url, upstream], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); | |
| 2822 | - execFileSync("git", ["update-ref", "refs/heads/main", sha], { cwd: upstream }); | |
| 2823 | - await pushHistory(name, upstream); | |
| 2824 | - }); | |
| 2825 | -} | |
| 2826 | - | |
| 2827 | -test("real histories: a 2.6 MB lockfile's 60 versions, a chain of deltas, go in one small push", async () => { | |
| 2828 | - const upstream = join(work, "lockfile-upstream"); | |
| 2829 | - mkdirSync(upstream, { recursive: true }); | |
| 2830 | - git(upstream, ["init", "-q", "-b", "main"]); | |
| 2831 | - const versions = new Map(); | |
| 2832 | - const pkgs = Array.from({ length: 11_000 }, (_, i) => `pkg-${String(i).padStart(5, "0")}`); | |
| 2833 | - pkgs.forEach((p, i) => versions.set(p, `1.${i % 51}.${i % 97}`)); | |
| 2834 | - for (let c = 0; c < 60; c++) { | |
| 2835 | - for (let k = 0; k < 40; k++) versions.set(pkgs[(c * 7919 + k * 104729) % pkgs.length], `2.${c}.${k}`); | |
| 2836 | - const body = pkgs.map((p) => { | |
| 2837 | - const v = versions.get(p); | |
| 2838 | - return ` "node_modules/${p}": {\n "version": "${v}",\n "resolved": "https://registry.npmjs.org/${p}/-/${p}-${v}.tgz",\n "integrity": "sha512-${createHash("sha512").update(p + v).digest("hex").slice(0, 64)}"\n },\n`; | |
| 2861 | +// Side by side, each on a server of its own: one big push keeps one server process busy, so on | |
| 2862 | +// separate processes they use the runner's other cores and take about as long as jquery's alone | |
| 2863 | +// instead of the sum. The lockfile's push uses the shared server. | |
| 2864 | +describe("real histories: whole projects, side by side", { concurrency: true }, () => { | |
| 2865 | + for (const [name, url, sha] of [ | |
| 2866 | + // 37 MB: staged in R2 and unpacked in pieces. | |
| 2867 | + ["jquery", "https://github.com/jquery/jquery", "c2f27864e4ffc2d1409c289fcb9a42b6165dfeaf"], | |
| 2868 | + // About 12 MB each: too many objects for parsing whole in memory, so unpacked in pieces too. | |
| 2869 | + ["tornado", "https://github.com/tornadoweb/tornado", "056cacaefc7de31bda28363378c4ffc86b97d7e4"], | |
| 2870 | + ["flask", "https://github.com/pallets/flask", "d086db856be187255b8ec61ef409357393020f32"], | |
| 2871 | + ]) { | |
| 2872 | + test(`real histories: ${name}'s whole history goes in one push and clones back intact`, async () => { | |
| 2873 | + const upstream = join(work, `${name}-upstream.git`); | |
| 2874 | + const [server] = await Promise.all([ | |
| 2875 | + startServer(name), | |
| 2876 | + gitAsync(work, ["clone", "-q", "--bare", "--no-tags", url, upstream], null).then(() => gitAsync(upstream, ["update-ref", "refs/heads/main", sha], null)), | |
| 2877 | + ]); | |
| 2878 | + try { | |
| 2879 | + await pushHistory(server, name, upstream); | |
| 2880 | + } finally { | |
| 2881 | + server.stop(); | |
| 2882 | + } | |
| 2839 | 2883 | }); |
| 2840 | - writeFileSync(join(upstream, "package-lock.json"), `{\n "lockfileVersion": 3,\n "packages": {\n${body.join("")} }\n}\n`); | |
| 2841 | - git(upstream, ["add", "-A"]); | |
| 2842 | - git(upstream, ["commit", "-q", "-m", `lockfile ${c}`]); | |
| 2843 | 2884 | } |
| 2844 | - assert.ok(readFileSync(join(upstream, "package-lock.json")).length > 2_500_000); | |
| 2845 | - const packBytes = Number(execFileSync("bash", ["-c", "git pack-objects --revs --stdout -q <<<main | wc -c"], { cwd: upstream, encoding: "utf8" }).trim()); | |
| 2846 | - assert.ok(packBytes < 16 * 1024 * 1024, `the pack is ${packBytes} bytes`); | |
| 2847 | - await pushHistory("lockfile", upstream); | |
| 2885 | + | |
| 2886 | + test("real histories: a 2.6 MB lockfile's 60 versions, a chain of deltas, go in one small push", async () => { | |
| 2887 | + const upstream = join(work, "lockfile-upstream"); | |
| 2888 | + mkdirSync(upstream, { recursive: true }); | |
| 2889 | + git(upstream, ["init", "-q", "-b", "main"]); | |
| 2890 | + const versions = new Map(); | |
| 2891 | + const pkgs = Array.from({ length: 11_000 }, (_, i) => `pkg-${String(i).padStart(5, "0")}`); | |
| 2892 | + pkgs.forEach((p, i) => versions.set(p, `1.${i % 51}.${i % 97}`)); | |
| 2893 | + for (let c = 0; c < 60; c++) { | |
| 2894 | + for (let k = 0; k < 40; k++) versions.set(pkgs[(c * 7919 + k * 104729) % pkgs.length], `2.${c}.${k}`); | |
| 2895 | + const body = pkgs.map((p) => { | |
| 2896 | + const v = versions.get(p); | |
| 2897 | + return ` "node_modules/${p}": {\n "version": "${v}",\n "resolved": "https://registry.npmjs.org/${p}/-/${p}-${v}.tgz",\n "integrity": "sha512-${createHash("sha512").update(p + v).digest("hex").slice(0, 64)}"\n },\n`; | |
| 2898 | + }); | |
| 2899 | + writeFileSync(join(upstream, "package-lock.json"), `{\n "lockfileVersion": 3,\n "packages": {\n${body.join("")} }\n}\n`); | |
| 2900 | + git(upstream, ["add", "-A"]); | |
| 2901 | + git(upstream, ["commit", "-q", "-m", `lockfile ${c}`]); | |
| 2902 | + } | |
| 2903 | + assert.ok(readFileSync(join(upstream, "package-lock.json")).length > 2_500_000); | |
| 2904 | + const packBytes = Number(execFileSync("bash", ["-c", "git pack-objects --revs --stdout -q <<<main | wc -c"], { cwd: upstream, encoding: "utf8" }).trim()); | |
| 2905 | + assert.ok(packBytes < 16 * 1024 * 1024, `the pack is ${packBytes} bytes`); | |
| 2906 | + await pushHistory({ base, token, log: devLog }, "lockfile", upstream); | |
| 2907 | + }); | |
| 2848 | 2908 | }); |
| 2849 | 2909 | |
| 2850 | 2910 | test("large files: a 40 MB file is pushed and cloned intact, and push protection still reads big text files", async () => { |