headless.snapshot.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. import { readFile, readdir, writeFile } from 'node:fs/promises'
  2. import { delimiter, dirname, join } from 'node:path'
  3. import { fileURLToPath } from 'node:url'
  4. import {
  5. normalizeSessionLog,
  6. normalizeStdout,
  7. refreshFixtureReplacements,
  8. scrubRequestHeaders,
  9. stabilizeRefreshLog,
  10. tokenizeSessionFixtureCwd,
  11. type HarvestedLog,
  12. type NormalizeContext,
  13. } from '@deepseek-ai/dsh-acp-snapshot'
  14. import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
  15. import { describe, expect, it } from 'vitest'
  16. const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots')
  17. const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain')
  18. const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl')
  19. const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl')
  20. const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url))
  21. const ptyScenarioDir = join(snapshotsDir, 'pty-tools')
  22. const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl')
  23. const ptyStreamExpected = join(ptyScenarioDir, 'stream-json.expected.jsonl')
  24. const ptyConfigPath = fileURLToPath(new URL('../pty.cordis.snapshot.yml', import.meta.url))
  25. const goalScenarioDir = join(snapshotsDir, 'goal-tools')
  26. const goalConfigPath = fileURLToPath(new URL('../goal.cordis.snapshot.yml', import.meta.url))
  27. const retryScenarioDir = join(snapshotsDir, 'provider-retry')
  28. const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
  29. const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
  30. const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
  31. const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
  32. const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
  33. const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
  34. const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
  35. interface JsonObject {
  36. [key: string]: unknown
  37. }
  38. interface PersistedLog {
  39. readonly content: string
  40. readonly header: JsonObject
  41. }
  42. function parseJsonl(content: string): JsonObject[] {
  43. return content.split('\n')
  44. .filter(line => line.trim().length > 0)
  45. .map(line => JSON.parse(line) as JsonObject)
  46. }
  47. function contextFromLogs(contents: readonly string[]): NormalizeContext {
  48. const headers = contents.map(content => parseJsonl(content)[0])
  49. return {
  50. sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []),
  51. cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0',
  52. }
  53. }
  54. function normalizeHeadlessStream(rawStdout: string, cwd: string): string {
  55. const records = parseJsonl(rawStdout)
  56. if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records')
  57. const final = records.at(-1)
  58. if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record')
  59. if (records.slice(0, -1).some(record => record.type !== 'session_event')) {
  60. throw new Error('headless snapshot emitted a non-event record before its result')
  61. }
  62. const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))]
  63. if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`)
  64. const context: NormalizeContext = { sessionIds, cwd }
  65. const events = records.slice(0, -1).map((record) => {
  66. if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) {
  67. throw new Error('headless snapshot emitted an invalid session event')
  68. }
  69. return record.event as JsonObject
  70. })
  71. const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog(
  72. `${events.map(event => JSON.stringify(event)).join('\n')}\n`,
  73. context,
  74. )))
  75. const normalizedRecords = records.map((record, index) => index < normalizedEvents.length
  76. ? { ...record, event: normalizedEvents[index] }
  77. : record)
  78. return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context)
  79. }
  80. /** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */
  81. function normalizeGoalTimestamps(value: unknown): unknown {
  82. if (typeof value === 'string') {
  83. return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10')
  84. }
  85. if (Array.isArray(value)) return value.map(normalizeGoalTimestamps)
  86. if (value !== null && typeof value === 'object') {
  87. return Object.fromEntries(Object.entries(value).map(([key, item]) => [
  88. key,
  89. ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number'
  90. ? 0
  91. : normalizeGoalTimestamps(item),
  92. ]))
  93. }
  94. return value
  95. }
  96. /** Normalize the stream's durable goal timestamps after the shared scrubbers. */
  97. function normalizeGoalStream(rawStdout: string, cwd: string): string {
  98. return parseJsonl(normalizeHeadlessStream(rawStdout, cwd))
  99. .map(record => JSON.stringify(normalizeGoalTimestamps(record)))
  100. .join('\n') + '\n'
  101. }
  102. async function scenarioPrompt(dir: string, label: string): Promise<string> {
  103. const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as {
  104. steps?: { op?: unknown; text?: unknown }[]
  105. }
  106. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  107. if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`)
  108. return prompt
  109. }
  110. async function persistedLogs(cwd: string): Promise<PersistedLog[]> {
  111. const root = join(cwd, '.sessions')
  112. const files = (await readdir(root, { recursive: true })).filter(file => file.endsWith('.jsonl'))
  113. return Promise.all(files.map(async (file) => {
  114. const content = await readFile(join(root, file), 'utf8')
  115. return { content, header: parseJsonl(content)[0] ?? {} }
  116. }))
  117. }
  118. describe('headless stream-json snapshots', () => {
  119. it('retries a transient provider failure through the one-shot app', async () => {
  120. const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
  121. const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')
  122. let runCwd = ''
  123. const result = await runLoaderSmoke({
  124. label: 'provider retry headless stream-json snapshot',
  125. tempDirPrefix: 'headless-snapshot-provider-retry-',
  126. binScript,
  127. configPath: retryConfigPath,
  128. binArgs: ['--config', retryConfigPath, '--output-format', 'stream-json', prompt],
  129. tsconfigPath,
  130. env: {
  131. DSH_SNAPSHOT: 'replay',
  132. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  133. },
  134. prepare: (cwd) => { runCwd = cwd },
  135. inspect: async (cwd) => {
  136. const logs = await persistedLogs(cwd)
  137. expect(logs).toHaveLength(1)
  138. const records = parseJsonl(logs[0]?.content ?? '')
  139. const retries = records.filter(record => record.type === 'llm/retry')
  140. expect(retries).toHaveLength(1)
  141. expect(retries[0]?.data).toMatchObject({
  142. provider: 'deepseek',
  143. mode: 'normal',
  144. policyKey: '["normal",1,["RATE_LIMIT"],1,1,0]',
  145. retry: 1,
  146. maxRetries: 1,
  147. delayMs: 1,
  148. failure: { message: 'snapshot transient failure', code: 'RATE_LIMIT', status: 429 },
  149. })
  150. },
  151. })
  152. expect(result.stderr).toBe('')
  153. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  154. if (refreshing) await writeFile(streamExpected, normalized)
  155. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  156. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  157. it('logs the model default and a dynamic next-step reasoning effort', async () => {
  158. const result = await runLoaderSmoke({
  159. label: 'reasoning effort headless stream-json snapshot',
  160. tempDirPrefix: 'headless-snapshot-reasoning-effort-',
  161. binScript,
  162. configPath: reasoningConfigPath,
  163. binArgs: ['--config', reasoningConfigPath, '--output-format', 'stream-json', 'prove dynamic reasoning effort'],
  164. tsconfigPath,
  165. })
  166. expect(result.stderr).toBe('')
  167. const headers = parseJsonl(result.stdout)
  168. .map(record => record.event)
  169. .filter((event): event is JsonObject => (
  170. event !== null
  171. && typeof event === 'object'
  172. && !Array.isArray(event)
  173. && 'type' in event
  174. && event.type === 'request/header'
  175. ))
  176. .map((event) => {
  177. const data = event.data as JsonObject
  178. return (data.header as JsonObject).config
  179. })
  180. expect(headers).toMatchInlineSnapshot(`
  181. [
  182. {
  183. "model": "cli-mock",
  184. "provider": "cli-mock",
  185. "reasoningEffort": "high",
  186. },
  187. {
  188. "model": "cli-mock",
  189. "provider": "cli-mock",
  190. "reasoningEffort": "off",
  191. },
  192. ]
  193. `)
  194. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  195. it('replays the advanced toolchain through the one-shot app', async () => {
  196. const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain')
  197. const fixtureFiles = [
  198. advancedSessionFixture,
  199. join(advancedScenarioDir, 'session.1.jsonl'),
  200. join(advancedScenarioDir, 'session.2.jsonl'),
  201. ]
  202. let expectedSessions = await Promise.all(fixtureFiles.map(file => readFile(file, 'utf8')))
  203. let runCwd = ''
  204. const result = await runLoaderSmoke({
  205. label: 'advanced headless stream-json snapshot',
  206. tempDirPrefix: 'headless-snapshot-advanced-',
  207. binScript,
  208. configPath: advancedConfigPath,
  209. binArgs: ['--config', advancedConfigPath, '--output-format', 'stream-json', prompt],
  210. tsconfigPath,
  211. env: {
  212. DSH_SNAPSHOT: 'replay',
  213. DSH_SNAPSHOT_FILE: advancedSessionFixture,
  214. DSH_SNAPSHOT_CHILD_FILES: [
  215. join(advancedScenarioDir, 'session.1.jsonl'),
  216. join(advancedScenarioDir, 'session.2.jsonl'),
  217. ].join(delimiter),
  218. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  219. },
  220. prepare: (cwd) => { runCwd = cwd },
  221. inspect: async (cwd) => {
  222. const logs = await persistedLogs(cwd)
  223. expect(logs).toHaveLength(3)
  224. const parents = logs.filter(log => typeof log.header.parentSession !== 'string')
  225. expect(parents).toHaveLength(1)
  226. const parent = parents[0]
  227. if (parent === undefined) throw new Error('headless snapshot did not persist its main session')
  228. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  229. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  230. const actualSessions = [parent, ...children]
  231. const actualContext = contextFromLogs(actualSessions.map(log => log.content))
  232. if (refreshing) {
  233. const harvested = actualSessions.map((log): HarvestedLog => ({
  234. id: String(log.header.id),
  235. createdAt: Number(log.header.createdAt),
  236. ...typeof log.header.parentSession === 'string'
  237. ? { parentSession: log.header.parentSession }
  238. : {},
  239. content: log.content,
  240. }))
  241. const replacements = refreshFixtureReplacements(harvested, expectedSessions)
  242. expectedSessions = await Promise.all(actualSessions.map(async (actual, index) => {
  243. const existing = expectedSessions[index]
  244. const file = fixtureFiles[index]
  245. if (existing === undefined || file === undefined) {
  246. throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  247. }
  248. const stable = tokenizeSessionFixtureCwd(
  249. stabilizeRefreshLog(actual.content, existing, replacements, actualContext),
  250. )
  251. await writeFile(file, stable)
  252. return stable
  253. }))
  254. }
  255. const expectedContext = contextFromLogs(expectedSessions)
  256. for (const [index, actual] of actualSessions.entries()) {
  257. const expected = expectedSessions[index]
  258. if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`)
  259. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  260. .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext)))
  261. }
  262. },
  263. })
  264. expect(result.stderr).toBe('')
  265. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  266. if (refreshing) await writeFile(advancedStreamExpected, normalized)
  267. expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8'))
  268. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  269. it('replays persisted goal tools through the one-shot app', async () => {
  270. const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools')
  271. const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl')
  272. let runCwd = ''
  273. const result = await runLoaderSmoke({
  274. label: 'goal tools headless stream-json snapshot',
  275. tempDirPrefix: 'headless-snapshot-goal-tools-',
  276. binScript,
  277. configPath: goalConfigPath,
  278. binArgs: ['--config', goalConfigPath, '--output-format', 'stream-json', prompt],
  279. tsconfigPath,
  280. env: {
  281. DSH_SNAPSHOT: 'replay',
  282. DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'),
  283. DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'),
  284. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  285. },
  286. prepare: (cwd) => { runCwd = cwd },
  287. inspect: async (cwd) => {
  288. const logs = await persistedLogs(cwd)
  289. expect(logs).toHaveLength(1)
  290. const records = parseJsonl(logs[0]?.content ?? '')
  291. const calls = records.filter(record => record.type === 'tool/call')
  292. .map(record => (record.data as JsonObject | undefined)?.name)
  293. expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal'])
  294. const probeResult = records.find((record) => {
  295. if (record.type !== 'tool/result') return false
  296. const data = record.data as JsonObject | undefined
  297. const message = data?.message as JsonObject | undefined
  298. const source = message?.source as JsonObject | undefined
  299. return source?.callId === 'call_goal_probe'
  300. })
  301. const probeData = probeResult?.data as JsonObject | undefined
  302. const probeMessage = probeData?.message as JsonObject | undefined
  303. const probeContent = probeMessage?.content as JsonObject[] | undefined
  304. expect(probeContent?.[0]?.isError).toBe(true)
  305. expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND')
  306. const goalChanges = records.filter((record) => {
  307. if (record.type !== 'user/message') return false
  308. const data = record.data as JsonObject | undefined
  309. const source = data?.source as JsonObject | undefined
  310. const change = source?.change as JsonObject | undefined
  311. return source?.kind === 'goal' && change?.kind === 'goal/change'
  312. })
  313. expect(goalChanges).toHaveLength(1)
  314. const data = goalChanges[0]?.data as JsonObject | undefined
  315. const source = data?.source as JsonObject | undefined
  316. const change = source?.change as JsonObject | undefined
  317. const goal = change?.goal as JsonObject | undefined
  318. expect(change?.operation).toBe('create')
  319. expect(goal).toMatchObject({
  320. objective: 'Finish the headless goal-tool snapshot proof',
  321. phase: 'active',
  322. maxGoalRounds: 7,
  323. })
  324. },
  325. })
  326. expect(result.stderr).toBe('')
  327. const normalized = normalizeGoalStream(result.stdout, runCwd)
  328. if (refreshing) await writeFile(streamExpected, normalized)
  329. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  330. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  331. it('replays two fresh Ralph rounds through the one-shot app', async () => {
  332. const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop')
  333. const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl')
  334. let runCwd = ''
  335. const result = await runLoaderSmoke({
  336. label: 'Ralph loop headless stream-json snapshot',
  337. tempDirPrefix: 'headless-snapshot-ralph-loop-',
  338. binScript,
  339. configPath: ralphConfigPath,
  340. binArgs: ['--config', ralphConfigPath, '--output-format', 'stream-json', prompt],
  341. tsconfigPath,
  342. env: {
  343. DSH_SNAPSHOT: 'replay',
  344. DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'),
  345. DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'),
  346. DSH_SNAPSHOT_CHILD_FILES: [
  347. join(ralphScenarioDir, 'session.1.jsonl'),
  348. join(ralphScenarioDir, 'session.2.jsonl'),
  349. ].join(delimiter),
  350. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  351. },
  352. prepare: (cwd) => { runCwd = cwd },
  353. inspect: async (cwd) => {
  354. const logs = await persistedLogs(cwd)
  355. expect(logs).toHaveLength(3)
  356. const parent = logs.find(log => typeof log.header.parentSession !== 'string')
  357. if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session')
  358. const parentId = parent.header.id
  359. expect(typeof parentId).toBe('string')
  360. const children = logs.filter(log => typeof log.header.parentSession === 'string')
  361. .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt))
  362. expect(children).toHaveLength(2)
  363. expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId])
  364. expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd])
  365. expect(parent.header.delegationDepth).toBe(0)
  366. expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1])
  367. expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined])
  368. expect(new Set(children.map(child => child.header.id)).size).toBe(2)
  369. const parentRecords = parseJsonl(parent.content)
  370. const parentCalls = parentRecords.filter(record => record.type === 'tool/call')
  371. expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph'])
  372. const parentResult = parentRecords.find(record => record.type === 'tool/result')
  373. const parentResultData = parentResult?.data as JsonObject | undefined
  374. const parentMessage = parentResultData?.message as JsonObject | undefined
  375. const parentContent = parentMessage?.content as JsonObject[] | undefined
  376. expect(parentContent?.[0]?.isError).toBe(false)
  377. expect(JSON.stringify(parentContent?.[0]?.content)).toContain('reported completion after 2 rounds')
  378. const childRecords = children.map(child => parseJsonl(child.content))
  379. const childPrompts = childRecords.map((records) => {
  380. const message = records.find(record => record.type === 'user/message')
  381. return JSON.stringify((message?.data as JsonObject | undefined)?.content)
  382. })
  383. expect(childPrompts[0]).toContain('Ralph round: 1 of 2.')
  384. expect(childPrompts[0]).toContain('(none — this is the first round)')
  385. expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF')
  386. expect(childPrompts[1]).toContain('Ralph round: 2 of 2.')
  387. expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF')
  388. for (const childPrompt of childPrompts) {
  389. expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.')
  390. expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop')
  391. }
  392. for (const records of childRecords) {
  393. const calls = records.filter(record => record.type === 'tool/call')
  394. expect(calls.map(record => (record.data as JsonObject | undefined)?.name))
  395. .toEqual(['structured_output'])
  396. }
  397. },
  398. })
  399. expect(result.stderr).toBe('')
  400. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  401. if (refreshing) await writeFile(streamExpected, normalized)
  402. expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
  403. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  404. it('replays persistent PTY tools through the one-shot app', async () => {
  405. const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as {
  406. steps?: { op?: unknown; text?: unknown }[]
  407. }
  408. const prompt = input.steps?.find(step => step.op === 'prompt')?.text
  409. if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step')
  410. let expectedSession = await readFile(ptySessionFixture, 'utf8')
  411. let runCwd = ''
  412. const result = await runLoaderSmoke({
  413. label: 'headless persistent PTY snapshot',
  414. tempDirPrefix: 'headless-snapshot-pty-',
  415. binScript,
  416. configPath: ptyConfigPath,
  417. binArgs: ['--config', ptyConfigPath, '--output-format', 'stream-json', prompt],
  418. tsconfigPath,
  419. env: {
  420. DSH_SNAPSHOT: 'replay',
  421. DSH_SNAPSHOT_FILE: ptySessionFixture,
  422. NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
  423. },
  424. prepare: (cwd) => { runCwd = cwd },
  425. inspect: async (cwd) => {
  426. const logs = await persistedLogs(cwd)
  427. expect(logs).toHaveLength(1)
  428. const actual = logs[0]
  429. if (actual === undefined) throw new Error('headless PTY snapshot did not persist its session')
  430. const actualContext = contextFromLogs([actual.content])
  431. if (refreshing) {
  432. const harvested: HarvestedLog = {
  433. id: String(actual.header.id),
  434. createdAt: Number(actual.header.createdAt),
  435. content: actual.content,
  436. }
  437. const replacements = refreshFixtureReplacements([harvested], [expectedSession])
  438. expectedSession = tokenizeSessionFixtureCwd(
  439. stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext),
  440. )
  441. await writeFile(ptySessionFixture, expectedSession)
  442. }
  443. const expectedContext = contextFromLogs([expectedSession])
  444. expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext)))
  445. .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext)))
  446. },
  447. })
  448. expect(result.stderr).toBe('')
  449. const normalized = normalizeHeadlessStream(result.stdout, runCwd)
  450. if (refreshing) await writeFile(ptyStreamExpected, normalized)
  451. expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8'))
  452. }, LOADER_SMOKE_TEST_TIMEOUT_MS)
  453. })