| f0b06dea Andy McCall |
1 |
// Against a real hub when the binary exists: create a repository, an |
| 2 |
// issue and a proposal through the client. |
| 3 |
import { test } from "node:test"; |
| 4 |
import assert from "node:assert/strict"; |
| 5 |
import { spawn, execFileSync } from "node:child_process"; |
| 6 |
import { mkdtempSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; |
| 7 |
import { tmpdir } from "node:os"; |
| 8 |
import { join } from "node:path"; |
| 991516a1 Andy McCall ↶ |
9 |
import { HubClient, HubError, encodeDiffQuery, decodeDiffQuery, hubTarget, releaseNotes, looksPrerelease, trustMarkdown } from "../out/hub.mjs"; |
| f0b06dea Andy McCall |
10 |
|
| 11 |
const HUB = join(import.meta.dirname, "../../oxygenhub/target/release/oxygenhub"); |
| 12 |
const OXY = join(import.meta.dirname, "../../oxygen/target/release/oxy"); |
| 13 |
|
| 14 |
test("hub client end to end", { skip: !existsSync(HUB) || !existsSync(OXY) }, async () => { |
| 15 |
const data = mkdtempSync(join(tmpdir(), "hubvs-")); |
| 16 |
const port = 17300 + Math.floor(Math.random() * 1000); |
| 17 |
const base = `http://127.0.0.1:${port}`; |
| 18 |
writeFileSync(join(data, "hub.toml"), `[server]\nbind = "127.0.0.1:${port}"\nbase_url = "${base}"\n[auth]\npush_tokens = ["site"]\n[registration]\nmode = "open"\n`); |
| 19 |
execFileSync(HUB, ["--data", data, "init"], { stdio: "ignore" }); |
| 20 |
execFileSync(HUB, ["--data", data, "admin", "user", "create", "andy", "--admin"], { stdio: "ignore" }); |
| 21 |
const proc = spawn(HUB, ["--data", data, "serve"], { stdio: "ignore" }); |
| 22 |
try { |
| 23 |
for (let i = 0; i < 50; i++) { |
| 24 |
try { |
| 25 |
await fetch(`${base}/health`); |
| 26 |
break; |
| 27 |
} catch { |
| 28 |
await new Promise((r) => setTimeout(r, 100)); |
| 29 |
} |
| 30 |
} |
| 31 |
const client = new HubClient(base, "site"); |
| 32 |
// The client has no createRepository method by design; use fetch directly. |
| 33 |
const res = await fetch(`${base}/api/v1/repos`, { method: "POST", headers: { authorization: "Bearer site", "content-type": "application/json" }, body: JSON.stringify({ owner: "andy", name: "demo" }) }); |
| 34 |
assert.equal(res.status, 201); |
| 35 |
// Push a bookmark with oxy so a proposal has something to add. |
| 36 |
const work = mkdtempSync(join(tmpdir(), "hubvs-work-")); |
| 37 |
const env = { ...process.env, OXY_HOME: mkdtempSync(join(tmpdir(), "hubvs-home-")) }; |
| 38 |
const oxy = (...args) => execFileSync(OXY, ["--plain", ...args], { cwd: work, env, stdio: ["ignore", "pipe", "pipe"] }).toString(); |
| 39 |
oxy("init"); |
| 40 |
oxy("remote", "add", "origin", `${base}/andy/demo`); |
| 41 |
oxy("config", "remote.origin.token", "site"); |
| 42 |
mkdirSync(join(work, "src"), { recursive: true }); |
| 43 |
writeFileSync(join(work, "src/a.txt"), "a\n"); |
| 44 |
oxy("commit", "-m", "base"); |
| 45 |
oxy("push", "origin"); |
| 46 |
oxy("bookmark", "create", "feat"); |
| 47 |
oxy("bookmark", "switch", "feat"); |
| 48 |
writeFileSync(join(work, "src/b.txt"), "b\n"); |
| bf73998c Andy McCall ↶ |
49 |
writeFileSync(join(work, "src/a.txt"), "a\nA\n"); |
| f0b06dea Andy McCall |
50 |
oxy("commit", "-m", "feat"); |
| 51 |
oxy("push", "origin", "--bookmark", "feat"); |
| 52 |
const p = await client.createProposal("andy", "demo", { title: "Feat", target: "main", source: "feat", body: "", draft: false }); |
| 53 |
assert.equal(p.number, 1); |
| 54 |
assert.equal(p.changes.length, 1); |
| bf73998c Andy McCall ↶ |
55 |
// The proposal's files and both sides, as the diff editors fetch them. |
| 56 |
const files = await client.proposalFiles("andy", "demo", 1); |
| 57 |
assert.deepEqual( |
| 58 |
files.map((f) => [f.path, f.kind]), |
| 59 |
[ |
| 60 |
["src/a.txt", "modified"], |
| 61 |
["src/b.txt", "added"], |
| 62 |
], |
| 63 |
); |
| 64 |
assert.equal(await client.proposalContent("andy", "demo", 1, "base", "src/a.txt"), "a\n"); |
| 65 |
assert.equal(await client.proposalContent("andy", "demo", 1, "head", "src/a.txt"), "a\nA\n"); |
| 66 |
assert.equal(await client.proposalContent("andy", "demo", 1, "base", "src/b.txt"), undefined, "absent on the target side"); |
| 67 |
// A review comment from the diff comes back on its line. |
| 68 |
const c = await client.commentProposal("andy", "demo", 1, "why?", "src/a.txt", 2); |
| 69 |
assert.equal(c.line, 2); |
| 70 |
const comments = await client.proposalComments("andy", "demo", 1); |
| 71 |
assert.equal(comments[0].line, 2); |
| f0b06dea Andy McCall |
72 |
const list = await client.proposals("andy", "demo"); |
| 73 |
assert.equal(list[0].title, "Feat"); |
| 74 |
const issue = await client.createIssue("andy", "demo", "Bug", "text"); |
| 75 |
assert.equal(issue.number, 2); |
| 76 |
await client.commentIssue("andy", "demo", 2, "more"); |
| 77 |
const closed = await client.setIssueState("andy", "demo", 2, "closed"); |
| 78 |
assert.equal(closed.state, "closed"); |
| b2dd082c Andy McCall ↶ |
79 |
// The inbox: the site token acts as the administrator, andy. |
| 80 |
const inbox = await client.notifications(); |
| 81 |
assert.equal(typeof inbox.unread, "number"); |
| 82 |
assert.ok(Array.isArray(inbox.items)); |
| 83 |
await client.markAllNotificationsRead(); |
| 84 |
assert.equal((await client.notifications()).unread, 0); |
| 1cc0ff44 Andy McCall ↶ |
85 |
// Releases: a tag pushed with oxy, then released with notes from history. |
| 86 |
oxy("bookmark", "switch", "main"); |
| 87 |
oxy("tag", "create", "v1.0", "-m", "First"); |
| 88 |
oxy("push", "origin"); |
| 89 |
const tags = await client.tags("andy", "demo"); |
| 90 |
assert.deepEqual(tags.map((t) => t.name), ["v1.0"]); |
| 91 |
assert.deepEqual(await client.releases("andy", "demo"), []); |
| 92 |
const hist = await client.history("andy", "demo", "v1.0", 500); |
| 93 |
const rel = await client.createRelease("andy", "demo", { tag: "v1.0", title: "One", notes: releaseNotes(hist.changes, []), prerelease: false }); |
| 94 |
assert.equal(rel.tag, "v1.0"); |
| 95 |
assert.match(rel.notes, /- base \(/); |
| 96 |
const rels = await client.releases("andy", "demo"); |
| 97 |
assert.equal(rels.length, 1); |
| 98 |
assert.equal(rels[0].title, "One"); |
| f0b06dea Andy McCall |
99 |
const me = await client.me(); |
| 100 |
assert.equal(me.username, "andy"); |
| 101 |
await assert.rejects(() => new HubClient(base, "nope").me(), (e) => e instanceof HubError && e.status === 401); |
| 102 |
} finally { |
| 103 |
proc.kill(); |
| 104 |
} |
| 105 |
}); |
| bf73998c Andy McCall ↶ |
106 |
|
| 107 |
test("diff document queries round trip", () => { |
| 108 |
const ref = { base: "https://earth.example:7343", owner: "andy", name: "demo", number: 7, side: "head", path: "src/dir with space/a.txt" }; |
| 109 |
const back = decodeDiffQuery(encodeDiffQuery(ref), "/" + ref.path); |
| 110 |
assert.deepEqual(back, ref); |
| 111 |
assert.equal(decodeDiffQuery("hub=x&owner=a&name=b&number=0&side=head", "/p"), undefined, "number must be positive"); |
| 112 |
assert.equal(decodeDiffQuery("hub=x&owner=a&name=b&number=3&side=left", "/p"), undefined, "side is base or head"); |
| 113 |
assert.equal(decodeDiffQuery("owner=a&name=b&number=3&side=head", "/p"), undefined, "the hub is required"); |
| 114 |
}); |
| b2dd082c Andy McCall ↶ |
115 |
|
| 116 |
test("hub page urls open in the editor when they are proposals or issues", () => { |
| 117 |
assert.deepEqual(hubTarget("https://earth.example:7343/andy/oxygen/proposal/12"), { kind: "proposal", base: "https://earth.example:7343", owner: "andy", name: "oxygen", number: 12 }); |
| 118 |
assert.deepEqual(hubTarget("https://h/andy/demo/issue/3#c7"), { kind: "issue", base: "https://h", owner: "andy", name: "demo", number: 3 }); |
| 119 |
assert.equal(hubTarget("https://h/andy/demo/releases/tag/v1").kind, "other"); |
| 120 |
assert.equal(hubTarget("https://h/andy/demo/proposal/x").kind, "other"); |
| 1cc0ff44 Andy McCall ↶ |
121 |
}); |
| 122 |
|
| 123 |
test("release notes list the changes since the previous release, newest first", () => { |
| 124 |
const c = (hash, message, when_ms) => ({ hash, message, when_ms, author: {}, deps: [], head: false, signature: {} }); |
| 125 |
const old = [c("a".repeat(64), "base", 1)]; |
| 126 |
const now = [...old, c("b".repeat(64), "Add feature\n\nbody", 2), c("c".repeat(64), "Fix bug", 3)]; |
| 127 |
assert.equal(releaseNotes(now, old, "v1.0"), `Changes since v1.0:\n\n- Fix bug (${"c".repeat(12)})\n- Add feature (${"b".repeat(12)})\n`); |
| 128 |
assert.equal(releaseNotes(old, old, "v1.0"), "No changes since the last release.\n"); |
| 129 |
assert.match(releaseNotes(old, []), /^Changes:\n\n- base/); |
| 130 |
}); |
| 131 |
|
| 132 |
test("pre-release tags are recognised", () => { |
| 133 |
for (const t of ["v1.0-rc1", "2.0.0-beta.2", "v3-alpha", "1.0.0-pre", "v2.1b3".replace("b3", "-b3")]) { |
| 134 |
assert.ok(looksPrerelease(t), t); |
| 135 |
} |
| 136 |
for (const t of ["v1.0", "2.0.0", "release-2026", "v1.0-final", "debate"]) { |
| 137 |
assert.ok(!looksPrerelease(t), t); |
| 138 |
} |
| b2dd082c Andy McCall ↶ |
139 |
}); |
| 426ddc50 Andy McCall ↶ |
140 |
|
| 141 |
test("file links: line ranges and state-address permalinks", async () => { |
| 142 |
const { hubUrl } = await import("../out/hub.mjs"); |
| 143 |
const a = "a".repeat(64), b = "b".repeat(64); |
| 144 |
assert.equal( |
| 145 |
hubUrl("https://h:7343", "andy", "oxy", { kind: "file", reference: `${a}+${b}`, path: "src/main.rs", line: 10, endLine: 20 }), |
| 146 |
`https://h:7343/andy/oxy/file/${a}+${b}/src/main.rs#L10-L20`, |
| 147 |
); |
| 148 |
assert.equal(hubUrl("https://h", "a", "r", { kind: "file", reference: "main", path: "x.rs", line: 3, endLine: 3 }), "https://h/a/r/file/main/x.rs#L3"); |
| 149 |
assert.equal(hubUrl("https://h", "a", "r", { kind: "file", reference: "feature/x", path: "x.rs" }), "https://h/a/r/file/feature%2Fx/x.rs"); |
| 150 |
}); |
| 991516a1 Andy McCall ↶ |
151 |
|
| 152 |
/** A fetch that records each request and answers from `reply`. */ |
| 153 |
function recorder(reply = () => ({})) { |
| 154 |
const calls = []; |
| 155 |
const f = async (url, init = {}) => { |
| 156 |
const body = init.body ? JSON.parse(init.body) : undefined; |
| 157 |
calls.push({ method: init.method ?? "GET", path: url.replace("https://hub/api/v1", ""), body, auth: new Headers(init.headers).get("authorization") }); |
| 158 |
const out = reply(init.method ?? "GET", url); |
| 159 |
return out === null ? new Response(null, { status: 204 }) : new Response(JSON.stringify(out), { status: 200 }); |
| 160 |
}; |
| 161 |
return { calls, f }; |
| 162 |
} |
| 163 |
|
| 164 |
test("drops, Flow decisions, drafts and starters go where the hub serves them", async () => { |
| 165 |
const { calls, f } = recorder((m) => (m === "DELETE" ? null : {})); |
| 166 |
const c = new HubClient("https://hub", "tok", f); |
| 167 |
const id = "0123456789abcdef01234567"; |
| 168 |
await c.drops(); |
| 169 |
await c.userDrops("sky blue"); |
| 170 |
await c.drop(id); |
| 171 |
await c.createDrop({ title: "t", secret: true, files: [{ name: "a.md", content: "x" }] }); |
| 172 |
await c.editDrop(id, { files: [{ name: "a.md", content: null }], message: "Remove a.md" }); |
| 173 |
await c.deleteDrop(id); |
| 174 |
await c.forkDrop(id); |
| 175 |
await c.starDrop(id, true); |
| 176 |
await c.starDrop(id, false); |
| 177 |
await c.dropComments(id); |
| 178 |
await c.commentDrop(id, "hi"); |
| 179 |
await c.deleteDropComment(id, 3); |
| 180 |
await c.decideFlowJob("o", "n", 7, "deploy prod", true); |
| 181 |
await c.decideFlowJob("o", "n", 7, "deploy", false); |
| 182 |
await c.expediteFlowRun("o", "n", 7, "outage"); |
| 183 |
await c.jobReports("o", "n", 7, "bench"); |
| 184 |
await c.testReports("o", "n", "main", 1); |
| 185 |
await c.testReport("o", "n", 4); |
| 186 |
await c.setProposalDraft("o", "n", 2, false); |
| 187 |
await c.userProposals(true); |
| 188 |
await c.releaseTrust("o", "n", "v1.0"); |
| 189 |
await c.createRepository({ owner: "o", name: "n2" }); |
| 190 |
await c.starterAssistants("my org"); |
| 191 |
await c.startStarter("o", "n2", { description: "a CLI" }); |
| 192 |
assert.deepEqual( |
| 193 |
calls.map((x) => `${x.method} ${x.path}`), |
| 194 |
[ |
| 195 |
"GET /drops", |
| 196 |
"GET /users/sky%20blue/drops", |
| 197 |
`GET /drops/${id}`, |
| 198 |
"POST /drops", |
| 199 |
`PATCH /drops/${id}`, |
| 200 |
`DELETE /drops/${id}`, |
| 201 |
`POST /drops/${id}/fork`, |
| 202 |
`PUT /drops/${id}/star`, |
| 203 |
`DELETE /drops/${id}/star`, |
| 204 |
`GET /drops/${id}/comments`, |
| 205 |
`POST /drops/${id}/comments`, |
| 206 |
`DELETE /drops/${id}/comments/3`, |
| 207 |
"POST /repos/o/n/flow/runs/7/jobs/deploy%20prod/approve", |
| 208 |
"POST /repos/o/n/flow/runs/7/jobs/deploy/reject", |
| 209 |
"POST /repos/o/n/flow/runs/7/expedite", |
| 210 |
"GET /repos/o/n/flow/runs/7/jobs/bench/reports", |
| 211 |
"GET /repos/o/n/tests?limit=1&bookmark=main", |
| 212 |
"GET /repos/o/n/tests/4", |
| 213 |
"PATCH /repos/o/n/proposals/2", |
| 214 |
"GET /user/proposals?landable=true", |
| 215 |
"GET /repos/o/n/releases/trust/v1.0", |
| 216 |
"POST /repos", |
| 217 |
"GET /user/starter?owner=my%20org", |
| 218 |
"POST /repos/o/n2/starter", |
| 219 |
], |
| 220 |
); |
| 221 |
assert.deepEqual(calls[4].body, { files: [{ name: "a.md", content: null }], message: "Remove a.md" }, "a null content removes a file"); |
| 222 |
assert.deepEqual(calls[10].body, { body: "hi" }); |
| 223 |
assert.deepEqual(calls[14].body, { reason: "outage" }); |
| 224 |
assert.deepEqual(calls[18].body, { draft: false }); |
| 225 |
assert.ok(calls.every((x) => x.auth === "Bearer tok")); |
| 226 |
}); |
| 227 |
|
| 228 |
test("downloads carry the token only to the hub itself", async () => { |
| 229 |
const seen = []; |
| 230 |
const f = async (url, init = {}) => (seen.push([url, new Headers(init.headers).get("authorization")]), new Response(new Uint8Array([1, 2, 3]))); |
| 231 |
const c = new HubClient("https://hub", "tok", f); |
| 232 |
assert.deepEqual([...(await c.download("https://hub/o/n/releases/download/v1/a.tar.gz"))], [1, 2, 3]); |
| 233 |
await c.download("https://elsewhere.example/a.tar.gz"); |
| 234 |
assert.deepEqual(seen, [ |
| 235 |
["https://hub/o/n/releases/download/v1/a.tar.gz", "Bearer tok"], |
| 236 |
["https://elsewhere.example/a.tar.gz", null], |
| 237 |
]); |
| 238 |
}); |
| 239 |
|
| 240 |
test("a release's trust report reads as Markdown", () => { |
| 241 |
const md = trustMarkdown({ |
| 242 |
repository: "andy/oxygen", tag: "v1.0", title: "One", release_url: "https://hub/andy/oxygen/releases/v1.0", heads: ["a".repeat(64)], |
| 243 |
tag_signer: { key: "k", name: "Andy", email: "a@x" }, tag_signature: "valid", since: "v0.9", changes: 3, |
| 244 |
authors: [{ who: { name: "Andy", email: "a@x", account: "andy" }, changes: 3, signed: 2, sealed: 1, unsigned: 0, revoked: 0, invalid: 0 }], |
| 245 |
assets: [{ name: "oxy.tar.gz", size: 9, blake3: "b".repeat(64), url: "", build: { run: 5, job: "dist", run_url: "https://hub/r/5", same_state_as_tag: true } }], |
| 246 |
audit: [{ verdict: "pass", title: "Tag signed", detail: "" }, { verdict: "warn", title: "One author", detail: "a bus factor of one" }], |
| 247 |
generated_ms: 0, |
| 248 |
}); |
| 249 |
assert.match(md, /^# Trust: One$/m); |
| 250 |
assert.match(md, /Tag signature: \*\*valid\*\* by Andy <a@x>/); |
| 251 |
assert.match(md, /3 changes since v0.9/); |
| 252 |
assert.match(md, /^- ✔ \*\*Tag signed\*\*$/m); |
| 253 |
assert.match(md, /^- ! \*\*One author\*\* — a bus factor of one$/m); |
| 254 |
assert.match(md, /\| andy \| 3 \| 2 \| 1 \| 0 \| 0 \| 0 \|/); |
| 255 |
assert.match(md, /`oxy.tar.gz` · BLAKE3 `bbbbbbbbbbbbbbbb…` · built by \[run #5\]\(https:\/\/hub\/r\/5\) dist$/m); |
| 256 |
}); |