github-ready-review.e2e.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. /** Keyless assembled-Web evidence for GitHub ready-for-review Session creation. */
  2. import { createHmac } from 'node:crypto'
  3. import { createServer } from 'node:http'
  4. import type { AddressInfo } from 'node:net'
  5. import { fileURLToPath } from 'node:url'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
  9. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  10. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  11. import type {} from '@deepseek-ai/dsh-webhook'
  12. import {
  13. captureExpandedTurnProcessAria,
  14. captureStableAria,
  15. compareOrRefreshGolden,
  16. launchWebScaffold,
  17. watchConsole,
  18. webSnapshotMode,
  19. type WebScaffold,
  20. } from './scaffold.ts'
  21. import { saveFailureShot } from './support.ts'
  22. const MODE = webSnapshotMode()
  23. const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url))
  24. const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url))
  25. const EXPANDED_EXPECTED = fileURLToPath(
  26. new URL('./expected/github-ready-review/conversation-expanded.expected.md', import.meta.url),
  27. )
  28. const PROVIDER = 'github-webhook-review-test'
  29. const MODEL = 'reply'
  30. const SECRET = 'github-webhook-review-secret'
  31. const TITLE = 'Review deepseek-harness/deepseek-harness#314'
  32. const REPLY = 'Review complete: no actionable findings.'
  33. /** Deterministic model response for the webhook-created Session. */
  34. class ReviewAdapter extends LlmAdapter {
  35. readonly requests: GenerateOptions[] = []
  36. override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  37. this.requests.push(options)
  38. yield { type: 'block-start', index: 0, blockType: 'text' }
  39. yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
  40. yield { type: 'finish', reason: { kind: 'stop' } }
  41. }
  42. }
  43. /** Reserve one currently free loopback port for the isolated WebServer. */
  44. async function freePort(): Promise<number> {
  45. const server = createServer()
  46. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  47. const port = (server.address() as AddressInfo).port
  48. await new Promise<void>(resolve => server.close(() => { resolve() }))
  49. return port
  50. }
  51. /** Sign one exact GitHub JSON body. */
  52. function signature(body: string): string {
  53. return `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`
  54. }
  55. /** Send one signed GitHub delivery to a selected origin. */
  56. async function send(origin: string, delivery: string, body: object, event = 'pull_request'): Promise<Response> {
  57. const text = JSON.stringify(body)
  58. return await fetch(`${origin}/github`, {
  59. method: 'POST',
  60. headers: {
  61. 'content-type': 'application/json',
  62. 'x-hub-signature-256': signature(text),
  63. 'x-github-event': event,
  64. 'x-github-delivery': delivery,
  65. },
  66. body: text,
  67. })
  68. }
  69. describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
  70. let scaffold: WebScaffold
  71. let browser: Browser
  72. let page: Page
  73. let webhookOrigin: string
  74. let tripwire: ReturnType<typeof watchConsole>
  75. let previousPort: string | undefined
  76. let previousSecret: string | undefined
  77. const adapter = new ReviewAdapter()
  78. beforeAll(async () => {
  79. previousPort = process.env.DSH_GITHUB_WEBHOOK_PORT
  80. previousSecret = process.env.DSH_GITHUB_WEBHOOK_SECRET
  81. const port = await freePort()
  82. process.env.DSH_GITHUB_WEBHOOK_PORT = String(port)
  83. process.env.DSH_GITHUB_WEBHOOK_SECRET = SECRET
  84. webhookOrigin = `http://127.0.0.1:${String(port)}`
  85. scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
  86. scaffold.ctx.effect(
  87. () => scaffold.ctx.llm.registerAdapter([PROVIDER], adapter),
  88. 'GitHub webhook review adapter',
  89. )
  90. await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
  91. browser = await chromium.launch()
  92. page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'en-US' })
  93. await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
  94. tripwire = watchConsole(page)
  95. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  96. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  97. }, 60_000)
  98. afterAll(async () => {
  99. await browser?.close()
  100. await scaffold?.close()
  101. if (previousPort === undefined) Reflect.deleteProperty(process.env, 'DSH_GITHUB_WEBHOOK_PORT')
  102. else process.env.DSH_GITHUB_WEBHOOK_PORT = previousPort
  103. if (previousSecret === undefined) Reflect.deleteProperty(process.env, 'DSH_GITHUB_WEBHOOK_SECRET')
  104. else process.env.DSH_GITHUB_WEBHOOK_SECRET = previousSecret
  105. })
  106. it('isolates ingress and creates a browsable Workspace Session', async () => {
  107. onTestFailed(async () => { await saveFailureShot(page, 'github-ready-review') })
  108. const before = scaffold.ctx.agents.list().length
  109. expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404)
  110. expect((await send(scaffold.baseUrl, 'wrong-port', { zen: 'ping' }, 'ping')).status).not.toBe(202)
  111. expect(scaffold.ctx.agents.list()).toHaveLength(before)
  112. expect((await send(webhookOrigin, 'ping', { zen: 'keep it logically awesome' }, 'ping')).status).toBe(202)
  113. await vi.waitFor(() => { expect(scaffold.ctx.agents.list()).toHaveLength(before) })
  114. const payload = {
  115. action: 'ready_for_review',
  116. number: 314,
  117. repository: { full_name: 'deepseek-harness/deepseek-harness' },
  118. pull_request: {
  119. title: 'Fix session replay',
  120. html_url: 'https://github.com/deepseek-harness/deepseek-harness/pull/314',
  121. draft: false,
  122. user: { login: 'octocat' },
  123. base: { ref: 'master', sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
  124. head: { ref: 'fix-session-replay', sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' },
  125. },
  126. }
  127. const completed = Promise.withResolvers<undefined>()
  128. let reviewSession: string | undefined
  129. const off = scaffold.ctx.on('session/event', (session, event) => {
  130. if (event.type === 'user/message' && event.data.source.kind === 'webhook'
  131. && event.data.source.provider === 'github' && event.data.source.source === 'primary-github'
  132. && event.data.source.deliveryId === 'ready' && event.data.source.ruleId === 'review-pr-when-ready') reviewSession = session.id
  133. if (event.type === 'turn/end' && session.id === reviewSession) completed.resolve(undefined)
  134. })
  135. const entered = Promise.withResolvers<undefined>()
  136. const release = Promise.withResolvers<undefined>()
  137. const createWorkspace = scaffold.ctx.workspaceRegistry.create.bind(scaffold.ctx.workspaceRegistry)
  138. const create = vi.spyOn(scaffold.ctx.workspaceRegistry, 'create').mockImplementationOnce(async (...args) => {
  139. entered.resolve(undefined)
  140. await release.promise
  141. return await createWorkspace(...args)
  142. })
  143. onTestFinished(() => {
  144. off()
  145. release.resolve(undefined)
  146. create.mockRestore()
  147. })
  148. try {
  149. expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202)
  150. await entered.promise
  151. expect(scaffold.ctx.agents.list()).toHaveLength(before)
  152. expect(adapter.requests).toHaveLength(0)
  153. release.resolve(undefined)
  154. await completed.promise
  155. } finally {
  156. release.resolve(undefined)
  157. create.mockRestore()
  158. off()
  159. }
  160. expect(scaffold.ctx.agents.list()).toHaveLength(before + 1)
  161. expect(adapter.requests).toHaveLength(1)
  162. const agent = scaffold.ctx.agents.list().find(candidate => candidate.session.header.cwd === scaffold.workspaceCwd)
  163. expect(agent).toBeDefined()
  164. const workspace = await scaffold.ctx.workspaceRegistry.resolveByPath(scaffold.workspaceCwd)
  165. expect(workspace?.sessionIds).toContain(agent?.id)
  166. const webhookMessage = adapter.requests[0]?.messages.find(message => message.source.kind === 'webhook')
  167. expect(webhookMessage?.content).toHaveLength(1)
  168. const [content] = webhookMessage?.content ?? []
  169. expect(content?.type).toBe('text')
  170. if (content?.type !== 'text') throw new Error('webhook prompt was not text')
  171. expect(content.text).toContain('exact head SHA bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb')
  172. const workspaceRow = page.locator('[role="treeitem"]').first()
  173. if (await workspaceRow.getAttribute('aria-expanded') !== 'true') await workspaceRow.click()
  174. await page.getByText(TITLE, { exact: true }).click()
  175. await page.getByText(REPLY, { exact: true }).waitFor({ state: 'visible', timeout: 30_000 })
  176. const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
  177. const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  178. await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE)
  179. const expanded = await captureExpandedTurnProcessAria(
  180. page,
  181. '[class*="centerCol"]',
  182. scaffold.workspaceCwd,
  183. )
  184. await compareOrRefreshGolden(EXPANDED_EXPECTED, `${tree}\n\n---\n\n${expanded}`, MODE)
  185. expect(tripwire.pageErrors).toEqual([])
  186. expect(tripwire.warnings).toEqual([])
  187. }, 60_000)
  188. })