| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303 |
- import { describe, expect, test } from 'bun:test';
- import * as fs from 'node:fs/promises';
- import { createServer } from 'node:http';
- import * as path from 'node:path';
- import { createDashboardServer } from './dashboard';
- // Helper to find a free port (matches interview.test.ts pattern)
- function findFreePort(): Promise<number> {
- return new Promise((resolve, reject) => {
- const server = createServer();
- server.listen(0, () => {
- const address = server.address();
- if (address && typeof address !== 'string') {
- const port = address.port;
- server.close(() => resolve(port));
- } else {
- server.close(() => reject(new Error('Failed to get port')));
- }
- });
- });
- }
- // Helper to start a dashboard on a free port
- async function startDashboard() {
- const port = await findFreePort();
- const dashboard = createDashboardServer({
- port,
- outputFolder: 'interview',
- });
- const baseUrl = await dashboard.start();
- return {
- dashboard,
- baseUrl,
- authToken: dashboard.authToken,
- cleanup: () => {
- dashboard.close();
- },
- };
- }
- // Helper to create a temp directory with interview files
- async function createTempInterviewDir() {
- const tempDir = await fs.mkdtemp('/tmp/dashboard-test-');
- const interviewDir = path.join(tempDir, 'interview');
- await fs.mkdir(interviewDir, { recursive: true });
- return tempDir;
- }
- describe('dashboard server', () => {
- describe('health endpoint', () => {
- test('returns 200 with status ok and counts', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/health`);
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- status: string;
- sessions: number;
- interviews: number;
- };
- expect(data.status).toBe('ok');
- expect(data.sessions).toBe(0);
- expect(data.interviews).toBe(0);
- } finally {
- cleanup();
- }
- });
- test('works without auth', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/health`);
- expect(response.status).toBe(200);
- } finally {
- cleanup();
- }
- });
- });
- describe('auth gate', () => {
- test('POST /api/register without auth returns 401', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/register`, {
- method: 'POST',
- body: JSON.stringify({
- sessionID: 'test-session',
- directory: '/test/dir',
- }),
- headers: { 'content-type': 'application/json' },
- });
- expect(response.status).toBe(401);
- } finally {
- cleanup();
- }
- });
- test('POST /api/interviews without auth returns 401', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/interviews`, {
- method: 'POST',
- body: JSON.stringify({
- interviewId: 'test-interview',
- sessionID: 'test-session',
- idea: 'Test idea',
- }),
- headers: { 'content-type': 'application/json' },
- });
- expect(response.status).toBe(401);
- } finally {
- cleanup();
- }
- });
- test('GET /api/sessions without auth returns 401', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/sessions`);
- expect(response.status).toBe(401);
- } finally {
- cleanup();
- }
- });
- });
- describe('auth methods', () => {
- test('works with ?token= query param', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/sessions?token=${authToken}`,
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as { sessions: unknown[] };
- expect(Array.isArray(data.sessions)).toBe(true);
- } finally {
- cleanup();
- }
- });
- test('works with Cookie header', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/sessions`, {
- headers: {
- cookie: `dashboard_token=${authToken}`,
- },
- });
- expect(response.status).toBe(200);
- } finally {
- cleanup();
- }
- });
- test('works with Authorization: Bearer header', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/sessions`, {
- headers: {
- authorization: `Bearer ${authToken}`,
- },
- });
- expect(response.status).toBe(200);
- } finally {
- cleanup();
- }
- });
- });
- describe('session registration (POST /api/register)', () => {
- test('registers a valid session', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/register?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- sessionID: 'session-123',
- directory: '/test/directory',
- pid: 12345,
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as { status: string };
- expect(data.status).toBe('registered');
- // Verify session is registered
- const _state = dashboard.getState('dummy-interview');
- // State doesn't exist yet, but session was registered
- } finally {
- cleanup();
- }
- });
- test('rejects missing sessionID', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/register?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- directory: '/test/directory',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- test('rejects invalid sessionID (special chars)', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/register?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- sessionID: 'session/with/slashes',
- directory: '/test/directory',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('create interview (POST /api/interviews)', () => {
- test('creates interview entry in cache', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- interviewId: 'interview-1',
- sessionID: 'session-1',
- idea: 'Test Interview',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- interviewId: string;
- url: string;
- };
- expect(data.interviewId).toBe('interview-1');
- expect(data.url).toContain('interview-1');
- // Verify interview is in cache
- const state = dashboard.getState('interview-1');
- expect(state?.interviewId).toBe('interview-1');
- expect(state?.idea).toBe('Test Interview');
- expect(state?.mode).toBe('awaiting-agent');
- } finally {
- cleanup();
- }
- });
- test('returns interview URL', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- interviewId: 'interview-2',
- sessionID: 'session-2',
- idea: 'Test URL',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- const data = (await response.json()) as { url: string };
- expect(data.url).toBe(`${baseUrl}/interview/interview-2`);
- } finally {
- cleanup();
- }
- });
- test('rejects missing fields', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- interviewId: 'interview-3',
- sessionID: 'session-3',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('state push/merge (POST /api/interviews/:id/state)', () => {
- test('creates new entry when not in cache', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews/new-interview/state?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- sessionID: 'session-new',
- idea: 'New Idea',
- summary: 'Test summary',
- questions: [
- { id: 'q-1', question: 'What?', options: ['A', 'B'] },
- ],
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- const state = dashboard.getState('new-interview');
- expect(state?.interviewId).toBe('new-interview');
- expect(state?.summary).toBe('Test summary');
- expect(state?.questions.length).toBe(1);
- } finally {
- cleanup();
- }
- });
- test('merges partial state update when entry exists', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // First push - create entry
- await fetch(
- `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- sessionID: 'session-merge',
- idea: 'Merge Test',
- summary: 'Initial summary',
- questions: [{ id: 'q-1', question: 'Q1?', options: ['A', 'B'] }],
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- // Second push - merge updates
- await fetch(
- `${baseUrl}/api/interviews/merge-test/state?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- mode: 'awaiting-user',
- summary: 'Updated summary',
- title: 'Updated Title',
- questions: [{ id: 'q-2', question: 'Q2?', options: ['C', 'D'] }],
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- const state = dashboard.getState('merge-test');
- expect(state?.mode).toBe('awaiting-user');
- expect(state?.summary).toBe('Updated summary');
- expect(state?.title).toBe('Updated Title');
- expect(state?.questions.length).toBe(1);
- expect(state?.questions[0].id).toBe('q-2');
- } finally {
- cleanup();
- }
- });
- test('rejects invalid interview ID', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews/invalid/id/state?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({}),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('get state (GET /api/interviews/:id/state)', () => {
- test('returns full state for existing interview', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create interview
- dashboard.pushState({
- interviewId: 'get-state-test',
- sessionID: 'session-get',
- idea: 'Get State Test',
- mode: 'awaiting-user',
- summary: 'Test summary',
- title: 'Test Title',
- questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/get-state-test/state?token=${authToken}`,
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- interview: { id: string; idea: string };
- mode: string;
- summary: string;
- questions: Array<{ id: string }>;
- };
- expect(data.interview.id).toBe('get-state-test');
- expect(data.interview.idea).toBe('Get State Test');
- expect(data.mode).toBe('awaiting-user');
- expect(data.summary).toBe('Test summary');
- expect(data.questions.length).toBe(1);
- } finally {
- cleanup();
- }
- });
- test('returns 404 for unknown interview', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews/unknown/state?token=${authToken}`,
- );
- expect(response.status).toBe(404);
- } finally {
- cleanup();
- }
- });
- test('includes document content from .md file when filePath points to real file', async () => {
- const tempDir = await createTempInterviewDir();
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create a markdown file
- const mdPath = path.join(tempDir, 'interview', 'doc-test.md');
- await fs.writeFile(mdPath, '# Test Document\n\nContent here.', 'utf8');
- // Register the temp directory as a session
- dashboard.registerSession({
- sessionID: 'session-doc',
- directory: tempDir,
- pid: 0,
- registeredAt: Date.now(),
- });
- // Push state with filePath
- dashboard.pushState({
- interviewId: 'doc-test',
- sessionID: 'session-doc',
- idea: 'Doc Test',
- mode: 'completed',
- summary: 'Test',
- title: 'Doc Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: mdPath,
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/doc-test/state?token=${authToken}`,
- );
- const data = (await response.json()) as { document: string };
- expect(data.document).toContain('# Test Document');
- expect(data.document).toContain('Content here.');
- await fs.rm(tempDir, { recursive: true, force: true });
- } finally {
- cleanup();
- }
- });
- test('returns isBusy true when mode is awaiting-agent', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'busy-test',
- sessionID: 'session-busy',
- idea: 'Busy Test',
- mode: 'awaiting-agent',
- summary: 'Test',
- title: 'Busy Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/busy-test/state?token=${authToken}`,
- );
- const data = (await response.json()) as { isBusy: boolean };
- expect(data.isBusy).toBe(true);
- } finally {
- cleanup();
- }
- });
- });
- describe('submit answers (POST /api/interviews/:id/answers)', () => {
- test('stores answers as pending', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create interview
- dashboard.pushState({
- interviewId: 'answers-test',
- sessionID: 'session-answers',
- idea: 'Answers Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Answers Test',
- questions: [
- {
- id: 'q-1',
- question: 'What?',
- options: ['A', 'B'],
- suggested: 'A',
- },
- ],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- // Submit answers
- const response = await fetch(
- `${baseUrl}/api/interviews/answers-test/answers?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- answers: [{ questionId: 'q-1', answer: 'A' }],
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- // Verify answers are stored
- const state = dashboard.getState('answers-test');
- expect(state?.pendingAnswers).toEqual([
- { questionId: 'q-1', answer: 'A' },
- ]);
- } finally {
- cleanup();
- }
- });
- test('sets mode to awaiting-agent', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'mode-test',
- sessionID: 'session-mode',
- idea: 'Mode Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Mode Test',
- questions: [{ id: 'q-1', question: 'What?', options: ['A', 'B'] }],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- await fetch(
- `${baseUrl}/api/interviews/mode-test/answers?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- answers: [{ questionId: 'q-1', answer: 'A' }],
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- const state = dashboard.getState('mode-test');
- expect(state?.mode).toBe('awaiting-agent');
- } finally {
- cleanup();
- }
- });
- test('rejects non-array answers', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create an interview first so the route exists
- dashboard.pushState({
- interviewId: 'invalid-answers',
- sessionID: 'session-invalid',
- idea: 'Invalid Answers',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Invalid Answers',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/invalid-answers/answers?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({
- answers: 'not-an-array',
- }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('consume pending answers (GET /api/interviews/:id/pending)', () => {
- test('returns and clears pending answers atomically', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create interview with pending answers
- dashboard.pushState({
- interviewId: 'pending-test',
- sessionID: 'session-pending',
- idea: 'Pending Test',
- mode: 'awaiting-agent',
- summary: 'Test',
- title: 'Pending Test',
- questions: [],
- pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- // First call - returns answers
- const response1 = await fetch(
- `${baseUrl}/api/interviews/pending-test/pending?token=${authToken}`,
- );
- const data1 = (await response1.json()) as {
- answers: Array<{ questionId: string; answer: string }> | null;
- };
- expect(data1.answers).toEqual([{ questionId: 'q-1', answer: 'A' }]);
- // Verify state was cleared
- const state = dashboard.getState('pending-test');
- expect(state?.pendingAnswers).toBeNull();
- } finally {
- cleanup();
- }
- });
- test('returns null on second call (already consumed)', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'consume-test',
- sessionID: 'session-consume',
- idea: 'Consume Test',
- mode: 'awaiting-agent',
- summary: 'Test',
- title: 'Consume Test',
- questions: [],
- pendingAnswers: [{ questionId: 'q-1', answer: 'A' }],
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- // First call
- await fetch(
- `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
- );
- // Second call - should return null
- const response2 = await fetch(
- `${baseUrl}/api/interviews/consume-test/pending?token=${authToken}`,
- );
- const data2 = (await response2.json()) as {
- answers: Array<{ questionId: string; answer: string }> | null;
- };
- expect(data2.answers).toBeNull();
- } finally {
- cleanup();
- }
- });
- test('returns 404 for unknown interview', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/interviews/unknown/pending?token=${authToken}`,
- );
- expect(response.status).toBe(404);
- } finally {
- cleanup();
- }
- });
- });
- describe('nudge (POST /api/interviews/:id/nudge)', () => {
- test('stores nudge action', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'nudge-test',
- sessionID: 'session-nudge',
- idea: 'Nudge Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Nudge Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/nudge-test/nudge?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({ action: 'more-questions' }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- const state = dashboard.getState('nudge-test');
- expect(state?.nudgeAction).toBe('more-questions');
- } finally {
- cleanup();
- }
- });
- test('sets mode to awaiting-agent', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'nudge-mode-test',
- sessionID: 'session-nudge-mode',
- idea: 'Nudge Mode Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Nudge Mode Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- await fetch(
- `${baseUrl}/api/interviews/nudge-mode-test/nudge?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({ action: 'confirm-complete' }),
- headers: { 'content-type': 'application/json' },
- },
- );
- const state = dashboard.getState('nudge-mode-test');
- expect(state?.mode).toBe('awaiting-agent');
- } finally {
- cleanup();
- }
- });
- test('rejects invalid action', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create an interview first so the route exists
- dashboard.pushState({
- interviewId: 'nudge-invalid',
- sessionID: 'session-nudge-invalid',
- idea: 'Nudge Invalid',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Nudge Invalid',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/nudge-invalid/nudge?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({ action: 'invalid-action' }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('consume nudge (GET /api/interviews/:id/nudge)', () => {
- test('returns and clears nudge action atomically', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'consume-nudge-test',
- sessionID: 'session-consume-nudge',
- idea: 'Consume Nudge Test',
- mode: 'awaiting-agent',
- summary: 'Test',
- title: 'Consume Nudge Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: 'more-questions',
- });
- const response = await fetch(
- `${baseUrl}/api/interviews/consume-nudge-test/nudge?token=${authToken}`,
- );
- const data = (await response.json()) as {
- action: 'more-questions' | 'confirm-complete' | null;
- };
- expect(data.action).toBe('more-questions');
- const state = dashboard.getState('consume-nudge-test');
- expect(state?.nudgeAction).toBeNull();
- } finally {
- cleanup();
- }
- });
- test('returns null on second call', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'nudge-second-test',
- sessionID: 'session-nudge-second',
- idea: 'Nudge Second Test',
- mode: 'awaiting-agent',
- summary: 'Test',
- title: 'Nudge Second Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: 'confirm-complete',
- });
- // First call
- await fetch(
- `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
- );
- // Second call
- const response2 = await fetch(
- `${baseUrl}/api/interviews/nudge-second-test/nudge?token=${authToken}`,
- );
- const data2 = (await response2.json()) as {
- action: 'more-questions' | 'confirm-complete' | null;
- };
- expect(data2.action).toBeNull();
- } finally {
- cleanup();
- }
- });
- });
- describe('interview page (GET /interview/:id)', () => {
- test('returns HTML with proper content type', async () => {
- const { baseUrl, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'page-test',
- sessionID: 'session-page',
- idea: 'Page Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Page Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(`${baseUrl}/interview/page-test`);
- expect(response.status).toBe(200);
- expect(response.headers.get('content-type')).toContain('text/html');
- const html = await response.text();
- expect(html).toContain('page-test');
- } finally {
- cleanup();
- }
- });
- test('sets session cookie', async () => {
- const { baseUrl, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'cookie-test',
- sessionID: 'session-cookie',
- idea: 'Cookie Test',
- mode: 'awaiting-user',
- summary: 'Test',
- title: 'Cookie Test',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const response = await fetch(`${baseUrl}/interview/cookie-test`);
- const cookies = response.headers.get('set-cookie');
- expect(cookies).toContain('dashboard_token=');
- expect(cookies).toContain('HttpOnly');
- } finally {
- cleanup();
- }
- });
- test('returns 400 for invalid interview ID', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/interview/invalid/id`);
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- describe('dashboard page (GET /)', () => {
- test('returns HTML', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/`);
- expect(response.status).toBe(200);
- expect(response.headers.get('content-type')).toContain('text/html');
- const html = await response.text();
- expect(html).toContain('Interview');
- } finally {
- cleanup();
- }
- });
- test('requires auth', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- // The root endpoint actually sets a cookie, so it doesn't require auth
- // Let's verify it works without auth (it sets cookie)
- const response = await fetch(`${baseUrl}/`);
- expect(response.status).toBe(200);
- } finally {
- cleanup();
- }
- });
- });
- describe('settings (GET /api/settings, POST /api/settings)', () => {
- test('GET returns current settings', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/settings?token=${authToken}`,
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- scanDays: number;
- folders: string[];
- discoveredFolders: string[];
- registeredSessions: number;
- };
- expect(typeof data.scanDays).toBe('number');
- expect(Array.isArray(data.folders)).toBe(true);
- expect(Array.isArray(data.discoveredFolders)).toBe(true);
- expect(typeof data.registeredSessions).toBe('number');
- } finally {
- cleanup();
- }
- });
- test('POST updates scan days', async () => {
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/settings?token=${authToken}`,
- {
- method: 'POST',
- body: JSON.stringify({ scanDays: 60 }),
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as { scanDays: number };
- expect(data.scanDays).toBe(60);
- // Verify it was updated
- expect(dashboard.getScanDays()).toBe(60);
- } finally {
- cleanup();
- }
- });
- test('both require auth', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const getResponse = await fetch(`${baseUrl}/api/settings`);
- expect(getResponse.status).toBe(401);
- const postResponse = await fetch(`${baseUrl}/api/settings`, {
- method: 'POST',
- body: JSON.stringify({ scanDays: 30 }),
- headers: { 'content-type': 'application/json' },
- });
- expect(postResponse.status).toBe(401);
- } finally {
- cleanup();
- }
- });
- });
- describe('direct API methods', () => {
- test('registerSession adds session to registry', async () => {
- const { baseUrl, dashboard, cleanup } = await startDashboard();
- try {
- dashboard.registerSession({
- sessionID: 'direct-session',
- directory: '/direct/dir',
- pid: 999,
- registeredAt: Date.now(),
- });
- // Verify session is registered by checking it exists
- const response = await fetch(
- `${baseUrl}/api/sessions?token=${dashboard.authToken}`,
- );
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- sessions: Array<{ sessionID: string }>;
- };
- expect(
- data.sessions.some((s) => s.sessionID === 'direct-session'),
- ).toBe(true);
- } finally {
- cleanup();
- }
- });
- test('pushState updates cache', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'direct-push',
- sessionID: 'session-direct',
- idea: 'Direct Push',
- mode: 'awaiting-user',
- summary: 'Direct',
- title: 'Direct Push',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const state = dashboard.getState('direct-push');
- expect(state?.idea).toBe('Direct Push');
- } finally {
- cleanup();
- }
- });
- test('storeAnswers stores pending answers', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'store-answers',
- sessionID: 'session-store',
- idea: 'Store Answers',
- mode: 'awaiting-user',
- summary: 'Store',
- title: 'Store Answers',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- dashboard.storeAnswers('store-answers', [
- { questionId: 'q-1', answer: 'Direct' },
- ]);
- const state = dashboard.getState('store-answers');
- expect(state?.pendingAnswers).toEqual([
- { questionId: 'q-1', answer: 'Direct' },
- ]);
- } finally {
- cleanup();
- }
- });
- test('consumePendingAnswers clears pending answers', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'consume-direct',
- sessionID: 'session-consume-direct',
- idea: 'Consume Direct',
- mode: 'awaiting-agent',
- summary: 'Consume',
- title: 'Consume Direct',
- questions: [],
- pendingAnswers: [{ questionId: 'q-1', answer: 'Test' }],
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: null,
- });
- const answers = dashboard.consumePendingAnswers('consume-direct');
- expect(answers).toEqual([{ questionId: 'q-1', answer: 'Test' }]);
- const state = dashboard.getState('consume-direct');
- expect(state?.pendingAnswers).toBeNull();
- } finally {
- cleanup();
- }
- });
- test('consumeNudgeAction clears nudge action', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.pushState({
- interviewId: 'nudge-direct',
- sessionID: 'session-nudge-direct',
- idea: 'Nudge Direct',
- mode: 'awaiting-agent',
- summary: 'Nudge',
- title: 'Nudge Direct',
- questions: [],
- pendingAnswers: null,
- lastUpdatedAt: Date.now(),
- filePath: '',
- nudgeAction: 'more-questions',
- });
- const action = dashboard.consumeNudgeAction('nudge-direct');
- expect(action).toBe('more-questions');
- const state = dashboard.getState('nudge-direct');
- expect(state?.nudgeAction).toBeNull();
- } finally {
- cleanup();
- }
- });
- test('addManualFolder and removeManualFolder', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.addManualFolder('/manual/folder1');
- expect(dashboard.getManualFolders()).toContain('/manual/folder1');
- dashboard.addManualFolder('/manual/folder2');
- expect(dashboard.getManualFolders().length).toBe(2);
- dashboard.removeManualFolder('/manual/folder1');
- expect(dashboard.getManualFolders()).not.toContain('/manual/folder1');
- expect(dashboard.getManualFolders()).toContain('/manual/folder2');
- } finally {
- cleanup();
- }
- });
- test('setScanDays and getScanDays', async () => {
- const { dashboard, cleanup } = await startDashboard();
- try {
- dashboard.setScanDays(45);
- expect(dashboard.getScanDays()).toBe(45);
- dashboard.setScanDays(0);
- expect(dashboard.getScanDays()).toBe(0);
- } finally {
- cleanup();
- }
- });
- });
- describe('file scanning (GET /api/files)', () => {
- test('lists interview files from registered sessions', async () => {
- const tempDir = await createTempInterviewDir();
- const { baseUrl, authToken, dashboard, cleanup } = await startDashboard();
- try {
- // Create a test markdown file
- const mdPath = path.join(tempDir, 'interview', 'test-file.md');
- await fs.writeFile(
- mdPath,
- '# Test File\n\n## Current spec\n\nSpec content.\n\n## Q&A history\n\n',
- 'utf8',
- );
- // Register session with the temp directory
- dashboard.registerSession({
- sessionID: 'session-files',
- directory: tempDir,
- pid: 0,
- registeredAt: Date.now(),
- });
- const response = await fetch(`${baseUrl}/api/files?token=${authToken}`);
- expect(response.status).toBe(200);
- const data = (await response.json()) as {
- files: Array<{ fileName: string; title: string }>;
- };
- // os.homedir() is always scanned, so other files may appear.
- // Assert our file is present rather than asserting exact count.
- const ourFile = data.files.find((f) => f.fileName === 'test-file.md');
- expect(ourFile).toBeDefined();
- expect(ourFile?.title).toBe('Test File');
- await fs.rm(tempDir, { recursive: true, force: true });
- } finally {
- cleanup();
- }
- });
- test('requires auth', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/files`);
- expect(response.status).toBe(401);
- } finally {
- cleanup();
- }
- });
- });
- describe('error handling', () => {
- test('returns 404 for unknown routes', async () => {
- const { baseUrl, cleanup } = await startDashboard();
- try {
- const response = await fetch(`${baseUrl}/api/unknown`);
- expect(response.status).toBe(404);
- } finally {
- cleanup();
- }
- });
- test('handles invalid JSON body', async () => {
- const { baseUrl, authToken, cleanup } = await startDashboard();
- try {
- const response = await fetch(
- `${baseUrl}/api/register?token=${authToken}`,
- {
- method: 'POST',
- body: 'invalid json',
- headers: { 'content-type': 'application/json' },
- },
- );
- expect(response.status).toBe(400);
- } finally {
- cleanup();
- }
- });
- });
- });
|