also looking at this
feat(reviews): the suggestions get an interface, priced as designed
#6
9 files
+741
-10
| @@ -0,0 +1,313 @@ | ||
| 1 | // The suggested-reviewers panel, through the real routes, with the credential | |
| 2 | // a browser actually carries. | |
| 3 | // | |
| 4 | // The ranking is unit tested and the git-to-forge joins are covered by | |
| 5 | // suggest-reviewers.test.ts, which calls the action in process. What neither | |
| 6 | // can see is the surface this file pins: the endpoint answering a fetch whose | |
| 7 | // only credential is a cookie (docs/todo's "a signed-in browser is not a | |
| 8 | // signed-in test client" - every other caller of this endpoint holds a | |
| 9 | // bearer), and the page offering the panel without paying for it. | |
| 10 | // | |
| 11 | // The panel's design constraint is that the `git log` is spent when the reader | |
| 12 | // opens it, never at render. No browser here runs the script, so that is | |
| 13 | // asserted the only way markup can assert it: the rendered page carries the | |
| 14 | // panel and the URL it would ask, and none of the answer. | |
| 15 | ||
| 16 | import { afterAll, beforeAll, describe, expect, test } from 'bun:test' | |
| 17 | import { mkdirSync, mkdtempSync, rmdirSync, rmSync, writeFileSync } from 'node:fs' | |
| 18 | import { tmpdir } from 'node:os' | |
| 19 | import { join, resolve } from 'node:path' | |
| 20 | import process from 'node:process' | |
| 21 | ||
| 22 | const created = { | |
| 23 | authorId: 0, | |
| 24 | expertId: 0, | |
| 25 | repositoryId: 0, | |
| 26 | handle: '', | |
| 27 | expertHandle: '', | |
| 28 | authorToken: '', | |
| 29 | name: '', | |
| 30 | diskPath: '', | |
| 31 | temp: '', | |
| 32 | } | |
| 33 | ||
| 34 | let available = false | |
| 35 | let port = 0 | |
| 36 | let server: any = null | |
| 37 | ||
| 38 | function unique(prefix: string): string { | |
| 39 | return `${prefix}${Buffer.from(crypto.getRandomValues(new Uint8Array(5))).toString('hex')}` | |
| 40 | } | |
| 41 | ||
| 42 | /** Commit as a specific person, which is what the suggester reads. */ | |
| 43 | async function git(cwd: string, as: { name: string, email: string } | null, ...args: string[]): Promise<string> { | |
| 44 | const child = Bun.spawn(['git', ...args], { | |
| 45 | cwd, | |
| 46 | stdout: 'pipe', | |
| 47 | stderr: 'pipe', | |
| 48 | env: { | |
| 49 | ...process.env, | |
| 50 | GIT_AUTHOR_NAME: as?.name ?? 'E2E', | |
| 51 | GIT_AUTHOR_EMAIL: as?.email ?? 'e2e@example.com', | |
| 52 | GIT_COMMITTER_NAME: 'E2E', | |
| 53 | GIT_COMMITTER_EMAIL: 'e2e@example.com', | |
| 54 | GIT_TERMINAL_PROMPT: '0', | |
| 55 | }, | |
| 56 | }) | |
| 57 | ||
| 58 | const [stdout, stderr, code] = await Promise.all([ | |
| 59 | new Response(child.stdout).text(), | |
| 60 | new Response(child.stderr).text(), | |
| 61 | child.exited, | |
| 62 | ]) | |
| 63 | ||
| 64 | if (code !== 0) | |
| 65 | throw new Error(`git ${args.join(' ')} exited ${code}: ${stderr.trim()}`) | |
| 66 | ||
| 67 | return stdout.trim() | |
| 68 | } | |
| 69 | ||
| 70 | /** The read a browser makes: a cookie, and nothing else. */ | |
| 71 | async function fetchPage(path: string, cookieToken?: string): Promise<{ status: number, html: string }> { | |
| 72 | const answer = await fetch(`http://127.0.0.1:${port}${path}`, { | |
| 73 | headers: { | |
| 74 | Accept: 'text/html', | |
| 75 | ...(cookieToken ? { Cookie: `auth-token=${cookieToken}` } : {}), | |
| 76 | }, | |
| 77 | }) | |
| 78 | ||
| 79 | return { status: answer.status, html: await answer.text() } | |
| 80 | } | |
| 81 | ||
| 82 | beforeAll(async () => { | |
| 83 | created.temp = mkdtempSync(join(tmpdir(), 'reviewos-suggest-panel-')) | |
| 84 | ||
| 85 | try { | |
| 86 | const { injectGlobalAutoImports } = await import('@stacksjs/server') | |
| 87 | const { route } = await import('@stacksjs/router') | |
| 88 | ||
| 89 | await injectGlobalAutoImports() | |
| 90 | await (globalThis as any).db.selectFrom('users').select(['id']).limit(1).execute() | |
| 91 | ||
| 92 | await route.importRoutes() | |
| 93 | server = await route.serve({ port: 0, hostname: '127.0.0.1' }) | |
| 94 | port = Number((server as any)?.port ?? (server as any)?.server?.port ?? 0) | |
| 95 | ||
| 96 | if (!port) | |
| 97 | throw new Error('the router did not report a port') | |
| 98 | ||
| 99 | const { repositoryPath } = await import('../../app/Actions/Git/storage') | |
| 100 | const { initBare } = await import('../../app/Actions/Git/git') | |
| 101 | const { createToken } = await import('@stacksjs/auth') | |
| 102 | ||
| 103 | const make = async (prefix: string): Promise<{ id: number, handle: string, email: string }> => { | |
| 104 | const handle = unique(prefix) | |
| 105 | const email = `${handle}@example.com` | |
| 106 | const row: any = await (globalThis as any).db | |
| 107 | .insertInto('users') | |
| 108 | .values({ name: 'Panel Tester', email, handle, password: 'x' }) | |
| 109 | .returning(['id']) | |
| 110 | .executeTakeFirst() | |
| 111 | ||
| 112 | return { id: Number(row?.id), handle, email } | |
| 113 | } | |
| 114 | ||
| 115 | const author = await make('spa') | |
| 116 | const expert = await make('spe') | |
| 117 | ||
| 118 | created.authorId = author.id | |
| 119 | created.handle = author.handle | |
| 120 | created.expertId = expert.id | |
| 121 | created.expertHandle = expert.handle | |
| 122 | ||
| 123 | const issued: any = await createToken(author.id, 'suggested reviewers panel test') | |
| 124 | created.authorToken = String(issued?.plainTextToken ?? issued?.token ?? issued) | |
| 125 | ||
| 126 | created.name = unique('repo') | |
| 127 | const resolvedPath = repositoryPath(created.handle, created.name) | |
| 128 | created.diskPath = resolvedPath.path! | |
| 129 | ||
| 130 | const repository: any = await (globalThis as any).db | |
| 131 | .insertInto('repositories') | |
| 132 | .values({ | |
| 133 | owner_type: 'user', | |
| 134 | owner_id: created.authorId, | |
| 135 | name: created.name, | |
| 136 | description: 'created by the suggested reviewers panel end to end test', | |
| 137 | visibility: 'public', | |
| 138 | default_branch: 'main', | |
| 139 | disk_path: resolvedPath.relative!, | |
| 140 | }) | |
| 141 | .returning(['id']) | |
| 142 | .executeTakeFirst() | |
| 143 | ||
| 144 | created.repositoryId = Number(repository?.id) | |
| 145 | ||
| 146 | mkdirSync(resolve(created.diskPath, '..'), { recursive: true }) | |
| 147 | await initBare(created.diskPath, 'main') | |
| 148 | ||
| 149 | const work = join(created.temp, 'seed') | |
| 150 | mkdirSync(work) | |
| 151 | await git(work, null, 'init', '--initial-branch=main') | |
| 152 | ||
| 153 | writeFileSync(join(work, 'touched.ts'), 'export const a = 0\n') | |
| 154 | await git(work, null, 'add', '.') | |
| 155 | await git(work, null, 'commit', '-m', 'the base') | |
| 156 | ||
| 157 | // The expert's history is what the endpoint will name, when asked. | |
| 158 | for (let round = 1; round <= 3; round += 1) { | |
| 159 | writeFileSync(join(work, 'touched.ts'), `export const a = ${round}\n`) | |
| 160 | await git(work, expert, 'add', '.') | |
| 161 | await git(work, expert, 'commit', '-m', `expert round ${round}`) | |
| 162 | } | |
| 163 | ||
| 164 | await git(work, null, 'push', created.diskPath, 'main') | |
| 165 | const baseSha = await git(work, null, 'rev-parse', 'HEAD') | |
| 166 | ||
| 167 | await git(work, null, 'checkout', '-b', 'change') | |
| 168 | writeFileSync(join(work, 'touched.ts'), 'export const a = 99\n') | |
| 169 | await git(work, { name: 'Author', email: `${created.handle}@example.com` }, 'add', '.') | |
| 170 | await git(work, { name: 'Author', email: `${created.handle}@example.com` }, 'commit', '-m', 'change the touched file') | |
| 171 | const headSha = await git(work, null, 'rev-parse', 'HEAD') | |
| 172 | await git(work, null, 'push', created.diskPath, 'change') | |
| 173 | ||
| 174 | await (globalThis as any).db | |
| 175 | .insertInto('pull_requests') | |
| 176 | .values({ | |
| 177 | repository_id: created.repositoryId, | |
| 178 | number: 1, | |
| 179 | title: 'Change the touched file', | |
| 180 | body: 'Opened by the suggested reviewers panel end to end test.', | |
| 181 | author_id: created.authorId, | |
| 182 | state: 'open', | |
| 183 | head_branch: 'change', | |
| 184 | head_sha: headSha, | |
| 185 | base_branch: 'main', | |
| 186 | base_sha: baseSha, | |
| 187 | draft: false, | |
| 188 | additions: 1, | |
| 189 | deletions: 1, | |
| 190 | changed_files: 1, | |
| 191 | }) | |
| 192 | .execute() | |
| 193 | ||
| 194 | available = true | |
| 195 | } | |
| 196 | catch (error) { | |
| 197 | console.warn(`[e2e] skipping: ${error instanceof Error ? error.message : String(error)}`) | |
| 198 | available = false | |
| 199 | } | |
| 200 | }, 120_000) | |
| 201 | ||
| 202 | afterAll(async () => { | |
| 203 | try { | |
| 204 | if (created.repositoryId) | |
| 205 | await (globalThis as any).db.deleteFrom('repositories').where('id', '=', created.repositoryId).execute() | |
| 206 | ||
| 207 | for (const id of [created.authorId, created.expertId]) { | |
| 208 | if (id) | |
| 209 | await (globalThis as any).db.deleteFrom('users').where('id', '=', id).execute() | |
| 210 | } | |
| 211 | } | |
| 212 | catch { /* the temp files still go, below */ } | |
| 213 | ||
| 214 | if (created.diskPath) { | |
| 215 | rmSync(created.diskPath, { recursive: true, force: true }) | |
| 216 | ||
| 217 | try { | |
| 218 | rmdirSync(resolve(created.diskPath, '..')) | |
| 219 | } | |
| 220 | catch { /* somebody else's repository lives there too */ } | |
| 221 | } | |
| 222 | ||
| 223 | if (created.temp) | |
| 224 | rmSync(created.temp, { recursive: true, force: true }) | |
| 225 | ||
| 226 | try { | |
| 227 | server?.stop?.(true) | |
| 228 | } | |
| 229 | catch { /* already down */ } | |
| 230 | }) | |
| 231 | ||
| 232 | describe('the suggested reviewers panel', () => { | |
| 233 | /** | |
| 234 | * The fetch the panel's script makes, with the credential a browser holds. | |
| 235 | * Every other caller of this endpoint in the suite authenticates with a | |
| 236 | * bearer, and a bearer works whether or not the cookie path does. | |
| 237 | */ | |
| 238 | test('the endpoint answers a cookie-only fetch with the expert and the why', async () => { | |
| 239 | if (!available) | |
| 240 | return | |
| 241 | ||
| 242 | const answer = await fetch( | |
| 243 | `http://127.0.0.1:${port}/api/repos/pulls/suggested-reviewers?owner=${created.handle}&repo=${created.name}&number=1`, | |
| 244 | { headers: { Accept: 'application/json', Cookie: `auth-token=${created.authorToken}` } }, | |
| 245 | ) | |
| 246 | ||
| 247 | expect(answer.status).toBe(200) | |
| 248 | ||
| 249 | const body: any = await answer.json() | |
| 250 | const suggestion = (body?.suggestions ?? []).find((entry: any) => entry.handle === created.expertHandle) | |
| 251 | ||
| 252 | expect(suggestion).toBeDefined() | |
| 253 | // The stable prefix only: the "last Nd ago" half reads the clock. | |
| 254 | expect(String(suggestion.reason)).toContain('3 commits here') | |
| 255 | }, 30_000) | |
| 256 | ||
| 257 | test('the page offers the panel to a signed-in reviewer, without paying for it', async () => { | |
| 258 | if (!available) | |
| 259 | return | |
| 260 | ||
| 261 | const { status, html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken) | |
| 262 | ||
| 263 | expect(status).toBe(200) | |
| 264 | expect(html).toContain('data-suggested-reviewers') | |
| 265 | ||
| 266 | // The URL the script would ask, addressed by the server so the client does | |
| 267 | // not know how a pull request is named. | |
| 268 | expect(html).toContain('/api/repos/pulls/suggested-reviewers?owner=') | |
| 269 | expect(html).toContain(`number=1`) | |
| 270 | ||
| 271 | // And none of the answer. The expert's handle appearing here would mean | |
| 272 | // the git log ran at render time, which is the cost the endpoint exists | |
| 273 | // to defer. | |
| 274 | expect(html).not.toContain(created.expertHandle) | |
| 275 | }, 30_000) | |
| 276 | ||
| 277 | /** | |
| 278 | * The reader's credential stays out of the document. stx's client bridge | |
| 279 | * seeds any identifier a client script shares with the server scope into | |
| 280 | * the page, and the server scope holds the request headers - so a script | |
| 281 | * that so much as says `headers` serializes the session cookie into the | |
| 282 | * HTML. That shipped, briefly, as `fetch(url, { headers: ... })`; this is | |
| 283 | * the assertion that keeps it shipped out. | |
| 284 | */ | |
| 285 | test('the page never carries the session token that fetched it', async () => { | |
| 286 | if (!available) | |
| 287 | return | |
| 288 | ||
| 289 | const { html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken) | |
| 290 | ||
| 291 | expect(html).not.toContain(created.authorToken) | |
| 292 | expect(html).not.toContain('auth-token') | |
| 293 | }, 30_000) | |
| 294 | ||
| 295 | test('the closed panel already says what it is, for readers without scripts', async () => { | |
| 296 | if (!available) | |
| 297 | return | |
| 298 | ||
| 299 | const { html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`, created.authorToken) | |
| 300 | ||
| 301 | expect(html).toContain('Who has worked on these files.') | |
| 302 | }, 30_000) | |
| 303 | ||
| 304 | test('a reader with no session is not offered a panel they cannot use', async () => { | |
| 305 | if (!available) | |
| 306 | return | |
| 307 | ||
| 308 | const { status, html } = await fetchPage(`/${created.handle}/${created.name}/pull/1`) | |
| 309 | ||
| 310 | expect(status).toBe(200) | |
| 311 | expect(html).not.toContain('data-suggested-reviewers') | |
| 312 | }, 30_000) | |
| 313 | }) | |