| 1 | import {mkdtemp, mkdir, readFile, stat, writeFile} from 'node:fs/promises'; |
| 2 | import os from 'node:os'; |
| 3 | import path from 'node:path'; |
| 4 | import {afterEach, describe, expect, it} from 'vitest'; |
| 5 | import {deleteSession, listSessionArtifacts, readSessionHistory, readSessionState, resolveArtifactPath, storeWorkspaceUpload} from './server-lib.mjs'; |
| 6 | |
| 7 | const roots = []; |
| 8 | |
| 9 | afterEach(async () => { |
| 10 | const {rm} = await import('node:fs/promises'); |
| 11 | await Promise.all(roots.splice(0).map((root) => rm(root, {recursive: true, force: true}))); |
| 12 | }); |
| 13 | |
| 14 | async function fixture() { |
| 15 | const root = await mkdtemp(path.join(os.tmpdir(), 'vimax-web-')); |
| 16 | roots.push(root); |
| 17 | await mkdir(path.join(root, '.vimax', 'logs'), {recursive: true}); |
| 18 | await mkdir(path.join(root, '.working_dir', 'session-1', 'script2video', 'shots', '0'), {recursive: true}); |
| 19 | await writeFile(path.join(root, '.vimax', 'sessions.json'), JSON.stringify({ |
| 20 | active_session_id: 'session-1', |
| 21 | sessions: { |
| 22 | 'session-1': {session_id: 'session-1', project_name: 'Ocean campaign', working_dir: '.working_dir/session-1', stage: 'rendering', updated_at: '2026-07-17T10:00:00'}, |
| 23 | }, |
| 24 | })); |
| 25 | return root; |
| 26 | } |
| 27 | |
| 28 | describe('web bridge state', () => { |
| 29 | it('returns sanitized session records', async () => { |
| 30 | const root = await fixture(); |
| 31 | const state = await readSessionState(root); |
| 32 | expect(state.activeSessionId).toBe('session-1'); |
| 33 | expect(state.sessions[0]).toMatchObject({sessionId: 'session-1', projectName: 'Ocean campaign', stage: 'rendering'}); |
| 34 | }); |
| 35 | |
| 36 | it('restores persisted turn history', async () => { |
| 37 | const root = await fixture(); |
| 38 | await writeFile(path.join(root, '.vimax', 'logs', 'loop_history.jsonl'), `${JSON.stringify({ |
| 39 | session_id: 'session-1', turn_id: 'turn-1', raw_user_input: 'Make a film', final_assistant_text: 'Planning is ready', status: 'completed', tool_rounds: [], |
| 40 | })}\n`); |
| 41 | const history = await readSessionHistory(root, 'session-1'); |
| 42 | expect(history.map((message) => message.role)).toEqual(['user', 'assistant']); |
| 43 | }); |
| 44 | |
| 45 | it('hides workspace upload metadata from restored user messages', async () => { |
| 46 | const root = await fixture(); |
| 47 | await writeFile(path.join(root, '.vimax', 'logs', 'loop_history.jsonl'), `${JSON.stringify({ |
| 48 | session_id: 'session-1', |
| 49 | turn_id: 'turn-upload', |
| 50 | raw_user_input: 'Use this script <workspace_uploads>["uploads/script.txt"]</workspace_uploads>', |
| 51 | })}\n`); |
| 52 | const history = await readSessionHistory(root, 'session-1'); |
| 53 | expect(history[0].text).toBe('Use this script'); |
| 54 | }); |
| 55 | |
| 56 | it('does not expose successful tool result payloads in restored history', async () => { |
| 57 | const root = await fixture(); |
| 58 | await writeFile(path.join(root, '.vimax', 'logs', 'loop_history.jsonl'), `${JSON.stringify({ |
| 59 | session_id: 'session-1', |
| 60 | turn_id: 'turn-tool', |
| 61 | raw_user_input: 'Plan a video', |
| 62 | tool_rounds: [{tool_results: [{ |
| 63 | name: 'vimax_narrative_planning', |
| 64 | ok: true, |
| 65 | content: JSON.stringify({session_id: 'session-1', working_dir: '.working_dir/session-1', generated: ['script.json']}), |
| 66 | }]}], |
| 67 | })}\n`); |
| 68 | const history = await readSessionHistory(root, 'session-1'); |
| 69 | const activity = history.find((message) => message.role === 'activity'); |
| 70 | expect(activity).toMatchObject({text: 'Completed', status: 'done', stage: 'completed'}); |
| 71 | expect(JSON.stringify(activity)).not.toContain('working_dir'); |
| 72 | }); |
| 73 | |
| 74 | it('lists media artifacts and blocks path traversal', async () => { |
| 75 | const root = await fixture(); |
| 76 | await writeFile(path.join(root, '.working_dir', 'session-1', 'script2video', 'shots', '0', 'first_frame.png'), 'image'); |
| 77 | const artifacts = await listSessionArtifacts(root, 'session-1'); |
| 78 | expect(artifacts[0]).toMatchObject({kind: 'image', name: 'first_frame.png'}); |
| 79 | expect(() => resolveArtifactPath(root, 'session-1', '../../secrets')).toThrow(/escapes/); |
| 80 | }); |
| 81 | |
| 82 | it('stores uploads inside the session without overwriting matching names', async () => { |
| 83 | const root = await fixture(); |
| 84 | const first = await storeWorkspaceUpload(root, 'session-1', 'script.txt', Buffer.from('first')); |
| 85 | const second = await storeWorkspaceUpload(root, 'session-1', 'script.txt', Buffer.from('second')); |
| 86 | expect(first).toMatchObject({name: 'script.txt', path: 'uploads/script.txt', size: 5}); |
| 87 | expect(second).toMatchObject({name: 'script (2).txt', path: 'uploads/script (2).txt', size: 6}); |
| 88 | expect(await readFile(path.join(root, '.working_dir', 'session-1', second.path), 'utf8')).toBe('second'); |
| 89 | await expect(storeWorkspaceUpload(root, 'session-1', '../escape.txt', Buffer.from('no'))).rejects.toThrow(/unsupported/); |
| 90 | }); |
| 91 | |
| 92 | it('deletes project state, artifacts, and matching log records', async () => { |
| 93 | const root = await fixture(); |
| 94 | const logPath = path.join(root, '.vimax', 'logs', 'loop_history.jsonl'); |
| 95 | await writeFile(logPath, [ |
| 96 | JSON.stringify({session_id: 'session-1', raw_user_input: 'Delete me'}), |
| 97 | JSON.stringify({session_id: 'session-2', raw_user_input: 'Keep me'}), |
| 98 | '', |
| 99 | ].join('\n')); |
| 100 | |
| 101 | const state = await deleteSession(root, 'session-1'); |
| 102 | expect(state).toEqual({activeSessionId: '', sessions: []}); |
| 103 | await expect(stat(path.join(root, '.working_dir', 'session-1'))).rejects.toThrow(); |
| 104 | expect(await readFile(logPath, 'utf8')).toContain('session-2'); |
| 105 | expect(await readFile(logPath, 'utf8')).not.toContain('session-1'); |
| 106 | }); |
| 107 | }); |
| 108 |