import { beforeEach, describe, expect, mock, test } from 'bun:test' const apiRequest = mock(async (_endpoint: string, _options?: RequestInit): Promise => ({ id: 'canvas-id' })) const getAuthenticatedWorkspaceId = mock(() => 'authenticated-workspace-id') void mock.module('./common.ts', () => ({ apiRequest, getAuthenticatedWorkspaceId, })) describe('canvas', () => { const legacyMessagesError = 'Use message instead of messages (pass a single CanvasMessage object, not an array)' beforeEach(() => { apiRequest.mockClear() getAuthenticatedWorkspaceId.mockClear() getAuthenticatedWorkspaceId.mockImplementation(() => 'authenticated-workspace-id') }) test('creates canvases with the canvas layout version', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Canvas', projectId: 'project-id', workspaceId: 'workspace-id', version: { title: 'v1', configuration: { model: 'gpt-5', type: 'chat' }, }, }) const [, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(body.layoutVersion).toBe('v1') expect(body.version.isWorkflow).toBe(false) }) test('creates workflow shells with the workflow layout', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Workflow', isWorkflow: true }) const [endpoint, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(endpoint).toBe('/prompt') expect(body.layoutVersion).toBe('v2') expect(body.workspaceId).toBe('authenticated-workspace-id') expect('projectId' in body).toBe(false) expect('isWorkflow' in body).toBe(false) }) test('marks an initial workflow version consistently with its layout', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Workflow', projectId: 'project-id', isWorkflow: true, version: { title: 'v1', configuration: { model: 'gpt-5', type: 'chat' }, }, }) const [, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(body.layoutVersion).toBe('v2') expect(body.version.isWorkflow).toBe(true) expect(body.version.variables).toEqual([]) expect(body.projectId).toBe('project-id') }) test('creates a personal draft with the authenticated workspace and no project id', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Personal Draft', }) const [endpoint, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(endpoint).toBe('/prompt') expect(body.title).toBe('Personal Draft') expect(body.workspaceId).toBe('authenticated-workspace-id') expect('projectId' in body).toBe(false) expect(getAuthenticatedWorkspaceId).toHaveBeenCalledTimes(1) }) test('keeps an explicit workspace id instead of inferring one', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Explicit Workspace', workspaceId: 'explicit-workspace-id', }) const [, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(body.workspaceId).toBe('explicit-workspace-id') expect(getAuthenticatedWorkspaceId).not.toHaveBeenCalled() }) test('fails clearly when the workspace cannot be inferred', async () => { const { createCanvas } = await import('./canvas.ts') getAuthenticatedWorkspaceId.mockImplementation(() => { throw new Error('The session token carries no workspace claim. Pass workspaceId explicitly.') }) let error: Error | undefined try { await createCanvas({ title: 'Missing Workspace' }) } catch (caught) { error = caught as Error } expect(error?.message).toContain('Pass workspaceId explicitly') expect(apiRequest).not.toHaveBeenCalled() }) test('treats a blank explicit workspace id as absent', async () => { const { createCanvas } = await import('./canvas.ts') await createCanvas({ title: 'Blank Workspace', workspaceId: '' }) const [, options] = apiRequest.mock.calls[0]! const body = JSON.parse(String(options?.body)) expect(body.workspaceId).toBe('authenticated-workspace-id') }) test('promotes a personal draft into a project', async () => { const { promoteCanvas } = await import('./canvas.ts') await promoteCanvas('canvas-id', 'project-id') const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/prompt/canvas-id/promote') expect(options?.method).toBe('POST') expect(JSON.parse(String(options?.body))).toEqual({ projectId: 'project-id' }) }) test('rejects legacy message arrays when creating a canvas', async () => { const { createCanvas } = await import('./canvas.ts') const create = createCanvas({ title: 'Canvas', projectId: 'project-id', workspaceId: 'workspace-id', version: { title: 'v1', configuration: { model: 'gpt-5', type: 'chat' }, // @ts-expect-error Legacy JavaScript callers can still send this field at runtime. messages: [{ role: 'system', content: 'Legacy', index: 0 }], }, }) let error: Error | undefined try { await create } catch (caught) { error = caught as Error } expect(error?.message).toContain(legacyMessagesError) expect(apiRequest).not.toHaveBeenCalled() }) test('rejects legacy message arrays when updating a canvas version', async () => { const { updateCanvasVersion } = await import('./canvas.ts') const update = updateCanvasVersion('version-id', { title: 'v2', // @ts-expect-error Legacy JavaScript callers can still send this field at runtime. messages: [{ role: 'system', content: 'Legacy', index: 0 }], }) let error: Error | undefined try { await update } catch (caught) { error = caught as Error } expect(error?.message).toContain(legacyMessagesError) expect(apiRequest).not.toHaveBeenCalled() }) test('rejects legacy message arrays when creating a canvas version', async () => { const { createCanvasVersion } = await import('./canvas.ts') const create = createCanvasVersion('canvas-id', { title: 'v2', configuration: { model: 'gpt-5', type: 'chat' }, // @ts-expect-error Legacy JavaScript callers can still send this field at runtime. messages: [{ role: 'system', content: 'Legacy', index: 0 }], }) let error: Error | undefined try { await create } catch (caught) { error = caught as Error } expect(error?.message).toContain(legacyMessagesError) expect(apiRequest).not.toHaveBeenCalled() }) test('preserves the current message when updating a canvas', async () => { const { updateCanvas } = await import('./canvas.ts') apiRequest.mockImplementation(async (endpoint: string) => { if (endpoint === '/prompt/canvas-id') { return { id: 'canvas-id', title: 'Canvas' } } if (endpoint === '/prompt-version?promptId=canvas-id') { return [ { id: 'version-id', promptId: 'canvas-id', title: 'v1', content: null, markdownContent: null, configuration: { model: 'gpt-5', type: 'chat' }, variables: [], promoted: false, draft: false, isWorkflow: false, workspaceId: 'workspace-id', createdBy: 'user-id', updatedBy: 'user-id', createdAt: '2026-06-22T10:00:00.000Z', updatedAt: '2026-06-22T10:00:00.000Z', deletedAt: null, message: { role: 'system', content: 'Current', markdownContent: 'Current', index: 0 }, }, ] } return { id: 'new-version-id' } }) await updateCanvas('canvas-id', { title: 'v2' }) const [, options] = apiRequest.mock.calls[2]! const body = JSON.parse(String(options?.body)) expect(body.message).toEqual({ role: 'system', content: 'Current', markdownContent: 'Current', index: 0 }) expect('messages' in body).toBe(false) }) test('preserves underscores in variable names when converting markdown to HTML', async () => { const { markdownToHtml } = await import('./canvas.ts') const markdown = [ '- Texto da publicação: {texto_publicacao}', '- Fase do processo: {fase_processo}', '- Garantia judicial: {garantia_judicial}', '- Órgão legal: {orgao_legal}', ].join('\n') const html = markdownToHtml(markdown) expect(html).toContain('texto_publicacao') expect(html).toContain('fase_processo') expect(html).toContain('garantia_judicial') expect(html).toContain('orgao_legal') }) test('does not replace literal sentinel-like text when restoring variables', async () => { const { markdownToHtml } = await import('./canvas.ts') const markdown = 'Literal sentinel: %%TELAVAR0%% {texto_publicacao}' const html = markdownToHtml(markdown) expect(html).toContain('Literal sentinel: %%TELAVAR0%% ') expect(html).toContain('texto_publicacao') }) test('renders markdown separator lines as horizontal rules', async () => { const { markdownToHtml } = await import('./canvas.ts') for (const separator of ['---', '***', '___']) { const html = markdownToHtml(`# Contexto\n\n${separator}\n\n# Etapas`) expect(html).toContain('
') expect(html).not.toContain(`

${separator}

`) } }) }) describe('markdownToHtml', () => { test('keeps nested ordered lists instead of flattening them into numbered paragraphs', async () => { const { markdownToHtml } = await import('./canvas.ts') const markdown = [ '1. Como diferenciar entre tipo de ação judicial e ofício:', ' 1. Ao avaliar documentos, não use apenas o órgão.', ' 2. Considere como judicial quando os dois critérios aparecem juntos:', ' 1. O documento tem origem em Tribunal de Justiça.', ].join('\n') const html = markdownToHtml(markdown) expect(html.match(/
    /g)).toHaveLength(3) expect(html).toContain('
  1. O documento tem origem em Tribunal de Justiça.
  2. ') expect(html).not.toContain('

    1.') }) test('keeps nested bullet lists and accepts the asterisk marker', async () => { const { markdownToHtml } = await import('./canvas.ts') const nested = markdownToHtml('- item a\n - sub b') expect(nested.match(/