github-webhook-real.e2e.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. /** Real CLI and DeepSeek evidence for a GitHub webhook-created Session. */
  2. import type { ChildProcess } from 'node:child_process'
  3. import { spawn } from 'node:child_process'
  4. import { createHmac } from 'node:crypto'
  5. import { existsSync } from 'node:fs'
  6. import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'
  7. import { createServer } from 'node:net'
  8. import type { AddressInfo } from 'node:net'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { setTimeout as delay } from 'node:timers/promises'
  12. import { fileURLToPath } from 'node:url'
  13. import { describe, expect, it } from 'vitest'
  14. const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
  15. const BUILT_BIN = join(REPO_ROOT, 'apps/cli/lib/bin.js')
  16. const OVERLAY = fileURLToPath(new URL('./fixtures/github-webhook-real/cordis.yml', import.meta.url))
  17. const SECRET = 'github-webhook-real-e2e-secret'
  18. const DELIVERY = 'github-webhook-real-e2e-delivery'
  19. const MARKER = 'DSH_GITHUB_WEBHOOK_REAL_E2E_OK'
  20. const TITLE = 'GitHub webhook real e2e'
  21. interface SessionList {
  22. items: Array<{
  23. sessionId: string
  24. cwd?: string
  25. agentPreset?: string
  26. blank: boolean
  27. }>
  28. }
  29. interface WorkspaceList {
  30. items: Array<{
  31. path: string
  32. sessionIds: string[]
  33. }>
  34. }
  35. interface HistoryPage {
  36. events: Array<{
  37. event: {
  38. type: string
  39. data: unknown
  40. }
  41. }>
  42. hasMore: boolean
  43. }
  44. interface ProcessObservation {
  45. readonly ready: Promise<string>
  46. readonly text: () => string
  47. }
  48. function isRecord(value: unknown): value is Record<string, unknown> {
  49. return typeof value === 'object' && value !== null
  50. }
  51. /** Capture bounded process output and resolve the public Web URL after settled boot. */
  52. function observeProcess(child: ChildProcess): ProcessObservation {
  53. let output = ''
  54. let settled = false
  55. let resolveReady!: (url: string) => void
  56. let rejectReady!: (error: Error) => void
  57. const ready = new Promise<string>((resolve, reject) => {
  58. resolveReady = resolve
  59. rejectReady = reject
  60. })
  61. const timer = setTimeout(() => {
  62. if (!settled) rejectReady(new Error(`dsh web did not become ready within 90s:\n${output}`))
  63. }, 90_000)
  64. timer.unref()
  65. const append = (chunk: Buffer | string): void => {
  66. output = `${output}${String(chunk)}`.slice(-100_000)
  67. const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
  68. if (settled || match?.[1] === undefined) return
  69. settled = true
  70. clearTimeout(timer)
  71. resolveReady(match[1].replace('0.0.0.0', '127.0.0.1'))
  72. }
  73. child.stdout?.on('data', append)
  74. child.stderr?.on('data', append)
  75. child.once('error', (error) => {
  76. if (!settled) rejectReady(error)
  77. })
  78. child.once('exit', (code) => {
  79. if (!settled) rejectReady(new Error(`dsh web exited before readiness (code ${String(code)}):\n${output}`))
  80. })
  81. return { ready, text: () => output }
  82. }
  83. /** Reserve and release one loopback port for the isolated webhook listener. */
  84. async function freePort(): Promise<number> {
  85. const server = createServer()
  86. await new Promise<void>((resolve, reject) => {
  87. server.once('error', reject)
  88. server.listen(0, '127.0.0.1', resolve)
  89. })
  90. const port = (server.address() as AddressInfo).port
  91. await new Promise<void>((resolve, reject) => {
  92. server.close((error) => {
  93. if (error === undefined) resolve()
  94. else reject(error)
  95. })
  96. })
  97. return port
  98. }
  99. /** Invoke one public Web RPC method. */
  100. async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promise<T> {
  101. const response = await fetch(`${baseUrl}/api/${method}`, {
  102. method: 'POST',
  103. headers: { 'content-type': 'application/json' },
  104. body: JSON.stringify({
  105. type: 'client-request',
  106. rpcId: `github-webhook-real-${method}`,
  107. method,
  108. payload,
  109. }),
  110. })
  111. if (!response.ok) throw new Error(`${method} returned HTTP ${String(response.status)}: ${await response.text()}`)
  112. const envelope = await response.json() as {
  113. result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
  114. }
  115. if (!envelope.result.ok) {
  116. throw new Error(`${method} failed: ${envelope.result.error.code}: ${envelope.result.error.message}`)
  117. }
  118. return envelope.result.value
  119. }
  120. /** Poll a public observation until it satisfies the test's behavior predicate. */
  121. async function eventually<T>(
  122. child: ChildProcess,
  123. processOutput: () => string,
  124. label: string,
  125. probe: () => Promise<T>,
  126. accepts: (value: T) => boolean,
  127. timeoutMs: number,
  128. ): Promise<T> {
  129. const deadline = Date.now() + timeoutMs
  130. let lastValue: T | undefined
  131. let lastError: unknown
  132. while (Date.now() < deadline) {
  133. if (child.exitCode !== null) {
  134. throw new Error(`dsh web exited while waiting for ${label} (code ${String(child.exitCode)}):\n${processOutput()}`)
  135. }
  136. try {
  137. lastValue = await probe()
  138. if (accepts(lastValue)) return lastValue
  139. } catch (error) {
  140. lastError = error
  141. }
  142. await delay(300)
  143. }
  144. throw new Error(
  145. `timed out waiting for ${label}; last value=${JSON.stringify(lastValue)}; `
  146. + `last error=${String(lastError)}; process output:\n${processOutput()}`,
  147. )
  148. }
  149. /** Return every text block from durable assistant messages. */
  150. function assistantText(page: HistoryPage): string {
  151. const text: string[] = []
  152. for (const { event } of page.events) {
  153. if (event.type !== 'assistant/message' || !isRecord(event.data) || !isRecord(event.data.message)) continue
  154. const content = event.data.message.content
  155. if (!Array.isArray(content)) continue
  156. for (const block of content) {
  157. if (isRecord(block) && block.type === 'text' && typeof block.text === 'string') text.push(block.text)
  158. }
  159. }
  160. return text.join('\n')
  161. }
  162. /** Stop the spawned CLI through its normal signal path, escalating only on a stuck teardown. */
  163. async function stop(child: ChildProcess): Promise<void> {
  164. if (child.exitCode !== null) return
  165. let resolveClosed!: () => void
  166. const closed = new Promise<void>((resolve) => { resolveClosed = resolve })
  167. child.once('close', resolveClosed)
  168. child.kill('SIGTERM')
  169. if (await Promise.race([closed.then(() => true), delay(10_000).then(() => false)])) return
  170. if (child.exitCode === null) child.kill('SIGKILL')
  171. await Promise.race([closed, delay(5_000)])
  172. }
  173. /** Send the sole synthetic external interaction: one signed GitHub delivery. */
  174. async function sendGitHubDelivery(origin: string): Promise<Response> {
  175. const body = JSON.stringify({
  176. action: 'ready_for_review',
  177. number: 4242,
  178. repository: { full_name: 'deepseek-harness/deepseek-harness' },
  179. pull_request: {
  180. title: 'Real CLI webhook e2e',
  181. html_url: 'https://github.com/deepseek-harness/deepseek-harness/pull/4242',
  182. draft: false,
  183. user: { login: 'octocat' },
  184. base: { ref: 'master', sha: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' },
  185. head: { ref: 'webhook-e2e', sha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' },
  186. },
  187. })
  188. const signature = `sha256=${createHmac('sha256', SECRET).update(body).digest('hex')}`
  189. return await fetch(`${origin}/github`, {
  190. method: 'POST',
  191. headers: {
  192. 'content-type': 'application/json',
  193. 'x-github-delivery': DELIVERY,
  194. 'x-github-event': 'pull_request',
  195. 'x-hub-signature-256': signature,
  196. },
  197. body,
  198. })
  199. }
  200. describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real dsh CLI and model', () => {
  201. it('creates, attaches, prompts, and completes a Workspace Session', async () => {
  202. expect(existsSync(BUILT_BIN), `missing built CLI ${BUILT_BIN}; run pnpm run build:official`).toBe(true)
  203. const root = await mkdtemp(join(tmpdir(), 'dsh-github-webhook-real-'))
  204. const workspacePath = join(root, 'workspace')
  205. await mkdir(workspacePath)
  206. const canonicalWorkspacePath = await realpath(workspacePath)
  207. const webhookPort = await freePort()
  208. const child = spawn(process.execPath, [
  209. BUILT_BIN,
  210. 'web',
  211. '--patch', OVERLAY,
  212. '--no-open',
  213. '--host', '127.0.0.1',
  214. '--port', '0',
  215. ], {
  216. cwd: root,
  217. env: {
  218. ...process.env,
  219. DEEPSEEK_BASE_URL: 'https://api.deepseek.com',
  220. DSH_AGENTS_HOME: join(root, '.agents'),
  221. DSH_GITHUB_E2E_MARKER: MARKER,
  222. DSH_GITHUB_E2E_WORKSPACE: workspacePath,
  223. DSH_GITHUB_WEBHOOK_PORT: String(webhookPort),
  224. DSH_GITHUB_WEBHOOK_SECRET: SECRET,
  225. DSH_HOME: join(root, '.dsh'),
  226. DSH_TELEMETRY_DISABLED: '1',
  227. },
  228. stdio: ['ignore', 'pipe', 'pipe'],
  229. })
  230. const observation = observeProcess(child)
  231. try {
  232. const baseUrl = await observation.ready
  233. const webhookOrigin = `http://127.0.0.1:${String(webhookPort)}`
  234. expect((await fetch(`${webhookOrigin}/api`)).status).toBe(404)
  235. expect((await sendGitHubDelivery(baseUrl)).status).not.toBe(202)
  236. expect((await sendGitHubDelivery(webhookOrigin)).status).toBe(202)
  237. const workspaces = await eventually(
  238. child,
  239. observation.text,
  240. 'one Workspace-attached Session',
  241. async () => await rpc<WorkspaceList>(baseUrl, 'workspace.list', {}),
  242. value => value.items.some(workspace =>
  243. workspace.path === canonicalWorkspacePath && workspace.sessionIds.length === 1),
  244. 30_000,
  245. )
  246. const workspace = workspaces.items.find(item => item.path === canonicalWorkspacePath)
  247. const sessionId = workspace?.sessionIds[0]
  248. if (sessionId === undefined) throw new Error('workspace.list did not expose the webhook Session')
  249. const sessions = await rpc<SessionList>(baseUrl, 'session.list', {})
  250. expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({
  251. agentPreset: 'minimal',
  252. blank: false,
  253. cwd: canonicalWorkspacePath,
  254. })
  255. const admitted = await eventually(
  256. child,
  257. observation.text,
  258. 'webhook provenance, title, and permission events',
  259. async () => await rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 100 }),
  260. (page) => {
  261. const events = page.events.map(item => item.event)
  262. const title = events.find(event => event.type === 'session/title')
  263. const permission = events.find(event =>
  264. event.type === 'permission/preset'
  265. && isRecord(event.data)
  266. && event.data.preset === 'read-only')
  267. const message = events.find(event =>
  268. event.type === 'user/message'
  269. && isRecord(event.data)
  270. && isRecord(event.data.source)
  271. && event.data.source.kind === 'webhook')
  272. return isRecord(title?.data) && title.data.title === TITLE
  273. && permission !== undefined
  274. && isRecord(message?.data) && isRecord(message.data.source)
  275. && message.data.source.provider === 'github'
  276. && message.data.source.deliveryId === DELIVERY
  277. },
  278. 30_000,
  279. )
  280. const webhookMessage = admitted.events.map(item => item.event)
  281. .find(event => event.type === 'user/message'
  282. && isRecord(event.data)
  283. && isRecord(event.data.source)
  284. && event.data.source.kind === 'webhook')
  285. expect(webhookMessage?.data).toMatchObject({
  286. content: [{ type: 'text', text: `Reply with exactly ${MARKER} and no other text. Do not call tools.` }],
  287. source: {
  288. kind: 'webhook',
  289. provider: 'github',
  290. deliveryId: DELIVERY,
  291. ruleId: 'github-real-e2e',
  292. source: 'github-real-e2e',
  293. },
  294. })
  295. const completed = await eventually(
  296. child,
  297. observation.text,
  298. 'a real DeepSeek assistant response',
  299. async () => await rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 100 }),
  300. page => assistantText(page).includes(MARKER),
  301. 150_000,
  302. )
  303. expect(assistantText(completed)).toContain(MARKER)
  304. } finally {
  305. await stop(child)
  306. await rm(root, { recursive: true, force: true })
  307. }
  308. }, 210_000)
  309. })