interview.test.ts 60 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777
  1. import { describe, expect, mock, test } from 'bun:test';
  2. import * as fs from 'node:fs/promises';
  3. import { createServer } from 'node:http';
  4. import * as path from 'node:path';
  5. import { InterviewConfigSchema } from '../config/schema';
  6. import { createInterviewServer } from './server';
  7. import { createInterviewService as createRealInterviewService } from './service';
  8. import type { InterviewAnswer } from './types';
  9. import { renderInterviewPage } from './ui';
  10. // Mock the plugin context with mutable message array
  11. function createMockContext(overrides?: {
  12. directory?: string;
  13. messagesData?: Array<{
  14. info?: { role: string };
  15. parts?: Array<{ type: string; text?: string }>;
  16. }>;
  17. promptImpl?: (args: any) => Promise<unknown>;
  18. }) {
  19. // Use a mutable array that can be updated after creation
  20. const messagesData = overrides?.messagesData ?? [];
  21. return {
  22. client: {
  23. session: {
  24. messages: mock(async () => ({ data: messagesData })),
  25. prompt: mock(async (args: any) => {
  26. if (overrides?.promptImpl) {
  27. return await overrides.promptImpl(args);
  28. }
  29. return {};
  30. }),
  31. promptAsync: mock(async (args: any) => {
  32. if (overrides?.promptImpl) {
  33. return await overrides.promptImpl(args);
  34. }
  35. return {};
  36. }),
  37. },
  38. },
  39. directory: overrides?.directory ?? '/test/directory',
  40. } as any;
  41. }
  42. // Helper to extract text from prompt calls
  43. function getPromptTexts(promptMock: {
  44. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  45. }): string[] {
  46. return promptMock.mock.calls
  47. .map((call) => call[0].body?.parts?.[0]?.text ?? '')
  48. .filter(Boolean);
  49. }
  50. // Helper to extract interview ID from the last prompt call
  51. function extractInterviewIdFromLastPrompt(promptMock: {
  52. mock: { calls: Array<[{ body?: { parts?: Array<{ text?: string }> } }]> };
  53. }): string | null {
  54. const calls = promptMock.mock.calls;
  55. if (calls.length === 0) return null;
  56. // Get the last call
  57. const lastCall = calls[calls.length - 1];
  58. const text = lastCall[0].body?.parts?.[0]?.text ?? '';
  59. const match = text.match(/interview\/([^\s]+)/);
  60. return match ? match[1] : null;
  61. }
  62. // Helper to extract text from output parts (kickoff/resume prompts go here)
  63. function extractOutputText(output: {
  64. parts: Array<{ type: string; text?: string }>;
  65. }): string {
  66. const textPart = output.parts.find((part) => part.type === 'text');
  67. return textPart?.text ?? '';
  68. }
  69. function requireInterviewId(value: string | null): string {
  70. expect(value).not.toBeNull();
  71. return value as string;
  72. }
  73. function createInterviewService(
  74. ctx: ReturnType<typeof createMockContext>,
  75. config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
  76. deps?: Parameters<typeof createRealInterviewService>[2],
  77. ) {
  78. const resolvedConfig = config
  79. ? InterviewConfigSchema.parse(config)
  80. : undefined;
  81. return createRealInterviewService(ctx, resolvedConfig, {
  82. openBrowser: mock((_url: string) => {}),
  83. ...deps,
  84. });
  85. }
  86. function createTestService(
  87. ctx: ReturnType<typeof createMockContext>,
  88. config?: Partial<Parameters<typeof createRealInterviewService>[1]>,
  89. deps?: Parameters<typeof createRealInterviewService>[2],
  90. ) {
  91. const openBrowserMock = mock((_url: string) => {});
  92. const resolvedConfig = config
  93. ? InterviewConfigSchema.parse(config)
  94. : undefined;
  95. const service = createRealInterviewService(ctx, resolvedConfig, {
  96. openBrowser: openBrowserMock,
  97. ...deps,
  98. });
  99. return {
  100. service,
  101. openBrowserMock,
  102. };
  103. }
  104. function createRuntimeEnv(
  105. overrides: NodeJS.ProcessEnv = {},
  106. ): NodeJS.ProcessEnv {
  107. return { ...overrides };
  108. }
  109. describe('interview service', () => {
  110. describe('/interview <idea> command', () => {
  111. test('creates interview and sends kickoff prompt with UI notification', async () => {
  112. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  113. const ctx = createMockContext({ directory: tempDir });
  114. const service = createInterviewService(ctx);
  115. // Set up base URL resolver to avoid server error
  116. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  117. const output = { parts: [] as Array<{ type: string; text?: string }> };
  118. await service.handleCommandExecuteBefore(
  119. {
  120. command: 'interview',
  121. sessionID: 'session-123',
  122. arguments: 'My App Idea',
  123. },
  124. output,
  125. );
  126. // Should inject kickoff prompt into output
  127. expect(output.parts.length).toBe(1);
  128. expect(output.parts[0].type).toBe('text');
  129. expect(output.parts[0].text).toContain('My App Idea');
  130. expect(output.parts[0].text).toContain('<interview_state>');
  131. // Should send UI notification prompt to session
  132. expect(ctx.client.session.prompt).toHaveBeenCalled();
  133. const promptTexts = getPromptTexts(ctx.client.session.prompt);
  134. expect(
  135. promptTexts.some((text) => text.includes('Interview UI ready')),
  136. ).toBe(true);
  137. expect(promptTexts.some((text) => text.includes('/interview/'))).toBe(
  138. true,
  139. );
  140. // Cleanup
  141. await fs.rm(tempDir, { recursive: true, force: true });
  142. });
  143. test('creates markdown file with slug-only filename (no timestamp prefix)', async () => {
  144. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  145. const ctx = createMockContext({ directory: tempDir });
  146. const service = createInterviewService(ctx);
  147. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  148. const output = { parts: [] as Array<{ type: string; text?: string }> };
  149. await service.handleCommandExecuteBefore(
  150. {
  151. command: 'interview',
  152. sessionID: 'session-456',
  153. arguments: 'Test Idea',
  154. },
  155. output,
  156. );
  157. // Check that interview directory and file were created
  158. const interviewDir = path.join(tempDir, 'interview');
  159. const files = await fs.readdir(interviewDir);
  160. expect(files.length).toBe(1);
  161. // Filename should be slug-only, no timestamp prefix
  162. expect(files[0]).toBe('test-idea.md');
  163. expect(files[0]).not.toMatch(/^\d+-/);
  164. // Check file content structure
  165. const content = await fs.readFile(
  166. path.join(interviewDir, files[0]),
  167. 'utf8',
  168. );
  169. expect(content).toContain('# Test Idea');
  170. expect(content).toContain('## Current spec');
  171. expect(content).toContain('## Q&A history');
  172. // Cleanup
  173. await fs.rm(tempDir, { recursive: true, force: true });
  174. });
  175. });
  176. describe('answer submission', () => {
  177. test('appends only Q/A history to markdown document', async () => {
  178. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  179. // Start with empty messages, then add questions after interview creation
  180. const messagesData: Array<{
  181. info?: { role: string };
  182. parts?: Array<{ type: string; text?: string }>;
  183. }> = [];
  184. const ctx = createMockContext({
  185. directory: tempDir,
  186. messagesData,
  187. });
  188. const service = createInterviewService(ctx);
  189. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  190. // Create interview first (with empty messages, so baseMessageCount = 0)
  191. const output = { parts: [] as Array<{ type: string; text?: string }> };
  192. await service.handleCommandExecuteBefore(
  193. {
  194. command: 'interview',
  195. sessionID: 'session-789',
  196. arguments: 'Platform App',
  197. },
  198. output,
  199. );
  200. // Get the interview ID from the prompt calls
  201. const interviewId = extractInterviewIdFromLastPrompt(
  202. ctx.client.session.prompt,
  203. );
  204. const requiredInterviewId = requireInterviewId(interviewId);
  205. // Now add the questions to messages (simulating agent response)
  206. messagesData.push({
  207. info: { role: 'assistant' },
  208. parts: [
  209. {
  210. type: 'text',
  211. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  212. },
  213. ],
  214. });
  215. // Submit an answer
  216. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'Web' }];
  217. await service.submitAnswers(requiredInterviewId, answers);
  218. // Read the markdown file
  219. const interviewDir = path.join(tempDir, 'interview');
  220. const files = await fs.readdir(interviewDir);
  221. const content = await fs.readFile(
  222. path.join(interviewDir, files[0]),
  223. 'utf8',
  224. );
  225. // Verify Q/A was appended to history section
  226. expect(content).toContain('## Q&A history');
  227. expect(content).toContain('Q: What platform?');
  228. expect(content).toContain('A: Web');
  229. // Verify the Current spec section exists (even if empty after submission)
  230. expect(content).toContain('## Current spec');
  231. // Cleanup
  232. await fs.rm(tempDir, { recursive: true, force: true });
  233. });
  234. test('preserves existing history when appending new answers', async () => {
  235. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  236. // Start with messages that include one answered question and one pending
  237. const messagesData: Array<{
  238. info?: { role: string };
  239. parts?: Array<{ type: string; text?: string }>;
  240. }> = [
  241. // First question and answer
  242. {
  243. info: { role: 'assistant' },
  244. parts: [
  245. {
  246. type: 'text',
  247. text: 'First question.\n<interview_state>\n{\n "summary": "Building an app",\n "questions": [\n {\n "id": "q-1",\n "question": "What is the name?",\n "options": ["App1", "App2"],\n "suggested": "App1"\n }\n ]\n}\n</interview_state>',
  248. },
  249. ],
  250. },
  251. { info: { role: 'user' }, parts: [{ type: 'text', text: 'App1' }] },
  252. // Second question (current)
  253. {
  254. info: { role: 'assistant' },
  255. parts: [
  256. {
  257. type: 'text',
  258. text: 'Second question.\n<interview_state>\n{\n "summary": "Building App1",\n "questions": [\n {\n "id": "q-2",\n "question": "What color?",\n "options": ["Red", "Blue"],\n "suggested": "Blue"\n }\n ]\n}\n</interview_state>',
  259. },
  260. ],
  261. },
  262. ];
  263. const ctx = createMockContext({
  264. directory: tempDir,
  265. messagesData,
  266. });
  267. const service = createInterviewService(ctx);
  268. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  269. // Create interview (baseMessageCount will be 3)
  270. const output = { parts: [] as Array<{ type: string; text?: string }> };
  271. await service.handleCommandExecuteBefore(
  272. {
  273. command: 'interview',
  274. sessionID: 'session-abc',
  275. arguments: 'Multi Round App',
  276. },
  277. output,
  278. );
  279. // Get interview ID from prompt calls
  280. const interviewId = extractInterviewIdFromLastPrompt(
  281. ctx.client.session.prompt,
  282. );
  283. const requiredInterviewId = requireInterviewId(interviewId);
  284. // Add a new message simulating agent response after interview creation
  285. // This ensures baseMessageCount (3) < current messages length
  286. messagesData.push({
  287. info: { role: 'assistant' },
  288. parts: [
  289. {
  290. type: 'text',
  291. text: 'Acknowledged.\n<interview_state>\n{\n "summary": "Building App1",\n "questions": [\n {\n "id": "q-2",\n "question": "What color?",\n "options": ["Red", "Blue"],\n "suggested": "Blue"\n }\n ]\n}\n</interview_state>',
  292. },
  293. ],
  294. });
  295. // Submit second answer (q-2 is the active question now)
  296. await service.submitAnswers(requiredInterviewId, [
  297. { questionId: 'q-2', answer: 'Blue' },
  298. ]);
  299. // Read file after submission
  300. const interviewDir = path.join(tempDir, 'interview');
  301. const files = await fs.readdir(interviewDir);
  302. const content = await fs.readFile(
  303. path.join(interviewDir, files[0]),
  304. 'utf8',
  305. );
  306. // Verify Q/A is in history
  307. expect(content).toContain('Q: What color?');
  308. expect(content).toContain('A: Blue');
  309. // Cleanup
  310. await fs.rm(tempDir, { recursive: true, force: true });
  311. });
  312. test('replaces placeholder history on first answer submission', async () => {
  313. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  314. const messagesData: Array<{
  315. info?: { role: string };
  316. parts?: Array<{ type: string; text?: string }>;
  317. }> = [];
  318. const ctx = createMockContext({
  319. directory: tempDir,
  320. messagesData,
  321. });
  322. const service = createInterviewService(ctx);
  323. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  324. const output = { parts: [] as Array<{ type: string; text?: string }> };
  325. await service.handleCommandExecuteBefore(
  326. {
  327. command: 'interview',
  328. sessionID: 'session-placeholder',
  329. arguments: 'Placeholder Test',
  330. },
  331. output,
  332. );
  333. const interviewId = extractInterviewIdFromLastPrompt(
  334. ctx.client.session.prompt,
  335. );
  336. const requiredInterviewId = requireInterviewId(interviewId);
  337. messagesData.push({
  338. info: { role: 'assistant' },
  339. parts: [
  340. {
  341. type: 'text',
  342. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  343. },
  344. ],
  345. });
  346. await service.submitAnswers(requiredInterviewId, [
  347. { questionId: 'q-1', answer: 'Web' },
  348. ]);
  349. const interviewDir = path.join(tempDir, 'interview');
  350. const files = await fs.readdir(interviewDir);
  351. const content = await fs.readFile(
  352. path.join(interviewDir, files[0]),
  353. 'utf8',
  354. );
  355. expect(content).not.toContain('## Q&A history\n\nNo answers yet.\n\nQ:');
  356. expect(content).toContain('## Q&A history\n\nQ: What platform?\nA: Web');
  357. await fs.rm(tempDir, { recursive: true, force: true });
  358. });
  359. test('rejects concurrent submission when first request holds busy lock', async () => {
  360. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  361. // Start with empty messages
  362. const messagesData: Array<{
  363. info?: { role: string };
  364. parts?: Array<{ type: string; text?: string }>;
  365. }> = [];
  366. // Create a prompt that delays to hold the lock
  367. let promptStarted = false;
  368. const ctx = createMockContext({
  369. directory: tempDir,
  370. messagesData,
  371. promptImpl: async () => {
  372. promptStarted = true;
  373. // Delay to hold the lock during test
  374. await new Promise((resolve) => setTimeout(resolve, 200));
  375. return {};
  376. },
  377. });
  378. const service = createInterviewService(ctx);
  379. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  380. // Create interview first (baseMessageCount = 0)
  381. const output = { parts: [] as Array<{ type: string; text?: string }> };
  382. await service.handleCommandExecuteBefore(
  383. {
  384. command: 'interview',
  385. sessionID: 'session-concurrent',
  386. arguments: 'Concurrent Test',
  387. },
  388. output,
  389. );
  390. const interviewId = extractInterviewIdFromLastPrompt(
  391. ctx.client.session.prompt,
  392. );
  393. const requiredInterviewId = requireInterviewId(interviewId);
  394. // Now add the agent response with questions
  395. messagesData.push({
  396. info: { role: 'assistant' },
  397. parts: [
  398. {
  399. type: 'text',
  400. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  401. },
  402. ],
  403. });
  404. // Start first submission (will hold lock due to slow prompt)
  405. const firstSubmissionPromise = service.submitAnswers(
  406. requiredInterviewId,
  407. [{ questionId: 'q-1', answer: 'Web' }],
  408. );
  409. // Wait for prompt to start (indicates lock is acquired)
  410. while (!promptStarted) {
  411. await new Promise((resolve) => setTimeout(resolve, 10));
  412. }
  413. // Second submission should be rejected immediately (busy lock held)
  414. await expect(
  415. service.submitAnswers(requiredInterviewId, [
  416. { questionId: 'q-1', answer: 'Mobile' },
  417. ]),
  418. ).rejects.toThrow('Interview session is busy');
  419. // Wait for first submission to complete (it will succeed after 200ms delay)
  420. await firstSubmissionPromise;
  421. // Cleanup
  422. await fs.rm(tempDir, { recursive: true, force: true });
  423. });
  424. test('busy lock released when validation fails after lock acquired', async () => {
  425. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  426. // Start with empty messages
  427. const messagesData: Array<{
  428. info?: { role: string };
  429. parts?: Array<{ type: string; text?: string }>;
  430. }> = [];
  431. const ctx = createMockContext({
  432. directory: tempDir,
  433. messagesData,
  434. });
  435. const service = createInterviewService(ctx);
  436. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  437. // Create interview first (baseMessageCount = 0)
  438. const output = { parts: [] as Array<{ type: string; text?: string }> };
  439. await service.handleCommandExecuteBefore(
  440. {
  441. command: 'interview',
  442. sessionID: 'session-retry',
  443. arguments: 'Retry Test',
  444. },
  445. output,
  446. );
  447. const interviewId = extractInterviewIdFromLastPrompt(
  448. ctx.client.session.prompt,
  449. );
  450. const requiredInterviewId = requireInterviewId(interviewId);
  451. // Add agent response with questions
  452. messagesData.push({
  453. info: { role: 'assistant' },
  454. parts: [
  455. {
  456. type: 'text',
  457. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a test app",\n "questions": [\n {\n "id": "q-1",\n "question": "What platform?",\n "options": ["Web", "Mobile"],\n "suggested": "Web"\n }\n ]\n}\n</interview_state>',
  458. },
  459. ],
  460. });
  461. // First submission with invalid answer (wrong question ID)
  462. await expect(
  463. service.submitAnswers(requiredInterviewId, [
  464. { questionId: 'invalid-id', answer: 'Web' },
  465. ]),
  466. ).rejects.toThrow('Answers do not match the current interview questions');
  467. // Second submission with correct answer should succeed (lock was released)
  468. await expect(
  469. service.submitAnswers(requiredInterviewId, [
  470. { questionId: 'q-1', answer: 'Web' },
  471. ]),
  472. ).resolves.toBeUndefined();
  473. // Cleanup
  474. await fs.rm(tempDir, { recursive: true, force: true });
  475. });
  476. test('busy lock released when no active questions validation fails', async () => {
  477. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  478. // Start with empty messages (no questions)
  479. const messagesData: Array<{
  480. info?: { role: string };
  481. parts?: Array<{ type: string; text?: string }>;
  482. }> = [
  483. {
  484. info: { role: 'assistant' },
  485. parts: [
  486. {
  487. type: 'text',
  488. text: 'Waiting.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  489. },
  490. ],
  491. },
  492. ];
  493. const ctx = createMockContext({
  494. directory: tempDir,
  495. messagesData,
  496. });
  497. const service = createInterviewService(ctx);
  498. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  499. // Create interview (baseMessageCount will be 1)
  500. const output = { parts: [] as Array<{ type: string; text?: string }> };
  501. await service.handleCommandExecuteBefore(
  502. {
  503. command: 'interview',
  504. sessionID: 'session-no-questions',
  505. arguments: 'No Questions Test',
  506. },
  507. output,
  508. );
  509. const interviewId = extractInterviewIdFromLastPrompt(
  510. ctx.client.session.prompt,
  511. );
  512. const requiredInterviewId = requireInterviewId(interviewId);
  513. // Submission with no active questions should fail and release lock
  514. await expect(
  515. service.submitAnswers(requiredInterviewId, [
  516. { questionId: 'q-1', answer: 'Web' },
  517. ]),
  518. ).rejects.toThrow('There are no active interview questions to answer');
  519. // Verify state is not busy after the failed submission
  520. const state = await service.getInterviewState(requiredInterviewId);
  521. expect(state.isBusy).toBe(false);
  522. // Cleanup
  523. await fs.rm(tempDir, { recursive: true, force: true });
  524. });
  525. });
  526. describe('session interview lifecycle', () => {
  527. test('starting /interview with different idea creates fresh interview', async () => {
  528. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  529. const ctx = createMockContext({ directory: tempDir });
  530. const service = createInterviewService(ctx);
  531. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  532. const sessionID = 'session-reuse-test';
  533. // First interview with "Idea One"
  534. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  535. await service.handleCommandExecuteBefore(
  536. { command: 'interview', sessionID, arguments: 'Idea One' },
  537. output1,
  538. );
  539. const interviewId1 = extractInterviewIdFromLastPrompt(
  540. ctx.client.session.prompt,
  541. );
  542. const requiredInterviewId1 = requireInterviewId(interviewId1);
  543. // Second interview with "Idea Two" - should create fresh interview
  544. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  545. await service.handleCommandExecuteBefore(
  546. { command: 'interview', sessionID, arguments: 'Idea Two' },
  547. output2,
  548. );
  549. // Get the second interview ID (should be the last prompt call)
  550. const interviewId2 = extractInterviewIdFromLastPrompt(
  551. ctx.client.session.prompt,
  552. );
  553. const requiredInterviewId2 = requireInterviewId(interviewId2);
  554. // Should be different interview IDs
  555. expect(interviewId1).not.toBe(interviewId2);
  556. // First interview should be marked as abandoned
  557. const state1 = await service.getInterviewState(requiredInterviewId1);
  558. expect(state1.interview.status).toBe('abandoned');
  559. // Second interview should be active
  560. const state2 = await service.getInterviewState(requiredInterviewId2);
  561. expect(state2.interview.idea).toBe('Idea Two');
  562. expect(state2.interview.status).toBe('active');
  563. // Cleanup
  564. await fs.rm(tempDir, { recursive: true, force: true });
  565. });
  566. test('reusing same idea in same session returns existing interview', async () => {
  567. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  568. const ctx = createMockContext({ directory: tempDir });
  569. const service = createInterviewService(ctx);
  570. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  571. const sessionID = 'session-same-idea';
  572. // First call with "Same Idea"
  573. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  574. await service.handleCommandExecuteBefore(
  575. { command: 'interview', sessionID, arguments: 'Same Idea' },
  576. output1,
  577. );
  578. const interviewId1 = extractInterviewIdFromLastPrompt(
  579. ctx.client.session.prompt,
  580. );
  581. expect(interviewId1).not.toBeNull();
  582. // Second call with same idea - should reuse
  583. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  584. await service.handleCommandExecuteBefore(
  585. { command: 'interview', sessionID, arguments: 'Same Idea' },
  586. output2,
  587. );
  588. const interviewId2 = extractInterviewIdFromLastPrompt(
  589. ctx.client.session.prompt,
  590. );
  591. expect(interviewId2).not.toBeNull();
  592. // Should be the same interview ID
  593. expect(interviewId1).toBe(interviewId2);
  594. // Cleanup
  595. await fs.rm(tempDir, { recursive: true, force: true });
  596. });
  597. test('session.deleted event marks interview as abandoned', async () => {
  598. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  599. const ctx = createMockContext({ directory: tempDir });
  600. const service = createInterviewService(ctx);
  601. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  602. const sessionID = 'session-delete-test';
  603. // Create interview
  604. const output = { parts: [] as Array<{ type: string; text?: string }> };
  605. await service.handleCommandExecuteBefore(
  606. { command: 'interview', sessionID, arguments: 'Delete Test' },
  607. output,
  608. );
  609. const interviewId = extractInterviewIdFromLastPrompt(
  610. ctx.client.session.prompt,
  611. );
  612. const requiredInterviewId = requireInterviewId(interviewId);
  613. // Verify interview is active
  614. const stateBefore = await service.getInterviewState(requiredInterviewId);
  615. expect(stateBefore.interview.status).toBe('active');
  616. // Simulate session deletion
  617. await service.handleEvent({
  618. event: {
  619. type: 'session.deleted',
  620. properties: { sessionID },
  621. },
  622. });
  623. // Interview should now be abandoned
  624. const stateAfter = await service.getInterviewState(requiredInterviewId);
  625. expect(stateAfter.interview.status).toBe('abandoned');
  626. // Cleanup
  627. await fs.rm(tempDir, { recursive: true, force: true });
  628. });
  629. });
  630. describe('session status handling', () => {
  631. test('session.status busy marks interview as awaiting-agent', async () => {
  632. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  633. // Start with no questions (awaiting-agent state)
  634. const messagesData: Array<{
  635. info?: { role: string };
  636. parts?: Array<{ type: string; text?: string }>;
  637. }> = [
  638. {
  639. info: { role: 'assistant' },
  640. parts: [
  641. {
  642. type: 'text',
  643. text: 'Waiting for response.\n<interview_state>\n{\n "summary": "Test",\n "questions": []\n}\n</interview_state>',
  644. },
  645. ],
  646. },
  647. ];
  648. const ctx = createMockContext({
  649. directory: tempDir,
  650. messagesData,
  651. });
  652. const service = createInterviewService(ctx);
  653. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  654. const sessionID = 'session-busy-test';
  655. // Create interview
  656. const output = { parts: [] as Array<{ type: string; text?: string }> };
  657. await service.handleCommandExecuteBefore(
  658. { command: 'interview', sessionID, arguments: 'Busy Test' },
  659. output,
  660. );
  661. const interviewId = extractInterviewIdFromLastPrompt(
  662. ctx.client.session.prompt,
  663. );
  664. const requiredInterviewId = requireInterviewId(interviewId);
  665. // Initially should be awaiting-agent (no questions)
  666. const stateBefore = await service.getInterviewState(requiredInterviewId);
  667. expect(stateBefore.mode).toBe('awaiting-agent');
  668. // Simulate busy status
  669. await service.handleEvent({
  670. event: {
  671. type: 'session.status',
  672. properties: { sessionID, status: { type: 'busy' } },
  673. },
  674. });
  675. // Should still be awaiting-agent and marked busy
  676. const stateAfter = await service.getInterviewState(requiredInterviewId);
  677. expect(stateAfter.mode).toBe('awaiting-agent');
  678. expect(stateAfter.isBusy).toBe(true);
  679. // Cleanup
  680. await fs.rm(tempDir, { recursive: true, force: true });
  681. });
  682. });
  683. describe('configurable output folder', () => {
  684. test('creates interview in configured output folder', async () => {
  685. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  686. const ctx = createMockContext({ directory: tempDir });
  687. // Create service with custom output folder config
  688. const service = createInterviewService(ctx, {
  689. maxQuestions: 2,
  690. outputFolder: 'custom-interviews',
  691. autoOpenBrowser: true,
  692. });
  693. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  694. const output = { parts: [] as Array<{ type: string; text?: string }> };
  695. await service.handleCommandExecuteBefore(
  696. {
  697. command: 'interview',
  698. sessionID: 'session-custom-folder',
  699. arguments: 'Custom Folder Idea',
  700. },
  701. output,
  702. );
  703. // Check that file was created in custom folder
  704. const customDir = path.join(tempDir, 'custom-interviews');
  705. const files = await fs.readdir(customDir);
  706. expect(files.length).toBe(1);
  707. expect(files[0]).toBe('custom-folder-idea.md');
  708. // Verify the markdownPath in state points to custom folder
  709. const interviewId = extractInterviewIdFromLastPrompt(
  710. ctx.client.session.prompt,
  711. );
  712. const requiredInterviewId = requireInterviewId(interviewId);
  713. const state = await service.getInterviewState(requiredInterviewId);
  714. expect(state.markdownPath).toContain('custom-interviews');
  715. // Cleanup
  716. await fs.rm(tempDir, { recursive: true, force: true });
  717. });
  718. test('handles nested output folder paths', async () => {
  719. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  720. const ctx = createMockContext({ directory: tempDir });
  721. // Create service with nested output folder path
  722. const service = createInterviewService(ctx, {
  723. maxQuestions: 2,
  724. outputFolder: 'docs/interviews',
  725. autoOpenBrowser: true,
  726. });
  727. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  728. const output = { parts: [] as Array<{ type: string; text?: string }> };
  729. await service.handleCommandExecuteBefore(
  730. {
  731. command: 'interview',
  732. sessionID: 'session-nested',
  733. arguments: 'Nested Path Idea',
  734. },
  735. output,
  736. );
  737. // Check that file was created in nested folder
  738. const nestedDir = path.join(tempDir, 'docs', 'interviews');
  739. const files = await fs.readdir(nestedDir);
  740. expect(files.length).toBe(1);
  741. expect(files[0]).toBe('nested-path-idea.md');
  742. // Cleanup
  743. await fs.rm(tempDir, { recursive: true, force: true });
  744. });
  745. });
  746. describe('resuming with existing markdown file', () => {
  747. test('resumes existing file and sends resume prompt instead of kickoff', async () => {
  748. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  749. const ctx = createMockContext({ directory: tempDir });
  750. // Pre-create an existing interview file
  751. const interviewDir = path.join(tempDir, 'interview');
  752. await fs.mkdir(interviewDir, { recursive: true });
  753. const existingFilePath = path.join(interviewDir, 'existing-idea.md');
  754. await fs.writeFile(
  755. existingFilePath,
  756. '# Existing Idea\n\n## Current spec\n\nExisting spec content.\n\n## Q&A history\n\nQ: What platform?\nA: Web\n',
  757. 'utf8',
  758. );
  759. const service = createInterviewService(ctx);
  760. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  761. const output = { parts: [] as Array<{ type: string; text?: string }> };
  762. // Resume by referencing the existing file basename
  763. await service.handleCommandExecuteBefore(
  764. {
  765. command: 'interview',
  766. sessionID: 'session-resume',
  767. arguments: 'existing-idea',
  768. },
  769. output,
  770. );
  771. // Should send resume prompt (references existing document)
  772. const outputText = extractOutputText(output);
  773. expect(outputText).toContain('Resume the interview');
  774. expect(outputText).toContain('Existing Idea');
  775. expect(outputText).toContain('Existing spec content');
  776. // Should NOT send kickoff prompt
  777. expect(outputText).not.toContain(
  778. 'You are running an interview q&a session',
  779. );
  780. expect(outputText).not.toContain('Initial idea:');
  781. // Cleanup
  782. await fs.rm(tempDir, { recursive: true, force: true });
  783. });
  784. test('resumes by full relative path to existing file', async () => {
  785. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  786. const ctx = createMockContext({ directory: tempDir });
  787. // Pre-create an existing interview file in custom location
  788. const customDir = path.join(tempDir, 'docs');
  789. await fs.mkdir(customDir, { recursive: true });
  790. const existingFilePath = path.join(customDir, 'my-project.md');
  791. await fs.writeFile(
  792. existingFilePath,
  793. '# My Project\n\n## Current spec\n\nProject spec here.\n\n## Q&A history\n\nNo answers yet.\n',
  794. 'utf8',
  795. );
  796. const service = createInterviewService(ctx);
  797. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  798. const output = { parts: [] as Array<{ type: string; text?: string }> };
  799. // Resume by referencing the relative path
  800. await service.handleCommandExecuteBefore(
  801. {
  802. command: 'interview',
  803. sessionID: 'session-resume-path',
  804. arguments: 'docs/my-project.md',
  805. },
  806. output,
  807. );
  808. // Should send resume prompt
  809. const outputText = extractOutputText(output);
  810. expect(outputText).toContain('Resume the interview');
  811. expect(outputText).toContain('My Project');
  812. // Cleanup
  813. await fs.rm(tempDir, { recursive: true, force: true });
  814. });
  815. test('reuses same file when resuming multiple times', async () => {
  816. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  817. const ctx = createMockContext({ directory: tempDir });
  818. // Pre-create an existing interview file
  819. const interviewDir = path.join(tempDir, 'interview');
  820. await fs.mkdir(interviewDir, { recursive: true });
  821. const existingFilePath = path.join(interviewDir, 'reusable.md');
  822. await fs.writeFile(
  823. existingFilePath,
  824. '# Reusable Interview\n\n## Current spec\n\nOriginal content.\n\n## Q&A history\n\n',
  825. 'utf8',
  826. );
  827. const service = createInterviewService(ctx);
  828. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  829. // First resume
  830. const output1 = { parts: [] as Array<{ type: string; text?: string }> };
  831. await service.handleCommandExecuteBefore(
  832. {
  833. command: 'interview',
  834. sessionID: 'session-reuse-1',
  835. arguments: 'reusable',
  836. },
  837. output1,
  838. );
  839. const interviewId1 = extractInterviewIdFromLastPrompt(
  840. ctx.client.session.prompt,
  841. );
  842. // Second resume (different session, same file)
  843. const output2 = { parts: [] as Array<{ type: string; text?: string }> };
  844. await service.handleCommandExecuteBefore(
  845. {
  846. command: 'interview',
  847. sessionID: 'session-reuse-2',
  848. arguments: 'reusable',
  849. },
  850. output2,
  851. );
  852. const interviewId2 = extractInterviewIdFromLastPrompt(
  853. ctx.client.session.prompt,
  854. );
  855. // Both should reference the same file
  856. const state1 = await service.getInterviewState(
  857. requireInterviewId(interviewId1),
  858. );
  859. const state2 = await service.getInterviewState(
  860. requireInterviewId(interviewId2),
  861. );
  862. expect(state1.markdownPath).toBe(state2.markdownPath);
  863. expect(state1.markdownPath).toContain('reusable.md');
  864. // Cleanup
  865. await fs.rm(tempDir, { recursive: true, force: true });
  866. });
  867. });
  868. describe('configurable maxQuestions', () => {
  869. test('kickoff prompt references configured maxQuestions count', async () => {
  870. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  871. const ctx = createMockContext({ directory: tempDir });
  872. // Create service with custom maxQuestions
  873. const service = createInterviewService(ctx, {
  874. maxQuestions: 5,
  875. outputFolder: 'interview',
  876. autoOpenBrowser: true,
  877. });
  878. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  879. const output = { parts: [] as Array<{ type: string; text?: string }> };
  880. await service.handleCommandExecuteBefore(
  881. {
  882. command: 'interview',
  883. sessionID: 'session-max-q',
  884. arguments: 'Max Questions Test',
  885. },
  886. output,
  887. );
  888. // Kickoff prompt should reference the configured maxQuestions
  889. const outputText = extractOutputText(output);
  890. expect(outputText).toContain('at most 5 questions');
  891. expect(outputText).toContain('Return 0 to 5 questions');
  892. expect(outputText).toContain('Do not ask more than 5 questions');
  893. // Cleanup
  894. await fs.rm(tempDir, { recursive: true, force: true });
  895. });
  896. test('resume prompt references configured maxQuestions count', async () => {
  897. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  898. const ctx = createMockContext({ directory: tempDir });
  899. // Pre-create an existing file to trigger resume
  900. const interviewDir = path.join(tempDir, 'interview');
  901. await fs.mkdir(interviewDir, { recursive: true });
  902. await fs.writeFile(
  903. path.join(interviewDir, 'resume-max.md'),
  904. '# Resume Max\n\n## Current spec\n\nSpec.\n\n## Q&A history\n\n',
  905. 'utf8',
  906. );
  907. // Create service with custom maxQuestions
  908. const service = createInterviewService(ctx, {
  909. maxQuestions: 3,
  910. outputFolder: 'interview',
  911. autoOpenBrowser: true,
  912. });
  913. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  914. const output = { parts: [] as Array<{ type: string; text?: string }> };
  915. await service.handleCommandExecuteBefore(
  916. {
  917. command: 'interview',
  918. sessionID: 'session-resume-max',
  919. arguments: 'resume-max',
  920. },
  921. output,
  922. );
  923. // Resume prompt should reference the configured maxQuestions
  924. const outputText = extractOutputText(output);
  925. expect(outputText).toContain('up to 3 at a time');
  926. // Cleanup
  927. await fs.rm(tempDir, { recursive: true, force: true });
  928. });
  929. test('state exposes at most configured maxQuestions questions', async () => {
  930. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  931. // Start with empty messages
  932. const messagesData: Array<{
  933. info?: { role: string };
  934. parts?: Array<{ type: string; text?: string }>;
  935. }> = [];
  936. const ctx = createMockContext({
  937. directory: tempDir,
  938. messagesData,
  939. });
  940. // Create service with maxQuestions = 2
  941. const service = createInterviewService(ctx, {
  942. maxQuestions: 2,
  943. outputFolder: 'interview',
  944. autoOpenBrowser: true,
  945. });
  946. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  947. const output = { parts: [] as Array<{ type: string; text?: string }> };
  948. // Create interview first (baseMessageCount = 0)
  949. await service.handleCommandExecuteBefore(
  950. {
  951. command: 'interview',
  952. sessionID: 'session-parse-max',
  953. arguments: 'Parse Max Test',
  954. },
  955. output,
  956. );
  957. const interviewId = extractInterviewIdFromLastPrompt(
  958. ctx.client.session.prompt,
  959. );
  960. const requiredInterviewId = requireInterviewId(interviewId);
  961. // Now add the agent response with more questions than maxQuestions
  962. messagesData.push({
  963. info: { role: 'assistant' },
  964. parts: [
  965. {
  966. type: 'text',
  967. text: 'Questions.\n<interview_state>\n{\n "summary": "Test",\n "questions": [\n {"id": "q-1", "question": "Q1?", "options": ["A", "B"]},\n {"id": "q-2", "question": "Q2?", "options": ["A", "B"]},\n {"id": "q-3", "question": "Q3?", "options": ["A", "B"]},\n {"id": "q-4", "question": "Q4?", "options": ["A", "B"]}\n ]\n}\n</interview_state>',
  968. },
  969. ],
  970. });
  971. // State should only expose at most maxQuestions questions
  972. const state = await service.getInterviewState(requiredInterviewId);
  973. expect(state.questions.length).toBeLessThanOrEqual(2);
  974. expect(state.questions.length).toBe(2);
  975. // Cleanup
  976. await fs.rm(tempDir, { recursive: true, force: true });
  977. });
  978. test('answer prompt references configured maxQuestions count', async () => {
  979. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  980. // Start with empty messages
  981. const messagesData: Array<{
  982. info?: { role: string };
  983. parts?: Array<{ type: string; text?: string }>;
  984. }> = [];
  985. const ctx = createMockContext({
  986. directory: tempDir,
  987. messagesData,
  988. });
  989. // Create service with custom maxQuestions
  990. const service = createInterviewService(ctx, {
  991. maxQuestions: 4,
  992. outputFolder: 'interview',
  993. autoOpenBrowser: true,
  994. });
  995. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  996. // Create interview first (baseMessageCount = 0)
  997. const output = { parts: [] as Array<{ type: string; text?: string }> };
  998. await service.handleCommandExecuteBefore(
  999. {
  1000. command: 'interview',
  1001. sessionID: 'session-answer-max',
  1002. arguments: 'Answer Max Test',
  1003. },
  1004. output,
  1005. );
  1006. const interviewId = extractInterviewIdFromLastPrompt(
  1007. ctx.client.session.prompt,
  1008. );
  1009. const requiredInterviewId = requireInterviewId(interviewId);
  1010. // Now add the agent response with a question
  1011. messagesData.push({
  1012. info: { role: 'assistant' },
  1013. parts: [
  1014. {
  1015. type: 'text',
  1016. text: 'Question.\n<interview_state>\n{\n "summary": "Test",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1017. },
  1018. ],
  1019. });
  1020. // Clear previous prompt calls to capture the answer prompt
  1021. ctx.client.session.promptAsync.mock.calls.length = 0;
  1022. // Submit an answer
  1023. const answers: InterviewAnswer[] = [{ questionId: 'q-1', answer: 'A' }];
  1024. await service.submitAnswers(requiredInterviewId, answers);
  1025. // Answer prompt should reference the configured maxQuestions
  1026. const lastPromptText = getPromptTexts(
  1027. ctx.client.session.promptAsync,
  1028. ).join('\n');
  1029. expect(lastPromptText).toContain('Return 0 to 4 questions');
  1030. // Cleanup
  1031. await fs.rm(tempDir, { recursive: true, force: true });
  1032. });
  1033. });
  1034. describe('agent-provided title', () => {
  1035. test('renames file when assistant provides title in interview_state', async () => {
  1036. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1037. // Start with empty messages
  1038. const messagesData: Array<{
  1039. info?: { role: string };
  1040. parts?: Array<{ type: string; text?: string }>;
  1041. }> = [];
  1042. const ctx = createMockContext({
  1043. directory: tempDir,
  1044. messagesData,
  1045. });
  1046. const service = createInterviewService(ctx);
  1047. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1048. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1049. // Create interview with user's idea
  1050. await service.handleCommandExecuteBefore(
  1051. {
  1052. command: 'interview',
  1053. sessionID: 'session-title-test',
  1054. arguments: 'My Great App Idea With Long Description',
  1055. },
  1056. output,
  1057. );
  1058. // Initial file should use slugified user input
  1059. const interviewDir = path.join(tempDir, 'interview');
  1060. let files = await fs.readdir(interviewDir);
  1061. expect(files.length).toBe(1);
  1062. expect(files[0]).toBe('my-great-app-idea-with-long-description.md');
  1063. const interviewId = extractInterviewIdFromLastPrompt(
  1064. ctx.client.session.prompt,
  1065. );
  1066. const requiredInterviewId = requireInterviewId(interviewId);
  1067. // Now add agent response with a concise title
  1068. messagesData.push({
  1069. info: { role: 'assistant' },
  1070. parts: [
  1071. {
  1072. type: 'text',
  1073. text: 'Here are some questions.\n<interview_state>\n{\n "summary": "Building a task management app",\n "title": "task-manager",\n "questions": [{"id": "q-1", "question": "What platform?", "options": ["Web", "Mobile"]}]\n}\n</interview_state>',
  1074. },
  1075. ],
  1076. });
  1077. // Sync interview (this triggers the rename)
  1078. const state = await service.getInterviewState(requiredInterviewId);
  1079. // File should be renamed to use assistant-provided title
  1080. files = await fs.readdir(interviewDir);
  1081. expect(files.length).toBe(1);
  1082. expect(files[0]).toBe('task-manager.md');
  1083. expect(state.markdownPath).toContain('task-manager.md');
  1084. // Cleanup
  1085. await fs.rm(tempDir, { recursive: true, force: true });
  1086. });
  1087. test('keeps original filename when assistant omits title', async () => {
  1088. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1089. const messagesData: Array<{
  1090. info?: { role: string };
  1091. parts?: Array<{ type: string; text?: string }>;
  1092. }> = [];
  1093. const ctx = createMockContext({
  1094. directory: tempDir,
  1095. messagesData,
  1096. });
  1097. const service = createInterviewService(ctx);
  1098. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1099. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1100. await service.handleCommandExecuteBefore(
  1101. {
  1102. command: 'interview',
  1103. sessionID: 'session-no-title',
  1104. arguments: 'Simple Idea',
  1105. },
  1106. output,
  1107. );
  1108. const interviewDir = path.join(tempDir, 'interview');
  1109. let files = await fs.readdir(interviewDir);
  1110. expect(files[0]).toBe('simple-idea.md');
  1111. const interviewId = extractInterviewIdFromLastPrompt(
  1112. ctx.client.session.prompt,
  1113. );
  1114. const requiredInterviewId = requireInterviewId(interviewId);
  1115. // Agent response without title field
  1116. messagesData.push({
  1117. info: { role: 'assistant' },
  1118. parts: [
  1119. {
  1120. type: 'text',
  1121. text: 'Questions.\n<interview_state>\n{\n "summary": "Building an app",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1122. },
  1123. ],
  1124. });
  1125. const state = await service.getInterviewState(requiredInterviewId);
  1126. // Filename should remain unchanged
  1127. files = await fs.readdir(interviewDir);
  1128. expect(files[0]).toBe('simple-idea.md');
  1129. expect(state.markdownPath).toContain('simple-idea.md');
  1130. // Cleanup
  1131. await fs.rm(tempDir, { recursive: true, force: true });
  1132. });
  1133. test('does not rename if target filename already exists', async () => {
  1134. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1135. const messagesData: Array<{
  1136. info?: { role: string };
  1137. parts?: Array<{ type: string; text?: string }>;
  1138. }> = [];
  1139. const ctx = createMockContext({
  1140. directory: tempDir,
  1141. messagesData,
  1142. });
  1143. // Pre-create a file with the target name
  1144. const interviewDir = path.join(tempDir, 'interview');
  1145. await fs.mkdir(interviewDir, { recursive: true });
  1146. await fs.writeFile(
  1147. path.join(interviewDir, 'target-name.md'),
  1148. '# Existing\n\n## Current spec\n\nExisting.\n\n## Q&A history\n\n',
  1149. 'utf8',
  1150. );
  1151. const service = createInterviewService(ctx);
  1152. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1153. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1154. await service.handleCommandExecuteBefore(
  1155. {
  1156. command: 'interview',
  1157. sessionID: 'session-existing',
  1158. arguments: 'Original Idea',
  1159. },
  1160. output,
  1161. );
  1162. let files = await fs.readdir(interviewDir);
  1163. expect(files).toContain('original-idea.md');
  1164. expect(files).toContain('target-name.md');
  1165. const interviewId = extractInterviewIdFromLastPrompt(
  1166. ctx.client.session.prompt,
  1167. );
  1168. const requiredInterviewId = requireInterviewId(interviewId);
  1169. // Agent suggests a title that matches existing file
  1170. messagesData.push({
  1171. info: { role: 'assistant' },
  1172. parts: [
  1173. {
  1174. type: 'text',
  1175. text: 'Questions.\n<interview_state>\n{\n "summary": "Building an app",\n "title": "target-name",\n "questions": [{"id": "q-1", "question": "What?", "options": ["A", "B"]}]\n}\n</interview_state>',
  1176. },
  1177. ],
  1178. });
  1179. const state = await service.getInterviewState(requiredInterviewId);
  1180. // Should not rename (would overwrite existing file)
  1181. files = await fs.readdir(interviewDir);
  1182. expect(files).toContain('original-idea.md');
  1183. expect(files).toContain('target-name.md');
  1184. expect(state.markdownPath).toContain('original-idea.md');
  1185. // Cleanup
  1186. await fs.rm(tempDir, { recursive: true, force: true });
  1187. });
  1188. });
  1189. describe('autoOpenBrowser config', () => {
  1190. test('does not open a browser during automated test runtimes', async () => {
  1191. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1192. const ctx = createMockContext({ directory: tempDir });
  1193. const { service, openBrowserMock } = createTestService(
  1194. ctx,
  1195. {
  1196. maxQuestions: 2,
  1197. outputFolder: 'interview',
  1198. autoOpenBrowser: true,
  1199. },
  1200. {
  1201. env: createRuntimeEnv({
  1202. NODE_ENV: 'test',
  1203. CI: '0',
  1204. }),
  1205. },
  1206. );
  1207. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1208. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1209. await service.handleCommandExecuteBefore(
  1210. {
  1211. command: 'interview',
  1212. sessionID: 'session-browser-test-env',
  1213. arguments: 'Browser Test Env',
  1214. },
  1215. output,
  1216. );
  1217. expect(openBrowserMock).not.toHaveBeenCalled();
  1218. await fs.rm(tempDir, { recursive: true, force: true });
  1219. });
  1220. test('does not open a browser in CI even when auto-open is enabled', async () => {
  1221. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1222. const ctx = createMockContext({ directory: tempDir });
  1223. const { service, openBrowserMock } = createTestService(
  1224. ctx,
  1225. {
  1226. maxQuestions: 2,
  1227. outputFolder: 'interview',
  1228. autoOpenBrowser: true,
  1229. },
  1230. {
  1231. env: createRuntimeEnv({
  1232. CI: 'true',
  1233. }),
  1234. },
  1235. );
  1236. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1237. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1238. await service.handleCommandExecuteBefore(
  1239. {
  1240. command: 'interview',
  1241. sessionID: 'session-browser-ci-env',
  1242. arguments: 'Browser CI Env',
  1243. },
  1244. output,
  1245. );
  1246. expect(openBrowserMock).not.toHaveBeenCalled();
  1247. await fs.rm(tempDir, { recursive: true, force: true });
  1248. });
  1249. test('uses injected browser opener instead of opening a real browser in tests', async () => {
  1250. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1251. const ctx = createMockContext({ directory: tempDir });
  1252. const { service, openBrowserMock } = createTestService(
  1253. ctx,
  1254. {
  1255. maxQuestions: 2,
  1256. outputFolder: 'interview',
  1257. autoOpenBrowser: true,
  1258. },
  1259. {
  1260. env: createRuntimeEnv({
  1261. NODE_ENV: 'development',
  1262. CI: '0',
  1263. }),
  1264. },
  1265. );
  1266. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1267. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1268. await service.handleCommandExecuteBefore(
  1269. {
  1270. command: 'interview',
  1271. sessionID: 'session-browser-open',
  1272. arguments: 'Browser Open Test',
  1273. },
  1274. output,
  1275. );
  1276. expect(openBrowserMock).toHaveBeenCalledTimes(1);
  1277. await fs.rm(tempDir, { recursive: true, force: true });
  1278. });
  1279. test('kickoff prompt includes title field guidance', async () => {
  1280. const tempDir = await fs.mkdtemp('/tmp/interview-test-');
  1281. const ctx = createMockContext({ directory: tempDir });
  1282. const service = createInterviewService(ctx, {
  1283. maxQuestions: 2,
  1284. outputFolder: 'interview',
  1285. autoOpenBrowser: true,
  1286. });
  1287. service.setBaseUrlResolver(async () => 'http://localhost:9999');
  1288. const output = { parts: [] as Array<{ type: string; text?: string }> };
  1289. await service.handleCommandExecuteBefore(
  1290. {
  1291. command: 'interview',
  1292. sessionID: 'session-browser-config',
  1293. arguments: 'Browser Config Test',
  1294. },
  1295. output,
  1296. );
  1297. // Kickoff prompt should mention title field
  1298. const outputText = extractOutputText(output);
  1299. expect(outputText).toContain('"title":');
  1300. expect(outputText).toContain('concise-kebab-case-title-for-filename');
  1301. // Cleanup
  1302. await fs.rm(tempDir, { recursive: true, force: true });
  1303. });
  1304. });
  1305. });
  1306. describe('renderInterviewPage', () => {
  1307. test('escapes HTML special characters in interviewId for title', () => {
  1308. const maliciousId = '<script>alert("xss")</script>';
  1309. const html = renderInterviewPage(maliciousId, maliciousId);
  1310. // Should not contain raw script tags in title
  1311. expect(html).not.toContain('<title>Interview <script>');
  1312. // Should contain escaped version in title
  1313. expect(html).toContain(
  1314. '<title>Interview &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;</title>',
  1315. );
  1316. });
  1317. test('escapes ampersand in interviewId', () => {
  1318. const idWithAmpersand = 'A&B Test';
  1319. const html = renderInterviewPage(idWithAmpersand, idWithAmpersand);
  1320. expect(html).toContain('<title>Interview A&amp;B Test</title>');
  1321. expect(html).not.toContain('<title>Interview A&B Test</title>');
  1322. });
  1323. test('escapes single quotes in interviewId', () => {
  1324. const idWithQuote = "test'quote";
  1325. const html = renderInterviewPage(idWithQuote, idWithQuote);
  1326. expect(html).toContain('<title>Interview test&#39;quote</title>');
  1327. });
  1328. test('preserves safe interviewId characters', () => {
  1329. const safeId = 'my-interview-123_test';
  1330. const html = renderInterviewPage(safeId, safeId);
  1331. expect(html).toContain(`<title>Interview ${safeId}</title>`);
  1332. });
  1333. test('interviewId in JSON script tag is properly stringified', () => {
  1334. const idWithQuotes = 'test"onclick"evil';
  1335. const html = renderInterviewPage(idWithQuotes, idWithQuotes);
  1336. // The interviewId in the JavaScript should be JSON.stringify'd
  1337. // JSON.stringify escapes quotes as \"
  1338. expect(html).toContain('const interviewId = ');
  1339. // The actual output has escaped quotes for JavaScript string
  1340. expect(html).toContain('"test\\"onclick\\"evil"');
  1341. });
  1342. test('does not inject raw interviewId into HTML title', () => {
  1343. const xssAttempt = '<img src=x onerror=alert(1)>';
  1344. const html = renderInterviewPage(xssAttempt, xssAttempt);
  1345. // Title should be escaped
  1346. expect(html).not.toContain(`<title>Interview ${xssAttempt}</title>`);
  1347. expect(html).toContain(
  1348. '<title>Interview &lt;img src=x onerror=alert(1)&gt;</title>',
  1349. );
  1350. });
  1351. test('renders a self-contained brand mark', () => {
  1352. const html = renderInterviewPage('brand-test', 'brand-test');
  1353. expect(html).toContain('<svg');
  1354. expect(html).not.toContain('https://ohmyopencodeslim.com');
  1355. });
  1356. test('includes smooth scroll-to-top behavior in the submit handler', () => {
  1357. const html = renderInterviewPage('scroll-test', 'scroll-test');
  1358. expect(html).toContain('function scrollToTop()');
  1359. expect(html).toContain(
  1360. "window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });",
  1361. );
  1362. expect(html).toContain(
  1363. 'overlayText.textContent = "Submitting Answers...";',
  1364. );
  1365. expect(html).toContain('scrollToTop();');
  1366. });
  1367. });
  1368. /** Discover a free port by briefly binding to port 0, then closing. */
  1369. async function findFreePort(): Promise<number> {
  1370. return new Promise((resolve, reject) => {
  1371. const srv = createServer();
  1372. srv.listen(0, '127.0.0.1', () => {
  1373. const addr = srv.address();
  1374. srv.close(() => {
  1375. if (!addr || typeof addr === 'string') {
  1376. reject(new Error('Failed to find free port'));
  1377. return;
  1378. }
  1379. resolve(addr.port);
  1380. });
  1381. });
  1382. srv.on('error', reject);
  1383. });
  1384. }
  1385. describe('interview server port configuration', () => {
  1386. const noopDeps = {
  1387. getState: mock(
  1388. async (_id: string) =>
  1389. ({
  1390. interview: {
  1391. id: 'x',
  1392. idea: 'x',
  1393. status: 'active',
  1394. markdownPath: 'x',
  1395. },
  1396. questions: [],
  1397. mode: 'awaiting-agent' as const,
  1398. isBusy: false,
  1399. }) as any,
  1400. ),
  1401. submitAnswers: mock(async (_id: string, _answers: InterviewAnswer[]) => {}),
  1402. };
  1403. test('server starts on a specific port when port is non-zero', async () => {
  1404. const freePort = await findFreePort();
  1405. const server = createInterviewServer({ ...noopDeps, port: freePort });
  1406. try {
  1407. const baseUrl = await server.ensureStarted();
  1408. expect(baseUrl).toBe(`http://127.0.0.1:${freePort}`);
  1409. } finally {
  1410. server.close();
  1411. }
  1412. });
  1413. test('server starts on a random port when port is 0', async () => {
  1414. const server = createInterviewServer({ ...noopDeps, port: 0 });
  1415. try {
  1416. const baseUrl = await server.ensureStarted();
  1417. expect(baseUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
  1418. const portStr = baseUrl.split(':').pop();
  1419. const port = Number.parseInt(portStr ?? '0', 10);
  1420. expect(port).toBeGreaterThan(0);
  1421. } finally {
  1422. server.close();
  1423. }
  1424. });
  1425. test('baseUrl contains the correct port number for fixed port', async () => {
  1426. const freePort = await findFreePort();
  1427. const server = createInterviewServer({ ...noopDeps, port: freePort });
  1428. try {
  1429. const baseUrl = await server.ensureStarted();
  1430. const portStr = baseUrl.split(':').pop();
  1431. const port = Number.parseInt(portStr ?? '0', 10);
  1432. expect(port).toBe(freePort);
  1433. } finally {
  1434. server.close();
  1435. }
  1436. });
  1437. test('baseUrl contains a valid port number for random port', async () => {
  1438. const server = createInterviewServer({ ...noopDeps, port: 0 });
  1439. try {
  1440. const baseUrl = await server.ensureStarted();
  1441. const portStr = baseUrl.split(':').pop();
  1442. const port = Number.parseInt(portStr ?? '0', 10);
  1443. expect(port).toBeGreaterThanOrEqual(1);
  1444. expect(port).toBeLessThanOrEqual(65535);
  1445. } finally {
  1446. server.close();
  1447. }
  1448. });
  1449. test('rejects with friendly error when port is already in use', async () => {
  1450. // Occupy a port first
  1451. const blocker = createServer();
  1452. const occupiedPort = await new Promise<number>((resolve, reject) => {
  1453. blocker.listen(0, '127.0.0.1', () => {
  1454. const addr = blocker.address();
  1455. if (!addr || typeof addr === 'string') {
  1456. reject(new Error('Failed to bind blocker'));
  1457. return;
  1458. }
  1459. resolve(addr.port);
  1460. });
  1461. blocker.on('error', reject);
  1462. });
  1463. const server = createInterviewServer({
  1464. ...noopDeps,
  1465. port: occupiedPort,
  1466. });
  1467. try {
  1468. await expect(server.ensureStarted()).rejects.toThrow(
  1469. `Interview server port ${occupiedPort} is already in use`,
  1470. );
  1471. } finally {
  1472. server.close();
  1473. blocker.close();
  1474. }
  1475. });
  1476. });
  1477. describe('InterviewConfigSchema port validation', () => {
  1478. test('accepts valid port 0', () => {
  1479. const result = InterviewConfigSchema.parse({ port: 0 });
  1480. expect(result.port).toBe(0);
  1481. });
  1482. test('accepts valid port 8080', () => {
  1483. const result = InterviewConfigSchema.parse({ port: 8080 });
  1484. expect(result.port).toBe(8080);
  1485. });
  1486. test('accepts valid port 65535', () => {
  1487. const result = InterviewConfigSchema.parse({ port: 65535 });
  1488. expect(result.port).toBe(65535);
  1489. });
  1490. test('defaults port to 0 when omitted', () => {
  1491. const result = InterviewConfigSchema.parse({});
  1492. expect(result.port).toBe(0);
  1493. });
  1494. test('rejects negative port', () => {
  1495. expect(() => InterviewConfigSchema.parse({ port: -1 })).toThrow();
  1496. });
  1497. test('rejects port above 65535', () => {
  1498. expect(() => InterviewConfigSchema.parse({ port: 70000 })).toThrow();
  1499. });
  1500. test('rejects float port', () => {
  1501. expect(() => InterviewConfigSchema.parse({ port: 3.5 })).toThrow();
  1502. });
  1503. });