github-ready-review.e2e.ts 7.3 KB

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