import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { auth } from '../src/auth' import { canCreateProject, store } from '../src/store' import type { ConversationView } from '../src/api' import type { Project } from '../src/contracts' const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' }) const task = (id: string, projectId: string, scratch = false, updatedAt = ''): ConversationView => ({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) }) const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) const detail = (conversation: ConversationView) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }) let projects: Project[] let tasks: ConversationView[] let me: Record let requests: { path: string; init?: RequestInit }[] let scratchCount: number const posts = () => requests.filter(r => r.init?.method === 'POST').map(r => r.path) beforeEach(async () => { projects = []; tasks = []; requests = []; scratchCount = 0 me = { owner: 'bob', name: 'Bob', auth: true, systemActions: [] } const saved = new Map() vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null, setItem: (key: string, value: string) => { saved.set(key, value) }, removeItem: (key: string) => { saved.delete(key) } }) vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => { requests.push({ path, init }) if (path === '/api/projects' && !init?.method) return response(projects) if (path === '/api/conversations' && !init?.method) return response(tasks) if (path === '/api/me') return response(me) if (path === '/api/scratch-sessions' && init?.method === 'POST') { const id = `scratch-${++scratchCount}` const conversation = task(`${id}-conv`, id, true) tasks = [conversation, ...tasks] return response({ project: project(id), conversation }) } const created = path.match(/^\/api\/projects\/([^/]+)\/conversations$/) if (created && init?.method === 'POST') { const conversation = task(`${created[1]}-new`, created[1]) tasks = [conversation, ...tasks] return response(conversation) } const conv = path.match(/^\/api\/conversations\/([^/]+)$/) if (conv && !init?.method) { const found = tasks.find(t => t.id === conv[1]) return found ? detail(found) : response({ error: 'not found' }, 404) } if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([]) if (path.endsWith('/cards')) return response({ cards: [] }) return response({}) }) vi.stubGlobal('EventSource', class { addEventListener() {} close() {} }) auth.beginSession() await store.refreshMe() }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) describe('scratch session entry', () => { it('enters scratch mode for an account without project creation and no projects, and never creates a project', async () => { await store.refreshProjects() expect(store.getState()).toMatchObject({ scratchMode: true, projectsLoaded: true, currentProjectId: null }) requests = [] await store.createTask() expect(posts()).toEqual(['/api/scratch-sessions']) expect(requests.some(r => r.path === '/api/projects' && r.init?.method === 'POST')).toBe(false) expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-1-conv', currentProjectId: 'scratch-1', projects: [] }) }) it('always creates a scratch session in scratch mode even when real projects exist', async () => { projects = [project('host-a')] await store.refreshProjects() expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' }) await store.enterScratchMode() expect(store.getState()).toMatchObject({ scratchMode: true, currentProjectId: null, currentConvId: null }) requests = [] await store.createTask() await store.createTask() expect(posts()).toEqual(['/api/scratch-sessions', '/api/scratch-sessions']) expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-2-conv', currentProjectId: 'scratch-2' }) }) it('keeps creating project conversations in project mode', async () => { projects = [project('host-a')] await store.refreshProjects() requests = [] await store.createTask() expect(posts()).toEqual(['/api/projects/host-a/conversations']) expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'host-a-new', currentProjectId: 'host-a' }) }) it('returns to the remembered scratch view on reload and opens the latest scratch task', async () => { projects = [project('host-a')] tasks = [task('old', 'scratch-o', true, '2026-01-01'), task('new', 'scratch-n', true, '2026-02-01'), task('work', 'host-a')] await store.refreshProjects() await store.enterScratchMode() expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' }) expect(store.getState().ui.lastScratch).toBe(true) await store.refreshProjects() expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' }) await store.openProject('host-a') expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' }) expect(store.getState().ui.lastScratch).toBe(false) }) }) describe('scratch mode follows the opened task', () => { it('sets scratch mode for a scratch task and clears it for a normal task', async () => { projects = [project('host-a')] tasks = [task('scratch-conv', 'scratch-1', true), task('work', 'host-a')] await store.refreshProjects() await store.openTask('scratch-conv') expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' }) expect(store.getState().ui.lastProjectId).toBe('host-a') await store.openTask('work') expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'work', currentProjectId: 'host-a' }) }) }) describe('canCreateProject', () => { it('allows project creation without auth or with project.create only', () => { expect(canCreateProject({ auth: false })).toBe(true) expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true) expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false) expect(canCreateProject({ auth: true })).toBe(false) expect(canCreateProject(null)).toBe(false) }) })