| 1 | // End-to-end GraphQL API (/api/graphql), on a real `wrangler dev` Worker with the real git CLI: |
| 2 | // the queries Claude Code sends to show a session's pull request and its CI (the badge and the |
| 3 | // check counts), the ones the GitHub CLI sends for `gh pr view` and `gh pr checks`, and the pull |
| 4 | // request mutations behind `gh pr create`, `gh pr ready`, `gh pr edit`, `gh pr comment`, |
| 5 | // `gh pr close`, `gh pr reopen` and `gh pr merge`. Also covers sign-in, token scopes, limits and |
| 6 | // GitHub's error shape. |
| 7 | // |
| 8 | // pnpm --filter @diffinery/app exec node --test test/e2e/graphql.test.mjs |
| 9 | import { spawn, execFileSync } from "node:child_process"; |
| 10 | import { createHash, randomBytes } from "node:crypto"; |
| 11 | import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; |
| 12 | import { tmpdir } from "node:os"; |
| 13 | import { join, dirname, resolve } from "node:path"; |
| 14 | import { fileURLToPath } from "node:url"; |
| 15 | import { test, before, after } from "node:test"; |
| 16 | import assert from "node:assert/strict"; |
| 17 | import { migratedState } from "./d1-state.mjs"; |
| 18 | import { freePort } from "./ports.mjs"; |
| 19 | |
| 20 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 21 | const work = mkdtempSync(join(tmpdir(), "diffinery-graphql-e2e-")); |
| 22 | let dev; |
| 23 | let base; |
| 24 | let log = ""; |
| 25 | |
| 26 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 27 | const T = { ann: `dfy_ann_${randomBytes(12).toString("hex")}`, annRead: `dfy_anr_${randomBytes(12).toString("hex")}`, bob: `dfy_bob_${randomBytes(12).toString("hex")}` }; |
| 28 | |
| 29 | before(async () => { |
| 30 | const port = await freePort(); |
| 31 | base = `http://127.0.0.1:${port}`; |
| 32 | const persist = join(work, "state"); |
| 33 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 34 | const at = new Date().toISOString(); |
| 35 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 36 | const person = (id, login) => `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${login}@example.com', 'active', 0, '${at}')`; |
| 37 | const token = (id, account, t, scope) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["${scope}"]', '${later}', '${at}')`; |
| 38 | migratedState(APP, persist); |
| 39 | wrangler("d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", [person("u-ann", "ann"), person("u-bob", "bob"), token("t-ann", "u-ann", T.ann, "admin"), token("t-anr", "u-ann", T.annRead, "read"), token("t-bob", "u-bob", T.bob, "admin")].join("; ")); |
| 40 | // No fixed dev identity (DEV_AUTH empty), so a request without a token is anonymous. |
| 41 | dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], { |
| 42 | cwd: APP, |
| 43 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| 44 | stdio: ["ignore", "pipe", "pipe"], |
| 45 | detached: true, |
| 46 | }); |
| 47 | dev.stdout.on("data", (d) => (log += d)); |
| 48 | dev.stderr.on("data", (d) => (log += d)); |
| 49 | const end = Date.now() + 120_000; |
| 50 | for (;;) { |
| 51 | try { |
| 52 | if ((await fetch(`${base}/healthz`)).ok) break; |
| 53 | } catch {} |
| 54 | if (Date.now() > end) assert.fail(`wrangler dev did not start\n${log.slice(-4000)}`); |
| 55 | await new Promise((r) => setTimeout(r, 500)); |
| 56 | } |
| 57 | }); |
| 58 | |
| 59 | after(() => { |
| 60 | try { |
| 61 | process.kill(-dev.pid, "SIGTERM"); |
| 62 | } catch {} |
| 63 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 64 | }); |
| 65 | |
| 66 | async function rest(method, path, body, tok = T.ann) { |
| 67 | const res = await fetch(`${base}/api/v3${path}`, { method, headers: { authorization: `Bearer ${tok}`, "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined }); |
| 68 | const text = await res.text(); |
| 69 | assert.ok(res.ok, `${method} ${path} -> ${res.status} ${text}`); |
| 70 | return text ? JSON.parse(text) : null; |
| 71 | } |
| 72 | |
| 73 | /** POST /api/graphql; returns { status, body }. */ |
| 74 | async function gql(query, variables = {}, tok = T.ann) { |
| 75 | const res = await fetch(`${base}/api/graphql`, { method: "POST", headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), "content-type": "application/json" }, body: JSON.stringify({ query, variables }) }); |
| 76 | return { status: res.status, body: await res.json() }; |
| 77 | } |
| 78 | |
| 79 | /** The data of a query that must succeed without errors. */ |
| 80 | async function data(query, variables, tok) { |
| 81 | const r = await gql(query, variables, tok); |
| 82 | assert.equal(r.status, 200, JSON.stringify(r.body)); |
| 83 | assert.equal(r.body.errors, undefined, JSON.stringify(r.body.errors)); |
| 84 | return r.body.data; |
| 85 | } |
| 86 | |
| 87 | function git(cwd, args) { |
| 88 | const auth = Buffer.from(`x-token:${T.ann}`).toString("base64"); |
| 89 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "user.name=ann", "-c", "user.email=ann@example.com", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); |
| 90 | } |
| 91 | |
| 92 | // Claude Code's query for the pull requests of its sessions (one alias per repository and pull request). |
| 93 | const CLAUDE_BATCH = `fragment pr on PullRequest { |
| 94 | number title state isDraft additions deletions |
| 95 | reviewDecision |
| 96 | commits(last:1){nodes{commit{statusCheckRollup{ |
| 97 | state |
| 98 | contexts(first:0){ |
| 99 | checkRunCountsByState{state count} |
| 100 | statusContextCountsByState{state count} |
| 101 | } |
| 102 | }}}} |
| 103 | } |
| 104 | query { |
| 105 | rateLimit { cost remaining resetAt } |
| 106 | r0: repository(owner: "ann", name: "app") { p0_0: pullRequest(number: 1) { ...pr } } |
| 107 | }`; |
| 108 | |
| 109 | let headSha; |
| 110 | let repoId; |
| 111 | let prId; |
| 112 | |
| 113 | test("setup: a private repository with a pull request, two check runs and a commit status", async () => { |
| 114 | await rest("POST", "/user/repos", { name: "app", private: true }); |
| 115 | const src = join(work, "app"); |
| 116 | mkdirSync(src); |
| 117 | writeFileSync(join(src, "README.md"), "# app\n"); |
| 118 | git(src, ["init", "-q", "-b", "main"]); |
| 119 | git(src, ["add", "-A"]); |
| 120 | git(src, ["commit", "-q", "-m", "init"]); |
| 121 | git(src, ["push", "-q", `${base}/ann/app.git`, "main"]); |
| 122 | git(src, ["checkout", "-q", "-b", "feature/chip"]); |
| 123 | writeFileSync(join(src, "README.md"), "# app\n\nA second line.\nAnd a third.\n"); |
| 124 | git(src, ["commit", "-q", "-am", "more readme"]); |
| 125 | git(src, ["push", "-q", `${base}/ann/app.git`, "feature/chip"]); |
| 126 | headSha = git(src, ["rev-parse", "HEAD"]).trim(); |
| 127 | |
| 128 | // gh pr create: the repository's id, then createPullRequest. |
| 129 | const d = await data(`query RepositoryInfo($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { id name owner { login } viewerPermission defaultBranchRef { name } squashMergeAllowed } }`, { owner: "ann", name: "app" }); |
| 130 | assert.equal(d.repository.viewerPermission, "ADMIN"); |
| 131 | assert.equal(d.repository.defaultBranchRef.name, "main"); |
| 132 | repoId = d.repository.id; |
| 133 | const created = await data(`mutation PullRequestCreate($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { id url number isDraft } } }`, { |
| 134 | input: { repositoryId: repoId, baseRefName: "main", headRefName: "feature/chip", title: "Longer readme", body: "Two lines.", draft: true, maintainerCanModify: true }, |
| 135 | }); |
| 136 | assert.equal(created.createPullRequest.pullRequest.url, `${base}/ann/app/pull/1`); |
| 137 | assert.equal(created.createPullRequest.pullRequest.isDraft, true); |
| 138 | prId = created.createPullRequest.pullRequest.id; |
| 139 | |
| 140 | await rest("POST", "/repos/ann/app/check-runs", { name: "lint", head_sha: headSha, status: "completed", conclusion: "success" }); |
| 141 | await rest("POST", "/repos/ann/app/check-runs", { name: "tests", head_sha: headSha, status: "in_progress" }); |
| 142 | await rest("POST", `/repos/ann/app/statuses/${headSha}`, { state: "success", context: "deploy/preview" }); |
| 143 | }); |
| 144 | |
| 145 | test("Claude Code: the current branch's pull request and its review decision", async () => { |
| 146 | // Over REST, as Claude Code looks it up for the branch it's on. |
| 147 | const list = await rest("GET", "/repos/ann/app/pulls?head=ann:feature/chip&state=open&per_page=1"); |
| 148 | assert.equal(list.length, 1); |
| 149 | assert.equal(list[0].number, 1); |
| 150 | assert.equal(list[0].html_url, `${base}/ann/app/pull/1`); |
| 151 | const d = await data(`query($o:String!,$r:String!,$n:Int!){repository(owner:$o,name:$r){pullRequest(number:$n){reviewDecision}}}`, { o: "ann", r: "app", n: 1 }); |
| 152 | assert.equal(d.repository.pullRequest.reviewDecision, null); |
| 153 | }); |
| 154 | |
| 155 | test("Claude Code: CI counts and pull request state for the badge", async () => { |
| 156 | const d = await data(CLAUDE_BATCH); |
| 157 | assert.ok(d.rateLimit.remaining > 0); |
| 158 | const p = d.r0.p0_0; |
| 159 | assert.deepEqual({ number: p.number, title: p.title, state: p.state, isDraft: p.isDraft, additions: p.additions, deletions: p.deletions }, { number: 1, title: "Longer readme", state: "OPEN", isDraft: true, additions: 3, deletions: 0 }); |
| 160 | const rollup = p.commits.nodes[0].commit.statusCheckRollup; |
| 161 | assert.equal(rollup.state, "PENDING"); |
| 162 | const counts = (list) => Object.fromEntries(list.map((x) => [x.state, x.count])); |
| 163 | assert.deepEqual(counts(rollup.contexts.checkRunCountsByState), { IN_PROGRESS: 1, SUCCESS: 1 }); |
| 164 | assert.deepEqual(counts(rollup.contexts.statusContextCountsByState), { SUCCESS: 1 }); |
| 165 | }); |
| 166 | |
| 167 | test("gh pr checks and gh pr view: the rollup, node lookup and fields Diffinery doesn't have", async () => { |
| 168 | const d = await data( |
| 169 | `query PullRequestStatusChecks($id: ID!) { node(id: $id) { ...on PullRequest { statusCheckRollup: commits(last: 1) { nodes { commit { oid statusCheckRollup { contexts(first: 100) { nodes { __typename ...on StatusContext { context state targetUrl } ...on CheckRun { name status conclusion workflowName: checkSuite { workflowRun { workflow { name } } } } } pageInfo { hasNextPage endCursor } } } } } } } } }`, |
| 170 | { id: prId }, |
| 171 | ); |
| 172 | const nodes = d.node.statusCheckRollup.nodes[0].commit.statusCheckRollup.contexts.nodes; |
| 173 | assert.equal(d.node.statusCheckRollup.nodes[0].commit.oid, headSha); |
| 174 | assert.deepEqual(nodes.map((n) => n.name ?? n.context).sort(), ["deploy/preview", "lint", "tests"]); |
| 175 | assert.equal(nodes.find((n) => n.name === "tests").status, "IN_PROGRESS"); |
| 176 | assert.equal(nodes.find((n) => n.name === "lint").conclusion, "SUCCESS"); |
| 177 | |
| 178 | // A field GitHub has and Diffinery doesn't is left out and named, so gh carries on. |
| 179 | const r = await gql(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { number mergeStateStatus mergeable notAThing } } }`); |
| 180 | assert.equal(r.body.errors, undefined, JSON.stringify(r.body)); |
| 181 | assert.equal(r.body.data.repository.pullRequest.number, 1); |
| 182 | assert.equal(r.body.data.repository.pullRequest.mergeStateStatus, "DRAFT"); |
| 183 | assert.equal(r.body.data.repository.pullRequest.mergeable, "MERGEABLE"); |
| 184 | assert.ok(r.body.extensions.unsupported.some((m) => m.includes("notAThing"))); |
| 185 | |
| 186 | // gh pr status: pull requests by the viewer, through search. |
| 187 | const s = await data(`query { search(query: "repo:ann/app is:pr is:open author:@me", type: ISSUE, first: 10) { issueCount nodes { ...on PullRequest { number } } } }`); |
| 188 | assert.equal(s.search.issueCount, 1); |
| 189 | }); |
| 190 | |
| 191 | test("gh pr ready, edit, comment, close, reopen and merge", async () => { |
| 192 | const pr = async () => (await data(`query { repository(owner: "ann", name: "app") { pullRequest(number: 1) { title body state isDraft merged labels(first: 10) { nodes { name } } comments(first: 10) { nodes { body } } } } }`)).repository.pullRequest; |
| 193 | await data(`mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { isDraft } } }`, { id: prId }); |
| 194 | assert.equal((await pr()).isDraft, false); |
| 195 | |
| 196 | await rest("POST", "/repos/ann/app/labels", { name: "docs", color: "0075ca" }); |
| 197 | const label = (await data(`query { repository(owner: "ann", name: "app") { labels(first: 10) { nodes { id name } } } }`)).repository.labels.nodes.find((l) => l.name === "docs"); |
| 198 | await data(`mutation($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { pullRequest { title } } }`, { input: { pullRequestId: prId, title: "A longer readme", body: "Three lines." } }); |
| 199 | await data(`mutation($input: AddLabelsToLabelableInput!) { addLabelsToLabelable(input: $input) { clientMutationId } }`, { input: { labelableId: prId, labelIds: [label.id] } }); |
| 200 | await data(`mutation($input: AddCommentInput!) { addComment(input: $input) { commentEdge { node { url } } } }`, { input: { subjectId: prId, body: "Looks right." } }); |
| 201 | let p = await pr(); |
| 202 | assert.equal(p.title, "A longer readme"); |
| 203 | assert.equal(p.body, "Three lines."); |
| 204 | assert.deepEqual(p.labels.nodes.map((l) => l.name), ["docs"]); |
| 205 | assert.deepEqual(p.comments.nodes.map((c) => c.body), ["Looks right."]); |
| 206 | |
| 207 | await data(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }); |
| 208 | assert.equal((await pr()).state, "CLOSED"); |
| 209 | await data(`mutation($id: ID!) { reopenPullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }); |
| 210 | assert.equal((await pr()).state, "OPEN"); |
| 211 | |
| 212 | // A read token can look but not change anything. |
| 213 | const refused = await gql(`mutation($id: ID!) { closePullRequest(input: { pullRequestId: $id }) { pullRequest { state } } }`, { id: prId }, T.annRead); |
| 214 | assert.equal(refused.body.errors?.[0]?.type, "FORBIDDEN", JSON.stringify(refused.body)); |
| 215 | assert.equal((await pr()).state, "OPEN"); |
| 216 | |
| 217 | await data(`mutation($input: MergePullRequestInput!) { mergePullRequest(input: $input) { clientMutationId } }`, { input: { pullRequestId: prId, mergeMethod: "SQUASH" } }); |
| 218 | p = await pr(); |
| 219 | assert.equal(p.state, "MERGED"); |
| 220 | assert.equal(p.merged, true); |
| 221 | }); |
| 222 | |
| 223 | test("sign-in, privacy, limits and GitHub's error shape", async () => { |
| 224 | const anonymous = await gql(`query { viewer { login } }`, {}, null); |
| 225 | assert.equal(anonymous.status, 401); |
| 226 | assert.equal((await data(`query UserCurrent { viewer { login } }`)).viewer.login, "ann"); |
| 227 | |
| 228 | // Someone without access gets GitHub's NOT_FOUND, the same as for a repository that doesn't exist. |
| 229 | for (const [owner, name, tok] of [["ann", "app", T.bob], ["ann", "nope", T.ann]]) { |
| 230 | const r = await gql(`query($o: String!, $n: String!) { repository(owner: $o, name: $n) { id } }`, { o: owner, n: name }, tok); |
| 231 | assert.equal(r.body.data.repository, null); |
| 232 | assert.equal(r.body.errors[0].type, "NOT_FOUND"); |
| 233 | } |
| 234 | |
| 235 | let deep = "id"; |
| 236 | for (let i = 0; i < 20; i++) deep = `parent { ${deep} }`; |
| 237 | const tooDeep = await gql(`query { repository(owner: "ann", name: "app") { ${deep} } }`); |
| 238 | assert.match(tooDeep.body.errors[0].message, /nested/); |
| 239 | |
| 240 | const sub = await gql(`subscription { viewer { login } }`); |
| 241 | assert.match(sub.body.errors[0].message, /Subscriptions/); |
| 242 | |
| 243 | // gh auth login checks the REST root of a GitHub Enterprise host. |
| 244 | const root = await fetch(`${base}/api/v3/`, { headers: { authorization: `Bearer ${T.ann}` } }); |
| 245 | assert.equal(root.status, 200); |
| 246 | assert.equal((await root.json()).graphql, `${base}/api/graphql`); |
| 247 | }); |