lambda.test.ts
7.2 kB · typescript · 166 lines
1import { expect, test } from "bun:test";2import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";3import { tmpdir } from "node:os";4import { join } from "node:path";5import { lambda, modules, type Get, type Head, type Wake } from "./lambda.ts";67/* FIXTURES */89const SHA = "0123456789abcdef0123456789abcdef01234567";10const SHELF = "fedcba9876543210fedcba9876543210fedcba98";11const HOLD = mkdtempSync(join(tmpdir(), "head-"));1213process.env.SHELF_REPO = "owner/shelf";1415const had = process.env.DRY_DIR;16delete process.env.DRY_DIR;1718const net = lambda({19 source: "owner/net",20 head: "build/net/head",21 dir: "/tmp/net",22 agent: "net-builder",23 bucket: "NET_BUCKET",24 folder: "site",25 shelf: "SHELF_REPO",26 sources: ["push", "schedule", "automator", "manual"],27 local: join(HOLD, "net"),28});2930const shop = lambda({31 source: "owner/shop",32 head: "data/build/head",33 dir: "/tmp/shop",34 agent: "shop-builder",35 bucket: "SHOP_BUCKET",36 folder: "site",37 local: join(HOLD, "shop"),38 hash: () => "d0",39});4041if (had) process.env.DRY_DIR = had;4243const stored: Head = { sha: "aaaaaaabbbbbbbcccccccdddddddeeeeeeefffff", etag: "old", shelf: SHELF, shelfEtag: "shelf-old", data: "" };4445const reply = (body: unknown, opts: { ok?: boolean; status?: number; etag?: string } = {}) => ({46 ok: opts.ok ?? true,47 status: opts.status ?? 200,48 json: async () => body,49 text: async () => JSON.stringify(body),50 headers: { get: (name: string) => (name === "etag" ? (opts.etag ?? null) : null) },51});5253const github = (plan: { compare?: string; code?: number; poll?: number; sha?: string; etag?: string }): Get => async (url) => {54 if (url.includes("/compare/")) return plan.compare ? reply({ status: plan.compare }) : reply({}, { ok: false, status: plan.code ?? 404 });55 if (plan.poll === 304) return reply({}, { ok: false, status: 304 });56 return reply({ sha: plan.sha }, { etag: plan.etag });57};5859const wake = (on: Wake["on"], sha: string): Wake => ({ source: "push", on, sha });6061/* EVENT */6263test("the payload parser reads a schedule tick and both push wakes", () => {64 expect(net.readEvent(JSON.stringify({ source: "schedule" }))).toEqual({ source: "schedule", on: "", sha: "" });65 expect(net.readEvent(JSON.stringify({ source: "push", repo: "owner/net", sha: SHA }))).toEqual({ source: "push", on: "source", sha: SHA });66 expect(net.readEvent(JSON.stringify({ source: "push", repo: "owner/shelf", sha: SHA }))).toEqual({ source: "push", on: "shelf", sha: SHA });67});6869test("the payload parser survives junk, an unknown source and a source word the site does not take", () => {70 expect(net.readEvent("")).toEqual({ source: "", on: "", sha: "" });71 expect(net.readEvent("not json")).toEqual({ source: "", on: "", sha: "" });72 expect(net.readEvent(JSON.stringify({ source: "nonsense", repo: "owner/net" }))).toEqual({ source: "", on: "source", sha: "" });73 expect(shop.readEvent(JSON.stringify({ source: "automator", repo: "owner/shop" }))).toEqual({ source: "", on: "source", sha: "" });74});7576test("the payload parser keeps a good sha, lowercases it and drops a malformed one", () => {77 const pin = (sha: string) => net.readEvent(JSON.stringify({ source: "manual", repo: "owner/net", sha })).sha;78 expect(pin(SHA)).toBe(SHA);79 expect(pin(SHA.toUpperCase())).toBe(SHA);80 expect(pin("nope")).toBe("");81 expect(pin("../../x/y/tar.gz/main")).toBe("");82 expect(pin(`${SHA}0`)).toBe("");83});8485test("a sha with no repo, or another repo's, names no target", () => {86 expect(net.readEvent(JSON.stringify({ body: JSON.stringify({ source: "manual", sha: SHA }) }))).toEqual({ source: "manual", on: "", sha: SHA });87 expect(net.readEvent(JSON.stringify({ source: "push", repo: "owner/fork", sha: SHA }))).toEqual({ source: "push", on: "", sha: SHA });88});8990/* GITHUB */9192test("the ancestor check accepts a sha main is ahead of or identical to", async () => {93 expect(await net.onMain("owner/net", SHA, github({ compare: "ahead" }))).toBe(true);94 expect(await net.onMain("owner/shelf", SHA, github({ compare: "identical" }))).toBe(true);95});9697test("the ancestor check refuses another status, a refused compare and a dead fetch", async () => {98 expect(await net.onMain("owner/net", SHA, github({ compare: "behind" }))).toBe(false);99 expect(await net.onMain("owner/net", SHA, github({ compare: "diverged" }))).toBe(false);100 expect(await net.onMain("owner/net", SHA, github({ code: 403 }))).toBe(false);101 expect(102 await net.onMain("owner/net", SHA, async () => {103 throw new Error("getaddrinfo ENOTFOUND api.github.com");104 }),105 ).toBe(false);106});107108/* PLAN */109110test("a wake for a sha already built is a no-op, and an unfinished head never answers seen", () => {111 const done: Head = { ...stored, sha: SHA };112 expect(net.seen(wake("source", SHA), done)).toBe(SHA.slice(0, 7));113 expect(net.seen(wake("shelf", SHELF), done)).toBe(`shelf ${SHELF.slice(0, 7)}`);114 expect(net.seen(wake("source", SHA), { ...done, shelf: "" })).toBe("");115 expect(net.seen(wake("source", SHA), { ...done, sha: "" })).toBe("");116});117118test("a site with a data hash never answers seen, because the data moves without the sha", () => {119 expect(shop.seen(wake("source", SHA), { ...stored, sha: SHA, data: "d0" })).toBe("");120});121122test("a sha wake stores an empty etag", async () => {123 const out = await net.freshen(wake("source", SHA), stored, github({ compare: "ahead", sha: "polled", etag: "fresh" }));124 expect(out.sha).toBe(SHA);125 expect(out.etag).toBe("");126});127128test("a sha with no repo beside it falls to the etag poll", async () => {129 const out = await net.freshen(wake("", SHA), stored, github({ compare: "ahead", sha: "polled", etag: "fresh" }));130 expect(out.sha).toBe("polled");131 expect(out.etag).toBe("fresh");132});133134test("a refused compare falls to the etag poll", async () => {135 const out = await net.freshen(wake("source", SHA), stored, github({ code: 403, sha: "polled", etag: "fresh" }));136 expect(out.sha).toBe("polled");137});138139test("a 304 from the etag poll keeps the stored mark", async () => {140 const out = await net.freshen({ source: "schedule", on: "", sha: "" }, stored, github({ poll: 304 }));141 expect(out).toEqual(stored);142});143144/* HEAD */145146test("the head file carries only the lines the site uses", async () => {147 await net.writeHead(null, { sha: SHA, etag: "e", shelf: SHELF, shelfEtag: "se", data: "ignored" });148 expect(readFileSync(join(HOLD, "net", "head"), "utf8")).toBe(`${SHA}\ne\n${SHELF}\nse\n`);149 expect(await net.readHead(null)).toEqual({ sha: SHA, etag: "e", shelf: SHELF, shelfEtag: "se", data: "" });150 await shop.writeHead(null, { sha: SHA, etag: "e", shelf: "no", shelfEtag: "no", data: "d0" });151 expect(readFileSync(join(HOLD, "shop", "head"), "utf8")).toBe(`${SHA}\ne\nd0\n`);152 expect(await shop.readHead(null)).toEqual({ sha: SHA, etag: "e", shelf: "", shelfEtag: "", data: "d0" });153});154155/* MODULES */156157test("the modules step takes the layer only when it holds this lockfile", () => {158 const site = mkdtempSync(join(tmpdir(), "site-"));159 const layer = mkdtempSync(join(tmpdir(), "layer-"));160 writeFileSync(join(site, "bun.lock"), "lock");161 expect(modules(site, layer)).toBe("absent");162 writeFileSync(join(layer, "bun.lock.sha256"), "nope\n");163 expect(modules(site, layer)).toBe("stale");164 writeFileSync(join(layer, "bun.lock.sha256"), `${new Bun.CryptoHasher("sha256").update("lock").digest("hex")}\n`);165 expect(modules(site, layer)).toBe("layer");166});