Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
120
apps/web/tests/artifacts/manifest.test.ts
Normal file
120
apps/web/tests/artifacts/manifest.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
artifactManifestNameFor,
|
||||
createHtmlArtifactManifest,
|
||||
inferLegacyManifest,
|
||||
parseArtifactManifest,
|
||||
} from '../../src/artifacts/manifest';
|
||||
|
||||
describe('parseArtifactManifest', () => {
|
||||
it('returns null for malformed json', () => {
|
||||
expect(parseArtifactManifest('{"version":1')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when required fields are missing', () => {
|
||||
expect(parseArtifactManifest(JSON.stringify({ version: 1, kind: 'html' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for wrong version', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 2,
|
||||
kind: 'html',
|
||||
title: 'x',
|
||||
entry: 'index.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
});
|
||||
expect(parseArtifactManifest(raw)).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults status to complete when missing', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'x',
|
||||
entry: 'index.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
});
|
||||
const out = parseArtifactManifest(raw);
|
||||
expect(out?.status).toBe('complete');
|
||||
});
|
||||
|
||||
it('preserves valid status when provided', () => {
|
||||
const raw = JSON.stringify({
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'x',
|
||||
entry: 'index.html',
|
||||
renderer: 'html',
|
||||
status: 'streaming',
|
||||
exports: ['html'],
|
||||
});
|
||||
const out = parseArtifactManifest(raw);
|
||||
expect(out?.status).toBe('streaming');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferLegacyManifest', () => {
|
||||
it('infers markdown manifests for .md files', () => {
|
||||
const out = inferLegacyManifest({ entry: 'README.md' });
|
||||
expect(out?.kind).toBe('markdown-document');
|
||||
expect(out?.renderer).toBe('markdown');
|
||||
expect(out?.status).toBe('complete');
|
||||
});
|
||||
|
||||
it('infers svg manifests for .svg files', () => {
|
||||
const out = inferLegacyManifest({ entry: 'logo.svg' });
|
||||
expect(out?.kind).toBe('svg');
|
||||
expect(out?.renderer).toBe('svg');
|
||||
expect(out?.status).toBe('complete');
|
||||
});
|
||||
|
||||
it('returns null for non-artifact file types', () => {
|
||||
expect(inferLegacyManifest({ entry: 'photo.png' })).toBeNull();
|
||||
expect(inferLegacyManifest({ entry: 'archive.bin' })).toBeNull();
|
||||
});
|
||||
|
||||
it('infers React component artifacts from JSX and TSX entries', () => {
|
||||
expect(inferLegacyManifest({ entry: 'Card.jsx' })).toMatchObject({
|
||||
kind: 'react-component',
|
||||
renderer: 'react-component',
|
||||
exports: ['jsx', 'html', 'zip'],
|
||||
});
|
||||
expect(inferLegacyManifest({ entry: 'Card.tsx' })).toMatchObject({
|
||||
kind: 'react-component',
|
||||
renderer: 'react-component',
|
||||
exports: ['jsx', 'html', 'zip'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('artifactManifestNameFor', () => {
|
||||
it('handles names without extension', () => {
|
||||
expect(artifactManifestNameFor('README')).toBe('README.artifact.json');
|
||||
});
|
||||
|
||||
it('handles names with multiple dots', () => {
|
||||
expect(artifactManifestNameFor('page.v2.final.html')).toBe('page.v2.final.html.artifact.json');
|
||||
});
|
||||
|
||||
it('avoids collisions between different extensions', () => {
|
||||
expect(artifactManifestNameFor('foo.html')).not.toBe(artifactManifestNameFor('foo.md'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createHtmlArtifactManifest', () => {
|
||||
it('creates expected default html manifest shape', () => {
|
||||
const out = createHtmlArtifactManifest({ entry: 'index.html', title: 'Landing' });
|
||||
expect(out.version).toBe(1);
|
||||
expect(out.kind).toBe('html');
|
||||
expect(out.renderer).toBe('html');
|
||||
expect(out.status).toBe('complete');
|
||||
expect(out.exports).toEqual(['html', 'pdf', 'zip']);
|
||||
expect(out.entry).toBe('index.html');
|
||||
expect(out.title).toBe('Landing');
|
||||
expect(typeof out.createdAt).toBe('string');
|
||||
expect(typeof out.updatedAt).toBe('string');
|
||||
});
|
||||
});
|
||||
71
apps/web/tests/artifacts/markdown.test.ts
Normal file
71
apps/web/tests/artifacts/markdown.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { renderMarkdownToSafeHtml } from '../../src/artifacts/markdown';
|
||||
|
||||
describe('renderMarkdownToSafeHtml', () => {
|
||||
it('renders common markdown blocks', () => {
|
||||
const md = [
|
||||
'# Title',
|
||||
'',
|
||||
'Paragraph with **bold** and *italic* and `code`.',
|
||||
'',
|
||||
'- one',
|
||||
'- two',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'> note line',
|
||||
'',
|
||||
'```',
|
||||
'const x = 1 < 2;',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
const out = renderMarkdownToSafeHtml(md);
|
||||
expect(out).toContain('<h1>Title</h1>');
|
||||
expect(out).toContain('<p>Paragraph with <strong>bold</strong> and <em>italic</em> and <code>code</code>.</p>');
|
||||
expect(out).toContain('<ul><li>one</li><li>two</li></ul>');
|
||||
expect(out).toContain('<ol><li>first</li><li>second</li></ol>');
|
||||
expect(out).toContain('<blockquote>note line</blockquote>');
|
||||
expect(out).toContain('<pre><code>const x = 1 < 2;</code></pre>');
|
||||
});
|
||||
|
||||
it('escapes raw html', () => {
|
||||
const out = renderMarkdownToSafeHtml('<script>alert(1)</script>');
|
||||
expect(out).toContain('<script>alert(1)</script>');
|
||||
expect(out).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('renders safe links with target attributes', () => {
|
||||
const out = renderMarkdownToSafeHtml('[Open](https://example.com)');
|
||||
expect(out).toContain('<a href="https://example.com" rel="noreferrer noopener" target="_blank">Open</a>');
|
||||
});
|
||||
|
||||
it('keeps underscores inside href intact', () => {
|
||||
const out = renderMarkdownToSafeHtml('[x](https://example.com/a_b_c)');
|
||||
expect(out).toContain('<a href="https://example.com/a_b_c" rel="noreferrer noopener" target="_blank">x</a>');
|
||||
expect(out).not.toContain('<em>b</em>');
|
||||
});
|
||||
|
||||
it('escapes raw html inside link text', () => {
|
||||
const out = renderMarkdownToSafeHtml('[<img src=x onerror=alert(1)>](https://example.com)');
|
||||
expect(out).toContain('<img src=x onerror=alert(1)>');
|
||||
expect(out).not.toContain('<img ');
|
||||
});
|
||||
|
||||
it('keeps markdown emphasis markers literal inside inline code', () => {
|
||||
const out = renderMarkdownToSafeHtml('Use `**literal**` and `_literal_` as code.');
|
||||
expect(out).toContain('<code>**literal**</code>');
|
||||
expect(out).toContain('<code>_literal_</code>');
|
||||
expect(out).not.toContain('<code><strong>literal</strong></code>');
|
||||
expect(out).not.toContain('<code><em>literal</em></code>');
|
||||
});
|
||||
|
||||
it('does not render unsafe link protocols', () => {
|
||||
const out = renderMarkdownToSafeHtml('[Bad](javascript:alert(1))');
|
||||
expect(out).toContain('<p>Bad)</p>');
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).not.toContain('<a ');
|
||||
});
|
||||
});
|
||||
168
apps/web/tests/artifacts/renderer-registry.test.ts
Normal file
168
apps/web/tests/artifacts/renderer-registry.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DeckHtmlRenderer,
|
||||
HtmlRenderer,
|
||||
MarkdownRenderer,
|
||||
ReactComponentRenderer,
|
||||
RendererRegistry,
|
||||
SvgRenderer,
|
||||
artifactRendererRegistry,
|
||||
} from '../../src/artifacts/renderer-registry';
|
||||
import { renderMarkdownToSafeHtml } from '../../src/artifacts/markdown';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
function baseFile(overrides: Partial<ProjectFile> & Pick<ProjectFile, 'name'>): ProjectFile {
|
||||
return {
|
||||
path: 'artifact.html',
|
||||
type: 'file',
|
||||
size: 1,
|
||||
mtime: Date.now(),
|
||||
kind: 'html',
|
||||
mime: 'text/html; charset=utf-8',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RendererRegistry', () => {
|
||||
const registry = new RendererRegistry([
|
||||
ReactComponentRenderer,
|
||||
DeckHtmlRenderer,
|
||||
HtmlRenderer,
|
||||
MarkdownRenderer,
|
||||
SvgRenderer,
|
||||
]);
|
||||
|
||||
it('resolves markdown renderer from explicit manifest', () => {
|
||||
const file = baseFile({
|
||||
name: 'notes.md',
|
||||
kind: 'text',
|
||||
mime: 'text/markdown; charset=utf-8',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'markdown-document',
|
||||
title: 'Notes',
|
||||
entry: 'notes.md',
|
||||
renderer: 'markdown',
|
||||
exports: ['md', 'html'],
|
||||
},
|
||||
});
|
||||
const match = registry.resolve({ file, isDeckHint: false });
|
||||
expect(match?.renderer.id).toBe('markdown');
|
||||
expect(match?.manifest.renderer).toBe('markdown');
|
||||
});
|
||||
|
||||
it('falls back to inferred markdown manifest for .md files', () => {
|
||||
const file = baseFile({
|
||||
name: 'README.md',
|
||||
kind: 'text',
|
||||
mime: 'text/markdown; charset=utf-8',
|
||||
artifactManifest: undefined,
|
||||
});
|
||||
const match = registry.resolve({ file, isDeckHint: false });
|
||||
expect(match?.renderer.id).toBe('markdown');
|
||||
expect(match?.manifest.kind).toBe('markdown-document');
|
||||
});
|
||||
|
||||
it('resolves svg renderer from explicit manifest', () => {
|
||||
const file = baseFile({
|
||||
name: 'diagram.svg',
|
||||
kind: 'sketch',
|
||||
mime: 'image/svg+xml',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'svg',
|
||||
title: 'Diagram',
|
||||
entry: 'diagram.svg',
|
||||
renderer: 'svg',
|
||||
exports: ['svg'],
|
||||
},
|
||||
});
|
||||
const match = registry.resolve({ file, isDeckHint: false });
|
||||
expect(match?.renderer.id).toBe('svg');
|
||||
expect(match?.manifest.renderer).toBe('svg');
|
||||
});
|
||||
|
||||
it('falls back to inferred svg manifest for .svg files', () => {
|
||||
const file = baseFile({
|
||||
name: 'logo.svg',
|
||||
kind: 'sketch',
|
||||
mime: 'image/svg+xml',
|
||||
artifactManifest: undefined,
|
||||
});
|
||||
const match = registry.resolve({ file, isDeckHint: false });
|
||||
expect(match?.renderer.id).toBe('svg');
|
||||
expect(match?.manifest.kind).toBe('svg');
|
||||
});
|
||||
|
||||
it('keeps unknown files on old fallback path', () => {
|
||||
const file = baseFile({
|
||||
name: 'archive.bin',
|
||||
kind: 'binary',
|
||||
mime: 'application/octet-stream',
|
||||
artifactManifest: undefined,
|
||||
});
|
||||
expect(registry.resolve({ file, isDeckHint: false })).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes conservative streaming contract values', () => {
|
||||
expect(HtmlRenderer.supportsStreaming).toBe(false);
|
||||
expect(DeckHtmlRenderer.supportsStreaming).toBe(false);
|
||||
|
||||
expect(MarkdownRenderer.supportsStreaming).toBe(true);
|
||||
expect(MarkdownRenderer.renderPartial).toBe(renderMarkdownToSafeHtml);
|
||||
|
||||
expect(SvgRenderer.supportsStreaming).toBe(false);
|
||||
expect(SvgRenderer.renderPartial).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps markdown partial renderer output safe', () => {
|
||||
const out = MarkdownRenderer.renderPartial?.('[<script>alert(1)</script>](https://example.com/a_b_c)') ?? '';
|
||||
expect(out).toContain('<script>alert(1)</script>');
|
||||
expect(out).toContain('href="https://example.com/a_b_c"');
|
||||
expect(out).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('routes JSX and TSX files to the React component renderer', () => {
|
||||
expect(
|
||||
artifactRendererRegistry.resolve({
|
||||
file: baseFile({
|
||||
name: 'Hero.jsx',
|
||||
kind: 'code',
|
||||
mime: 'text/javascript; charset=utf-8',
|
||||
}),
|
||||
isDeckHint: false,
|
||||
})?.renderer.id,
|
||||
).toBe('react-component');
|
||||
expect(
|
||||
artifactRendererRegistry.resolve({
|
||||
file: baseFile({
|
||||
name: 'Hero.tsx',
|
||||
kind: 'code',
|
||||
mime: 'text/typescript; charset=utf-8',
|
||||
}),
|
||||
isDeckHint: false,
|
||||
})?.renderer.id,
|
||||
).toBe('react-component');
|
||||
});
|
||||
|
||||
it('prefers an explicit React manifest over the coarse code kind', () => {
|
||||
expect(
|
||||
artifactRendererRegistry.resolve({
|
||||
file: baseFile({
|
||||
name: 'entry.txt',
|
||||
kind: 'text',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'react-component',
|
||||
title: 'Entry',
|
||||
entry: 'entry.txt',
|
||||
renderer: 'react-component',
|
||||
exports: ['jsx', 'html', 'zip'],
|
||||
},
|
||||
}),
|
||||
isDeckHint: false,
|
||||
})?.renderer.id,
|
||||
).toBe('react-component');
|
||||
});
|
||||
});
|
||||
256
apps/web/tests/comments.test.ts
Normal file
256
apps/web/tests/comments.test.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildBoardCommentAttachments,
|
||||
commentsToAttachments,
|
||||
historyWithCommentAttachmentContext,
|
||||
liveSnapshotForComment,
|
||||
mergeAttachedComments,
|
||||
messageContentWithCommentAttachments,
|
||||
overlayBoundsFromSnapshot,
|
||||
removeAttachedComment,
|
||||
targetFromSnapshot,
|
||||
} from '../src/comments';
|
||||
import type { ChatMessage, PreviewComment } from '../src/types';
|
||||
|
||||
describe('preview comment attachment helpers', () => {
|
||||
it('builds compact target context from an iframe snapshot', () => {
|
||||
const target = targetFromSnapshot({
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
text: ` ${'Title '.repeat(80)} `,
|
||||
htmlHint: `<h1 class="hero-title" data-od-id="hero-title">${'x'.repeat(240)}</h1>`,
|
||||
position: { x: 10.4, y: 20.5, width: 300.2, height: 88.8 },
|
||||
});
|
||||
|
||||
expect(target.text.length).toBeLessThanOrEqual(160);
|
||||
expect(target.htmlHint.length).toBeLessThanOrEqual(180);
|
||||
expect(target.position).toEqual({ x: 10, y: 21, width: 300, height: 89 });
|
||||
});
|
||||
|
||||
it('creates ordered compact send payloads from attached comments', () => {
|
||||
const attachments = commentsToAttachments([
|
||||
comment({ id: 'c1', elementId: 'hero-title', note: 'Shorten this title' }),
|
||||
comment({ id: 'c2', elementId: 'chart', note: 'Make it feel real' }),
|
||||
]);
|
||||
|
||||
expect(attachments).toMatchObject([
|
||||
{ id: 'c1', order: 1, elementId: 'hero-title', comment: 'Shorten this title' },
|
||||
{ id: 'c2', order: 2, elementId: 'chart', comment: 'Make it feel real' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds grouped board payloads for pod selections', () => {
|
||||
const attachments = buildBoardCommentAttachments({
|
||||
target: {
|
||||
filePath: 'atlas.html',
|
||||
elementId: 'pod-1',
|
||||
selector: '[data-od-id="hero"], [data-od-id="chart"]',
|
||||
label: 'Hero and chart',
|
||||
text: 'Hero title Chart value',
|
||||
position: { x: 10, y: 20, width: 300, height: 200 },
|
||||
htmlHint: '<section data-od-id="hero">',
|
||||
selectionKind: 'pod',
|
||||
memberCount: 2,
|
||||
podMembers: [
|
||||
{
|
||||
elementId: 'hero',
|
||||
selector: '[data-od-id="hero"]',
|
||||
label: 'section.hero',
|
||||
text: 'Hero title',
|
||||
position: { x: 10, y: 20, width: 200, height: 100 },
|
||||
htmlHint: '<section data-od-id="hero">',
|
||||
},
|
||||
{
|
||||
elementId: 'chart',
|
||||
selector: '[data-od-id="chart"]',
|
||||
label: 'section.chart',
|
||||
text: 'Chart value',
|
||||
position: { x: 120, y: 80, width: 190, height: 120 },
|
||||
htmlHint: '<section data-od-id="chart">',
|
||||
},
|
||||
],
|
||||
},
|
||||
notes: ['Tighten the hierarchy', 'Make the chart feel premium'],
|
||||
});
|
||||
|
||||
expect(attachments).toHaveLength(2);
|
||||
expect(attachments[0]).toMatchObject({
|
||||
selectionKind: 'pod',
|
||||
memberCount: 2,
|
||||
source: 'board-batch',
|
||||
comment: 'Tighten the hierarchy',
|
||||
});
|
||||
expect(messageContentWithCommentAttachments('', attachments)).toContain('memberCount: 2');
|
||||
});
|
||||
|
||||
it('keeps large queued board-note batches ordered in one send payload', () => {
|
||||
const notes = Array.from({ length: 8 }, (_, index) => `Note ${index + 1}`);
|
||||
const attachments = buildBoardCommentAttachments({
|
||||
target: {
|
||||
filePath: 'atlas.html',
|
||||
elementId: 'pod-2',
|
||||
selector: '[data-od-id="card"]',
|
||||
label: 'Card pod',
|
||||
text: 'Heading Body CTA',
|
||||
position: { x: 20, y: 30, width: 240, height: 160 },
|
||||
htmlHint: '<section data-od-id="card">',
|
||||
selectionKind: 'pod',
|
||||
memberCount: 3,
|
||||
podMembers: [
|
||||
{
|
||||
elementId: 'card-heading',
|
||||
selector: '[data-od-id="card-heading"]',
|
||||
label: 'h2.card-heading',
|
||||
text: 'Heading',
|
||||
position: { x: 24, y: 34, width: 100, height: 32 },
|
||||
htmlHint: '<h2 data-od-id="card-heading">',
|
||||
},
|
||||
{
|
||||
elementId: 'card-body',
|
||||
selector: '[data-od-id="card-body"]',
|
||||
label: 'p.card-body',
|
||||
text: 'Body',
|
||||
position: { x: 24, y: 72, width: 180, height: 48 },
|
||||
htmlHint: '<p data-od-id="card-body">',
|
||||
},
|
||||
{
|
||||
elementId: 'card-cta',
|
||||
selector: '[data-od-id="card-cta"]',
|
||||
label: 'button.card-cta',
|
||||
text: 'CTA',
|
||||
position: { x: 24, y: 128, width: 96, height: 32 },
|
||||
htmlHint: '<button data-od-id="card-cta">',
|
||||
},
|
||||
],
|
||||
},
|
||||
notes,
|
||||
});
|
||||
|
||||
expect(attachments).toHaveLength(8);
|
||||
expect(attachments.map((attachment) => attachment.order)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
expect(attachments.map((attachment) => attachment.comment)).toEqual(notes);
|
||||
expect(messageContentWithCommentAttachments('', attachments)).toContain('8. pod-2');
|
||||
});
|
||||
|
||||
it('updates and removes attached comments by saved comment id', () => {
|
||||
const first = comment({ id: 'c1', elementId: 'hero-title', note: 'Original' });
|
||||
const updated = comment({ id: 'c1', elementId: 'hero-title', note: 'Updated' });
|
||||
const chart = comment({ id: 'c2', elementId: 'chart', note: 'Fix chart' });
|
||||
|
||||
const merged = mergeAttachedComments([first, chart], updated);
|
||||
expect(merged).toHaveLength(2);
|
||||
expect(merged[0]?.note).toBe('Updated');
|
||||
|
||||
const remaining = removeAttachedComment(merged, 'c1');
|
||||
expect(commentsToAttachments(remaining)).toEqual([
|
||||
expect.objectContaining({ id: 'c2', elementId: 'chart' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('converts iframe snapshot bounds into scaled overlay bounds', () => {
|
||||
expect(overlayBoundsFromSnapshot({
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
text: '',
|
||||
htmlHint: '',
|
||||
position: { x: 10, y: 20, width: 120, height: 40 },
|
||||
}, 1.25)).toEqual({
|
||||
left: 12.5,
|
||||
top: 25,
|
||||
width: 150,
|
||||
height: 50,
|
||||
});
|
||||
});
|
||||
|
||||
it('only resolves saved markers from live snapshots for the same file', () => {
|
||||
const saved = comment({ filePath: 'index.html', elementId: 'hero-title' });
|
||||
const snapshots = new Map([
|
||||
['hero-title', {
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
text: '',
|
||||
htmlHint: '',
|
||||
position: { x: 1, y: 2, width: 3, height: 4 },
|
||||
}],
|
||||
]);
|
||||
|
||||
expect(liveSnapshotForComment(saved, snapshots)?.elementId).toBe('hero-title');
|
||||
expect(liveSnapshotForComment(comment({ filePath: 'other.html' }), snapshots)).toBeNull();
|
||||
});
|
||||
|
||||
it('serializes selected comments into API-mode prompt context without visible input', () => {
|
||||
const attachments = commentsToAttachments([
|
||||
comment({ id: 'c1', elementId: 'hero-title', note: 'Only shorten this title' }),
|
||||
]);
|
||||
|
||||
const content = messageContentWithCommentAttachments('', attachments);
|
||||
|
||||
expect(content).toContain('(No extra typed instruction.)');
|
||||
expect(content).toContain('<attached-preview-comments>');
|
||||
expect(content).toContain('selector: [data-od-id="hero-title"]');
|
||||
expect(content).toContain('comment: Only shorten this title');
|
||||
});
|
||||
|
||||
it('adds hidden comment context only to the current user message sent to API providers', () => {
|
||||
const attachments = commentsToAttachments([
|
||||
comment({ id: 'c1', elementId: 'hero-title', note: 'Make it bolder' }),
|
||||
]);
|
||||
const history: ChatMessage[] = [
|
||||
{
|
||||
id: 'old',
|
||||
role: 'user',
|
||||
content: 'Previous request',
|
||||
createdAt: 0,
|
||||
commentAttachments: attachments,
|
||||
},
|
||||
{
|
||||
id: 'u1',
|
||||
role: 'user',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
commentAttachments: attachments,
|
||||
},
|
||||
{
|
||||
id: 'a1',
|
||||
role: 'assistant',
|
||||
content: 'Ready',
|
||||
createdAt: 2,
|
||||
commentAttachments: attachments,
|
||||
},
|
||||
];
|
||||
|
||||
const next = historyWithCommentAttachmentContext(history, 'u1');
|
||||
|
||||
expect(next[0]?.content).toBe('Previous request');
|
||||
expect(next[1]?.content).toContain('<attached-preview-comments>');
|
||||
expect(next[1]?.content).toContain('comment: Make it bolder');
|
||||
expect(next[2]?.content).toBe('Ready');
|
||||
expect(history[1]?.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
function comment(patch: Partial<PreviewComment>): PreviewComment {
|
||||
return {
|
||||
id: 'c1',
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
text: 'Current title',
|
||||
position: { x: 1, y: 2, width: 3, height: 4 },
|
||||
htmlHint: '<h1 data-od-id="hero-title">',
|
||||
note: 'Comment',
|
||||
status: 'open',
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
21
apps/web/tests/components/AgentIcon.test.tsx
Normal file
21
apps/web/tests/components/AgentIcon.test.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { AgentIcon } from '../../src/components/AgentIcon';
|
||||
|
||||
describe('AgentIcon', () => {
|
||||
it('renders Qoder with a dedicated supplied-mark visual', () => {
|
||||
const markup = renderToStaticMarkup(<AgentIcon id="qoder" size={24} />);
|
||||
|
||||
expect(markup).toContain('background:#111113');
|
||||
expect(markup).toContain('fill="#2ADB5C"');
|
||||
expect(markup).toContain('fill="#FFFFFF"');
|
||||
});
|
||||
|
||||
it('keeps unknown agents on the generic fallback visual', () => {
|
||||
const markup = renderToStaticMarkup(<AgentIcon id="unknown-agent" size={24} />);
|
||||
|
||||
expect(markup).toContain('linear-gradient(135deg, #6b7280 0%, #4b5563 100%)');
|
||||
expect(markup).not.toContain('fill="#2ADB5C"');
|
||||
});
|
||||
});
|
||||
66
apps/web/tests/components/AssistantMessage.test.ts
Normal file
66
apps/web/tests/components/AssistantMessage.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { assistantRoleLabel } from '../../src/components/AssistantMessage';
|
||||
import type { ChatMessage } from '../../src/types';
|
||||
|
||||
const t = () => 'Assistant';
|
||||
|
||||
describe('assistantRoleLabel', () => {
|
||||
it('prefers the persisted assistant display name over the protocol id', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'message-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
agentId: 'openai-api',
|
||||
agentName: 'OpenAI API · google/gemma-4-e4b',
|
||||
};
|
||||
|
||||
expect(assistantRoleLabel(message, t)).toBe('OpenAI API · google/gemma-4-e4b');
|
||||
});
|
||||
|
||||
it('maps API protocol ids to readable labels when no display name is saved', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'message-2',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
agentId: 'openai-api',
|
||||
};
|
||||
|
||||
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
|
||||
});
|
||||
|
||||
it('normalizes saved API protocol ids used as display names', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'message-3',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
agentName: 'openai-api',
|
||||
};
|
||||
|
||||
expect(assistantRoleLabel(message, t)).toBe('OpenAI API');
|
||||
});
|
||||
|
||||
it('preserves an explicit local agent model in the display name', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'message-4',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
agentId: 'claude',
|
||||
agentName: 'Claude · claude-sonnet-4-6',
|
||||
};
|
||||
|
||||
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
|
||||
});
|
||||
|
||||
it('adds the model reported by a local CLI initializing event', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'message-5',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
agentId: 'claude',
|
||||
agentName: 'Claude',
|
||||
events: [{ kind: 'status', label: 'initializing', detail: 'claude-sonnet-4-6' }],
|
||||
};
|
||||
|
||||
expect(assistantRoleLabel(message, t)).toBe('Claude · claude-sonnet-4-6');
|
||||
});
|
||||
});
|
||||
20
apps/web/tests/components/DesignsTab.test.ts
Normal file
20
apps/web/tests/components/DesignsTab.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { STATUS_LABEL_KEYS, STATUS_ORDER } from '../../src/components/DesignsTab';
|
||||
|
||||
describe('DesignsTab status metadata', () => {
|
||||
it('places awaiting_input between running and succeeded', () => {
|
||||
expect(STATUS_ORDER).toEqual([
|
||||
'not_started',
|
||||
'running',
|
||||
'awaiting_input',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'canceled',
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps awaiting_input to the i18n label key', () => {
|
||||
expect(STATUS_LABEL_KEYS.awaiting_input).toBe('designs.status.awaitingInput');
|
||||
});
|
||||
});
|
||||
95
apps/web/tests/components/EntryView.test.ts
Normal file
95
apps/web/tests/components/EntryView.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
isTrustedConnectorCallbackOrigin,
|
||||
sortConnectorsForDisplay,
|
||||
sortConnectorsForSearch,
|
||||
} from '../../src/components/EntryView';
|
||||
|
||||
describe('connector OAuth callback origin', () => {
|
||||
it('accepts the app origin', () => {
|
||||
expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60809', 'http://127.0.0.1:60809')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts loopback daemon origins on a different port', () => {
|
||||
expect(isTrustedConnectorCallbackOrigin('http://127.0.0.1:60807', 'http://127.0.0.1:60809')).toBe(true);
|
||||
expect(isTrustedConnectorCallbackOrigin('http://localhost:60807', 'http://127.0.0.1:60809')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-loopback origins', () => {
|
||||
expect(isTrustedConnectorCallbackOrigin('https://example.com', 'http://127.0.0.1:60809')).toBe(false);
|
||||
expect(isTrustedConnectorCallbackOrigin('file://callback', 'http://127.0.0.1:60809')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connector display sorting', () => {
|
||||
it('places connected connectors first and sorts the rest alphabetically', () => {
|
||||
const sorted = sortConnectorsForDisplay([
|
||||
{ id: 'zapi', name: 'Zapier', provider: 'Composio', category: 'Automation', status: 'available', tools: [] },
|
||||
{ id: 'gmail', name: 'Gmail', provider: 'Composio', category: 'Email', status: 'connected', tools: [] },
|
||||
{ id: 'airtable', name: 'Airtable', provider: 'Composio', category: 'Data', status: 'available', tools: [] },
|
||||
{ id: 'github', name: 'GitHub', provider: 'Composio', category: 'Code', status: 'connected', tools: [] },
|
||||
{ id: 'calendar', name: 'Calendar', provider: 'Composio', category: 'Calendar', status: 'available', tools: [] },
|
||||
]);
|
||||
|
||||
expect(sorted.map((connector) => connector.id)).toEqual([
|
||||
'github',
|
||||
'gmail',
|
||||
'airtable',
|
||||
'calendar',
|
||||
'zapi',
|
||||
]);
|
||||
});
|
||||
|
||||
it('ranks exact and prefix name/provider matches above description matches', () => {
|
||||
const sorted = sortConnectorsForSearch([
|
||||
{
|
||||
id: 'linear',
|
||||
name: 'Linear',
|
||||
provider: 'Composio',
|
||||
category: 'Project management',
|
||||
status: 'connected',
|
||||
description: 'Sync issues from GitHub repositories.',
|
||||
tools: [],
|
||||
},
|
||||
{
|
||||
id: 'github-enterprise',
|
||||
name: 'GitHub Enterprise',
|
||||
provider: 'Composio',
|
||||
category: 'Code',
|
||||
status: 'available',
|
||||
tools: [],
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
provider: 'Composio',
|
||||
category: 'Code',
|
||||
status: 'available',
|
||||
tools: [],
|
||||
},
|
||||
{
|
||||
id: 'slack',
|
||||
name: 'Slack',
|
||||
provider: 'Composio',
|
||||
category: 'Communication',
|
||||
status: 'connected',
|
||||
tools: [
|
||||
{
|
||||
title: 'Post GitHub release',
|
||||
name: 'post_github_release',
|
||||
safety: { sideEffect: 'write', approval: 'confirm', reason: 'Posts a message.' },
|
||||
refreshEligible: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
], 'github');
|
||||
|
||||
expect(sorted.map((connector) => connector.id)).toEqual([
|
||||
'github',
|
||||
'github-enterprise',
|
||||
'slack',
|
||||
'linear',
|
||||
]);
|
||||
});
|
||||
});
|
||||
278
apps/web/tests/components/FileViewer.test.tsx
Normal file
278
apps/web/tests/components/FileViewer.test.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
FileViewer,
|
||||
LiveArtifactRefreshHistoryPanel,
|
||||
SvgViewer,
|
||||
} from '../../src/components/FileViewer';
|
||||
import type { LiveArtifact, ProjectFile } from '../../src/types';
|
||||
|
||||
function baseFile(overrides: Partial<ProjectFile>): ProjectFile {
|
||||
return {
|
||||
name: 'asset.png',
|
||||
path: 'asset.png',
|
||||
type: 'file',
|
||||
size: 1024,
|
||||
mtime: 1710000000,
|
||||
kind: 'image',
|
||||
mime: 'image/png',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileViewer SVG artifacts', () => {
|
||||
it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => {
|
||||
const file = baseFile({
|
||||
name: 'diagram.svg',
|
||||
path: 'diagram.svg',
|
||||
mime: 'image/svg+xml',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'svg',
|
||||
title: 'Diagram',
|
||||
entry: 'diagram.svg',
|
||||
renderer: 'svg',
|
||||
exports: ['svg'],
|
||||
},
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
expect(markup).toContain('class="viewer svg-viewer"');
|
||||
expect(markup).not.toContain('class="viewer image-viewer"');
|
||||
expect(markup).toContain('Preview');
|
||||
expect(markup).toContain('Source');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/diagram.svg?v=1710000000&r=0"');
|
||||
});
|
||||
|
||||
it('keeps normal image artifacts on the existing image viewer path', () => {
|
||||
const file = baseFile({ name: 'photo.png', path: 'photo.png' });
|
||||
|
||||
const markup = renderToStaticMarkup(<FileViewer projectId="project-1" file={file} />);
|
||||
|
||||
expect(markup).toContain('class="viewer image-viewer"');
|
||||
expect(markup).not.toContain('class="viewer svg-viewer"');
|
||||
expect(markup).not.toContain('class="viewer-tabs"');
|
||||
});
|
||||
|
||||
it('marks preview and source modes through the SVG viewer toggle controls', () => {
|
||||
const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml' });
|
||||
|
||||
const previewMarkup = renderToStaticMarkup(
|
||||
<SvgViewer projectId="project-1" file={file} initialMode="preview" />,
|
||||
);
|
||||
const sourceMarkup = renderToStaticMarkup(
|
||||
<SvgViewer
|
||||
projectId="project-1"
|
||||
file={file}
|
||||
initialMode="source"
|
||||
initialSource="<svg><title>Diagram</title></svg>"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(previewMarkup).toContain('class="viewer-tab active" aria-pressed="true">Preview</button>');
|
||||
expect(previewMarkup).toContain('aria-pressed="false">Source</button>');
|
||||
expect(previewMarkup).toContain('<img');
|
||||
|
||||
expect(sourceMarkup).toContain('aria-pressed="false">Preview</button>');
|
||||
expect(sourceMarkup).toContain('class="viewer-tab active" aria-pressed="true">Source</button>');
|
||||
expect(sourceMarkup).toContain('class="viewer-source"');
|
||||
expect(sourceMarkup).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('URL-loads a plain HTML preview iframe instead of inlining via srcDoc', () => {
|
||||
const file = baseFile({
|
||||
name: 'page.html',
|
||||
path: 'page.html',
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Page',
|
||||
entry: 'page.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileViewer projectId="project-1" file={file} liveHtml="<html><body>hi</body></html>" />,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="artifact-preview-frame"');
|
||||
expect(markup).toContain('data-od-render-mode="url-load"');
|
||||
expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0"');
|
||||
expect(markup).not.toContain('data-od-render-mode="srcdoc"');
|
||||
});
|
||||
|
||||
it('keeps decks on the srcDoc path so the deck postMessage bridge can run', () => {
|
||||
const file = baseFile({
|
||||
name: 'deck.html',
|
||||
path: 'deck.html',
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'deck',
|
||||
title: 'Deck',
|
||||
entry: 'deck.html',
|
||||
renderer: 'deck-html',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={file}
|
||||
isDeck
|
||||
liveHtml={'<html><body><section class="slide">one</section></body></html>'}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="artifact-preview-frame"');
|
||||
expect(markup).toContain('data-od-render-mode="srcdoc"');
|
||||
expect(markup).not.toContain('data-od-render-mode="url-load"');
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the HTML body looks deck-shaped even without an isDeck hint', () => {
|
||||
const file = baseFile({
|
||||
name: 'inferred.html',
|
||||
path: 'inferred.html',
|
||||
mime: 'text/html',
|
||||
kind: 'html',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'html',
|
||||
title: 'Inferred',
|
||||
entry: 'inferred.html',
|
||||
renderer: 'html',
|
||||
exports: ['html'],
|
||||
},
|
||||
});
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileViewer
|
||||
projectId="project-1"
|
||||
file={file}
|
||||
liveHtml={'<html><body><section class="slide">one</section><section class="slide">two</section></body></html>'}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-od-render-mode="srcdoc"');
|
||||
expect(markup).not.toContain('data-od-render-mode="url-load"');
|
||||
});
|
||||
|
||||
it('renders unsafe SVG source as escaped text instead of executable markup', () => {
|
||||
const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' });
|
||||
const unsafeSource = [
|
||||
'<svg onload="alert(1)"><script>alert(2)</script><text>Logo</text></svg>',
|
||||
'<svg><![CDATA[<script>alert(3)</script>]]></svg>',
|
||||
].join('\n');
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<SvgViewer
|
||||
projectId="project-1"
|
||||
file={file}
|
||||
initialMode="source"
|
||||
initialSource={unsafeSource}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('<svg onload="alert(1)">');
|
||||
expect(markup).toContain('<script>alert(2)</script>');
|
||||
expect(markup).toContain('<![CDATA[<script>alert(3)</script>]]>');
|
||||
expect(markup).not.toContain('<svg onload');
|
||||
expect(markup).not.toContain('<script>');
|
||||
expect(markup).not.toContain('<![CDATA[');
|
||||
expect(markup).not.toContain('dangerouslySetInnerHTML');
|
||||
});
|
||||
});
|
||||
|
||||
function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {
|
||||
const artifact: LiveArtifact = {
|
||||
schemaVersion: 1,
|
||||
id: 'la_1',
|
||||
projectId: 'proj_1',
|
||||
title: 'Launch Metrics',
|
||||
slug: 'launch-metrics',
|
||||
status: 'active',
|
||||
pinned: false,
|
||||
preview: { type: 'html', entry: 'index.html' },
|
||||
refreshStatus: 'idle',
|
||||
createdAt: '2026-04-29T12:00:00.000Z',
|
||||
updatedAt: '2026-04-29T12:00:00.000Z',
|
||||
document: {
|
||||
format: 'html_template_v1',
|
||||
templatePath: 'template.html',
|
||||
generatedPreviewPath: 'index.html',
|
||||
dataPath: 'data.json',
|
||||
dataJson: { title: 'Launch Metrics' },
|
||||
},
|
||||
};
|
||||
return { ...artifact, ...overrides, document: overrides.document ?? artifact.document };
|
||||
}
|
||||
|
||||
describe('LiveArtifactRefreshHistoryPanel', () => {
|
||||
it('renders a human-readable status instead of raw JSON when no history exists', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LiveArtifactRefreshHistoryPanel
|
||||
liveArtifact={baseLiveArtifact({ refreshStatus: 'never' })}
|
||||
fallbackRefreshStatus="never"
|
||||
isRunning={false}
|
||||
sessionEvents={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Status badge with tone, not JSON
|
||||
expect(markup).toContain('live-artifact-refresh-panel');
|
||||
expect(markup).toContain('data-testid="live-artifact-refresh-status-badge"');
|
||||
expect(markup).toContain('Not refreshable');
|
||||
expect(markup).toContain('Last refreshed');
|
||||
expect(markup).toContain('Never');
|
||||
expect(markup).toContain('No refresh activity yet in this session');
|
||||
// Raw JSON is available but tucked inside a collapsed <details>, not exposed as the primary view.
|
||||
expect(markup).toContain('<details');
|
||||
expect(markup).toContain('Advanced debug metadata');
|
||||
const detailsIndex = markup.indexOf('<details');
|
||||
const rawJsonIndex = markup.search(/<pre class="viewer-source">\s*\{/);
|
||||
expect(detailsIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(rawJsonIndex).toBeGreaterThan(detailsIndex);
|
||||
});
|
||||
|
||||
it('surfaces running state and a session timeline with duration + source counts', () => {
|
||||
const now = Date.now();
|
||||
const markup = renderToStaticMarkup(
|
||||
<LiveArtifactRefreshHistoryPanel
|
||||
liveArtifact={baseLiveArtifact({
|
||||
refreshStatus: 'succeeded',
|
||||
lastRefreshedAt: new Date(now - 45_000).toISOString(),
|
||||
})}
|
||||
fallbackRefreshStatus="succeeded"
|
||||
isRunning
|
||||
sessionEvents={[
|
||||
{ id: 1, phase: 'started', at: now - 5_000 },
|
||||
{
|
||||
id: 2,
|
||||
phase: 'succeeded',
|
||||
at: now - 1_200,
|
||||
durationMs: 3_800,
|
||||
refreshedSourceCount: 2,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
// isRunning wins over persisted `succeeded`
|
||||
expect(markup).toContain('Refreshing');
|
||||
// Both timeline rows are present
|
||||
expect(markup).toContain('Started');
|
||||
expect(markup).toContain('Succeeded');
|
||||
// Source count + duration are humanized (3.8s), not raw ms
|
||||
expect(markup).toContain('2 sources updated');
|
||||
expect(markup).toContain('3.8s');
|
||||
});
|
||||
|
||||
});
|
||||
246
apps/web/tests/components/FileWorkspace.test.tsx
Normal file
246
apps/web/tests/components/FileWorkspace.test.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
|
||||
import { projectSplitClassName } from '../../src/components/ProjectView';
|
||||
|
||||
describe('FileWorkspace upload input', () => {
|
||||
it('keeps the Design Files picker aligned with drag-and-drop file support', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="design-files-upload-input"');
|
||||
expect(markup).not.toContain('accept=');
|
||||
});
|
||||
|
||||
it('keeps focus mode controls in the workspace tab bar', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
focusMode={false}
|
||||
onFocusModeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="workspace-focus-toggle"');
|
||||
expect(markup).toContain('Focus workspace');
|
||||
});
|
||||
|
||||
it('keeps the focus mode action outside the horizontally scrollable tablist', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
focusMode={false}
|
||||
onFocusModeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="ws-tabs-shell"');
|
||||
expect(markup).toContain('class="ws-tabs-actions"');
|
||||
expect(markup).toMatch(
|
||||
/<div class="ws-tabs-bar" role="tablist"[^>]*>[\s\S]*?<\/div><div class="ws-tabs-actions">/,
|
||||
);
|
||||
});
|
||||
|
||||
it('labels the same workspace control as chat restore while focused', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
files={[]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
focusMode
|
||||
onFocusModeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('Show chat');
|
||||
});
|
||||
});
|
||||
|
||||
describe('projectSplitClassName', () => {
|
||||
it('marks the project split as focused so the chat pane can collapse globally', () => {
|
||||
expect(projectSplitClassName(false)).toBe('split');
|
||||
expect(projectSplitClassName(true)).toBe('split split-focus');
|
||||
});
|
||||
});
|
||||
|
||||
describe('scrollWorkspaceTabsWithWheel', () => {
|
||||
function makeTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
|
||||
return { scrollLeft, scrollWidth, clientWidth } as HTMLDivElement;
|
||||
}
|
||||
|
||||
function makeClampedTabBar(scrollLeft: number, scrollWidth = 400, clientWidth = 200) {
|
||||
let value = scrollLeft;
|
||||
return {
|
||||
scrollWidth,
|
||||
clientWidth,
|
||||
get scrollLeft() {
|
||||
return value;
|
||||
},
|
||||
set scrollLeft(next: number) {
|
||||
value = Math.min(Math.max(next, 0), scrollWidth - clientWidth);
|
||||
},
|
||||
} as HTMLDivElement;
|
||||
}
|
||||
|
||||
it('maps vertical mouse wheel movement to horizontal tab scrolling', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 40,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(52);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('supports reverse vertical wheel movement', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(52);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: -40,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(12);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes line-based wheel deltas to useful pixel movement', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 1,
|
||||
deltaX: 0,
|
||||
deltaY: 3,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(60);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('normalizes page-based wheel deltas to useful pixel movement', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12, 600, 200);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 2,
|
||||
deltaX: 0,
|
||||
deltaY: 1,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(172);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves native horizontal wheel gestures alone', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 50,
|
||||
deltaY: 10,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(12);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves ctrl-wheel zoom gestures alone', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12);
|
||||
const event = {
|
||||
ctrlKey: true,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 40,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(12);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not intercept vertical wheel movement when tabs do not overflow', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeTabBar(12, 200, 200);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 40,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(12);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets page scrolling continue when the tab bar is already at the wheel boundary', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const currentTarget = makeClampedTabBar(200, 400, 200);
|
||||
const event = {
|
||||
ctrlKey: false,
|
||||
deltaMode: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 40,
|
||||
preventDefault,
|
||||
} as unknown as WheelEvent;
|
||||
|
||||
scrollWorkspaceTabsWithWheel(currentTarget, event);
|
||||
|
||||
expect(currentTarget.scrollLeft).toBe(200);
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal file
154
apps/web/tests/components/ManualEditPanel.test.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { ManualEditPanel, emptyManualEditDraft, manualEditPatchSummary } from '../../src/components/ManualEditPanel';
|
||||
import type { ManualEditTarget } from '../../src/edit-mode/types';
|
||||
|
||||
const target: ManualEditTarget = {
|
||||
id: 'hero-title',
|
||||
kind: 'text',
|
||||
label: 'Hero Title',
|
||||
tagName: 'h1',
|
||||
className: 'hero',
|
||||
text: 'Original',
|
||||
rect: { x: 0, y: 0, width: 120, height: 40 },
|
||||
fields: { text: 'Original' },
|
||||
attributes: { 'data-od-id': 'hero-title' },
|
||||
styles: {
|
||||
color: '',
|
||||
backgroundColor: '',
|
||||
fontSize: '',
|
||||
fontWeight: '',
|
||||
textAlign: '',
|
||||
padding: '',
|
||||
margin: '',
|
||||
borderRadius: '',
|
||||
border: '',
|
||||
width: '',
|
||||
minHeight: '',
|
||||
},
|
||||
outerHtml: '<h1 data-od-id="hero-title">Original</h1>',
|
||||
};
|
||||
|
||||
describe('ManualEditPanel', () => {
|
||||
let dom: JSDOM;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>');
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
host = dom.window.document.querySelector('#root') as HTMLDivElement;
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
dom.window.close();
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
Reflect.deleteProperty(globalThis, 'document');
|
||||
Reflect.deleteProperty(globalThis, 'HTMLElement');
|
||||
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
});
|
||||
|
||||
it('opens with target metadata and calls selection from the layers rail', () => {
|
||||
const onSelectTarget = vi.fn();
|
||||
renderPanel({ onSelectTarget });
|
||||
|
||||
expect(host.textContent).toContain('Hero Title');
|
||||
expect(host.textContent).toContain('hero-title');
|
||||
|
||||
click(buttonByText('Hero Title'));
|
||||
|
||||
expect(onSelectTarget).toHaveBeenCalledWith(target);
|
||||
});
|
||||
|
||||
it('builds content patches from the active target', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
renderPanel({ onApplyPatch });
|
||||
|
||||
click(buttonByText('Apply Content'));
|
||||
|
||||
expect(onApplyPatch).toHaveBeenCalledWith(
|
||||
{ id: 'hero-title', kind: 'set-text', value: 'Updated copy' },
|
||||
'Content: Hero Title',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows invalid attribute JSON without applying a write patch', () => {
|
||||
const onApplyPatch = vi.fn();
|
||||
const onError = vi.fn();
|
||||
renderPanel({ onApplyPatch, onError, attributesText: '{bad' });
|
||||
|
||||
click(buttonByText('Attributes'));
|
||||
click(buttonByText('Apply Attributes'));
|
||||
|
||||
expect(onError).toHaveBeenCalled();
|
||||
expect(onApplyPatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('summarizes full-source history entries without rendering the full file', () => {
|
||||
const source = '<html><body>' + 'x'.repeat(10_000) + '</body></html>';
|
||||
|
||||
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).toBe(
|
||||
JSON.stringify({ kind: 'set-full-source', bytes: source.length }),
|
||||
);
|
||||
expect(manualEditPatchSummary({ kind: 'set-full-source', source })).not.toContain('x'.repeat(100));
|
||||
});
|
||||
|
||||
function renderPanel({
|
||||
onSelectTarget = vi.fn(),
|
||||
onApplyPatch = vi.fn(),
|
||||
onError = vi.fn(),
|
||||
attributesText = '{}',
|
||||
}: {
|
||||
onSelectTarget?: ReturnType<typeof vi.fn>;
|
||||
onApplyPatch?: ReturnType<typeof vi.fn>;
|
||||
onError?: ReturnType<typeof vi.fn>;
|
||||
attributesText?: string;
|
||||
}) {
|
||||
const draft = {
|
||||
...emptyManualEditDraft('<html></html>'),
|
||||
text: 'Updated copy',
|
||||
attributesText,
|
||||
outerHtml: target.outerHtml,
|
||||
};
|
||||
act(() => {
|
||||
root.render(
|
||||
<ManualEditPanel
|
||||
targets={[target]}
|
||||
selectedTarget={target}
|
||||
draft={draft}
|
||||
history={[]}
|
||||
error={null}
|
||||
canUndo={false}
|
||||
canRedo={false}
|
||||
onSelectTarget={onSelectTarget}
|
||||
onDraftChange={vi.fn()}
|
||||
onApplyPatch={onApplyPatch}
|
||||
onError={onError}
|
||||
onCancelDraft={vi.fn()}
|
||||
onUndo={vi.fn()}
|
||||
onRedo={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buttonByText(text: string): HTMLButtonElement {
|
||||
const buttons = Array.from(host.querySelectorAll('button'));
|
||||
const button = buttons.find((item) => item.textContent?.includes(text));
|
||||
if (!button) throw new Error(`Button not found: ${text}`);
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function click(button: HTMLButtonElement): void {
|
||||
act(() => {
|
||||
button.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
}
|
||||
});
|
||||
12
apps/web/tests/components/NewProjectPanel.test.ts
Normal file
12
apps/web/tests/components/NewProjectPanel.test.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { supportedModels } from '../../src/components/NewProjectPanel';
|
||||
import { IMAGE_MODELS } from '../../src/media/models';
|
||||
|
||||
describe('NewProjectPanel image provider visibility', () => {
|
||||
it('shows Nano Banana in supported image models', () => {
|
||||
const models = supportedModels('image', IMAGE_MODELS);
|
||||
expect(models.some((model) => model.provider === 'nanobanana')).toBe(true);
|
||||
expect(models.some((model) => model.id === 'gemini-3.1-flash-image-preview')).toBe(true);
|
||||
});
|
||||
});
|
||||
49
apps/web/tests/components/PreviewModal.test.tsx
Normal file
49
apps/web/tests/components/PreviewModal.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PreviewModal } from '../../src/components/PreviewModal';
|
||||
|
||||
describe('PreviewModal sandbox isolation', () => {
|
||||
it('renders generated previews without same-origin sandbox access', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<PreviewModal
|
||||
title="Unsafe preview"
|
||||
views={[
|
||||
{
|
||||
id: 'preview',
|
||||
label: 'Preview',
|
||||
html: '<script>window.parent.document.body.innerHTML="owned"</script>',
|
||||
},
|
||||
]}
|
||||
exportTitleFor={() => 'unsafe-preview'}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('sandbox="allow-scripts"');
|
||||
expect(markup).not.toContain('allow-same-origin');
|
||||
expect(markup).toContain('srcDoc=');
|
||||
});
|
||||
|
||||
it('keeps deck srcdoc handling for deck preview views', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<PreviewModal
|
||||
title="Deck preview"
|
||||
views={[
|
||||
{
|
||||
id: 'deck',
|
||||
label: 'Deck',
|
||||
html: '<section class="slide">one</section><section class="slide">two</section>',
|
||||
deck: true,
|
||||
},
|
||||
]}
|
||||
exportTitleFor={() => 'deck-preview'}
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('sandbox="allow-scripts"');
|
||||
expect(markup).not.toContain('allow-same-origin');
|
||||
expect(markup).toContain('od:slide');
|
||||
});
|
||||
});
|
||||
143
apps/web/tests/components/QuickSwitcher.test.tsx
Normal file
143
apps/web/tests/components/QuickSwitcher.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { nextCursor, QuickSwitcher, scoreMatch } from '../../src/components/QuickSwitcher';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
// QuickSwitcher reads recents from localStorage during render. The default
|
||||
// vitest env is node, so stub a minimal Storage to keep the component
|
||||
// happy and the assertions deterministic.
|
||||
function createStorageStub(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
clear: () => { store.clear(); },
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
} satisfies Storage;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createStorageStub());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function file(overrides: Partial<ProjectFile>): ProjectFile {
|
||||
return {
|
||||
name: 'index.html',
|
||||
path: 'index.html',
|
||||
type: 'file',
|
||||
size: 1024,
|
||||
mtime: 1700000000,
|
||||
kind: 'html',
|
||||
mime: 'text/html',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('scoreMatch — fuzzy ranking tiers', () => {
|
||||
it('exact basename match scores highest', () => {
|
||||
expect(scoreMatch(file({ name: 'app.tsx' }), 'app.tsx')).toBe(1000);
|
||||
});
|
||||
|
||||
it('prefix-on-basename outranks substring-on-basename', () => {
|
||||
const prefix = scoreMatch(file({ name: 'header.tsx' }), 'head');
|
||||
const substring = scoreMatch(file({ name: 'page-header.tsx' }), 'head');
|
||||
expect(prefix).toBeGreaterThan(substring);
|
||||
});
|
||||
|
||||
it('substring-on-basename outranks substring-on-path-only', () => {
|
||||
const inBase = scoreMatch(file({ name: 'utils/helper.ts' }), 'help');
|
||||
const onlyInPath = scoreMatch(file({ name: 'helpers/main.ts' }), 'help');
|
||||
// 'help' is in the basename of utils/helper.ts ('helper.ts')
|
||||
// 'help' is only in the dir of helpers/main.ts ('helpers')
|
||||
expect(inBase).toBeGreaterThan(onlyInPath);
|
||||
});
|
||||
|
||||
it('returns 0 when the query matches neither basename nor path', () => {
|
||||
expect(scoreMatch(file({ name: 'app.tsx' }), 'xyz')).toBe(0);
|
||||
});
|
||||
|
||||
it('matching is case-insensitive (queries normalized to lowercase by caller)', () => {
|
||||
// The component lowercases the query before calling scoreMatch, so
|
||||
// scoreMatch itself can rely on the contract that q is already lower.
|
||||
expect(scoreMatch(file({ name: 'Hero.tsx' }), 'hero')).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextCursor — arrow-key wrap behavior', () => {
|
||||
it('moves forward through the list without wrapping in the middle', () => {
|
||||
expect(nextCursor(0, 5, 1)).toBe(1);
|
||||
expect(nextCursor(2, 5, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it('moves backward through the list without wrapping in the middle', () => {
|
||||
expect(nextCursor(3, 5, -1)).toBe(2);
|
||||
expect(nextCursor(1, 5, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps from the last row to the first when pressing ↓', () => {
|
||||
// Row 4 (last of 5) → 0 (first). Documented behavior in the PR test plan.
|
||||
expect(nextCursor(4, 5, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps from the first row to the last when pressing ↑', () => {
|
||||
expect(nextCursor(0, 5, -1)).toBe(4);
|
||||
});
|
||||
|
||||
it('returns 0 when the list is empty (no division-by-zero, no NaN)', () => {
|
||||
expect(nextCursor(0, 0, 1)).toBe(0);
|
||||
expect(nextCursor(0, 0, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('stays put on a single-item list (wrap is a no-op)', () => {
|
||||
expect(nextCursor(0, 1, 1)).toBe(0);
|
||||
expect(nextCursor(0, 1, -1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QuickSwitcher render', () => {
|
||||
it('renders the empty state when the project has no files', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
// Empty-state copy comes from i18n; the rendered class is stable.
|
||||
expect(markup).toContain('class="qs-empty"');
|
||||
expect(markup).not.toContain('class="qs-row');
|
||||
});
|
||||
|
||||
it('renders a row per file when no query is set', () => {
|
||||
const files = [
|
||||
file({ name: 'a.html', mtime: 3 }),
|
||||
file({ name: 'b.html', mtime: 2 }),
|
||||
file({ name: 'c.html', mtime: 1 }),
|
||||
];
|
||||
const markup = renderToStaticMarkup(
|
||||
<QuickSwitcher projectId="p1" files={files} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
const rowCount = (markup.match(/class="qs-row /g) ?? []).length;
|
||||
expect(rowCount).toBe(3);
|
||||
});
|
||||
|
||||
it('exposes the keyboard hints in the footer', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<QuickSwitcher projectId="p1" files={[file({})]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
// Three <kbd> hints (↑↓ / ↵ / esc).
|
||||
const kbdCount = (markup.match(/<kbd>/g) ?? []).length;
|
||||
expect(kbdCount).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('renders the input placeholder so users discover the palette is searchable', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<QuickSwitcher projectId="p1" files={[]} onOpenFile={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
expect(markup).toContain('class="qs-input"');
|
||||
expect(markup).toContain('placeholder=');
|
||||
});
|
||||
});
|
||||
225
apps/web/tests/components/SettingsDialog.test.ts
Normal file
225
apps/web/tests/components/SettingsDialog.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
agentRefreshOptionsForConfig,
|
||||
isValidApiBaseUrl,
|
||||
switchApiProtocolConfig,
|
||||
updateAgentCliEnvValue,
|
||||
updateCurrentApiProtocolConfig,
|
||||
} from '../../src/components/SettingsDialog';
|
||||
import type { AppConfig } from '../../src/types';
|
||||
|
||||
const baseConfig: AppConfig = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
apiProtocol: 'anthropic',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
apiProviderBaseUrl: 'https://api.anthropic.com',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
|
||||
describe('SettingsDialog API protocol switching', () => {
|
||||
it('stores the current custom protocol config while preserving custom endpoint details', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
apiKey: 'anthropic-key',
|
||||
apiProviderBaseUrl: null,
|
||||
baseUrl: 'https://my-proxy.example.com',
|
||||
model: 'my-model',
|
||||
};
|
||||
|
||||
const next = switchApiProtocolConfig(config, 'openai');
|
||||
|
||||
expect(next).toMatchObject({
|
||||
mode: 'api',
|
||||
apiProtocol: 'openai',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://my-proxy.example.com',
|
||||
model: 'my-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
expect(next.apiProtocolConfigs?.anthropic).toMatchObject({
|
||||
apiKey: 'anthropic-key',
|
||||
baseUrl: 'https://my-proxy.example.com',
|
||||
model: 'my-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('restores each protocol draft instead of leaking shared field values', () => {
|
||||
const openai = switchApiProtocolConfig(baseConfig, 'openai');
|
||||
const openaiEdited = updateCurrentApiProtocolConfig(openai, {
|
||||
apiKey: 'openai-key',
|
||||
baseUrl: 'https://openai-proxy.example.com',
|
||||
model: 'openai-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
const google = switchApiProtocolConfig(openaiEdited, 'google');
|
||||
const googleEdited = updateCurrentApiProtocolConfig(google, {
|
||||
apiKey: 'google-key',
|
||||
baseUrl: 'https://google-proxy.example.com',
|
||||
model: 'google-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
|
||||
const restoredOpenai = switchApiProtocolConfig(googleEdited, 'openai');
|
||||
|
||||
expect(restoredOpenai).toMatchObject({
|
||||
mode: 'api',
|
||||
apiProtocol: 'openai',
|
||||
apiKey: 'openai-key',
|
||||
baseUrl: 'https://openai-proxy.example.com',
|
||||
model: 'openai-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
expect(restoredOpenai.apiProtocolConfigs?.google).toMatchObject({
|
||||
apiKey: 'google-key',
|
||||
baseUrl: 'https://google-proxy.example.com',
|
||||
model: 'google-model',
|
||||
apiProviderBaseUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the new protocol default on first visit', () => {
|
||||
expect(switchApiProtocolConfig(baseConfig, 'openai')).toMatchObject({
|
||||
mode: 'api',
|
||||
apiProtocol: 'openai',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o',
|
||||
apiProviderBaseUrl: 'https://api.openai.com/v1',
|
||||
});
|
||||
});
|
||||
|
||||
it('auto-fills Google defaults when switching from a selected known provider', () => {
|
||||
expect(switchApiProtocolConfig(baseConfig, 'google')).toMatchObject({
|
||||
mode: 'api',
|
||||
apiProtocol: 'google',
|
||||
apiKey: '',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com',
|
||||
model: 'gemini-2.0-flash',
|
||||
apiProviderBaseUrl: 'https://generativelanguage.googleapis.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps Azure API version in the Azure draft only', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
apiProtocol: 'azure',
|
||||
apiKey: 'azure-key',
|
||||
model: 'deployment-one',
|
||||
apiVersion: '2024-10-21',
|
||||
};
|
||||
|
||||
const next = switchApiProtocolConfig(config, 'openai');
|
||||
|
||||
expect(next).toMatchObject({
|
||||
apiProtocol: 'openai',
|
||||
apiKey: '',
|
||||
apiVersion: '',
|
||||
});
|
||||
expect(next.apiProtocolConfigs?.azure).toMatchObject({
|
||||
apiKey: 'azure-key',
|
||||
model: 'deployment-one',
|
||||
apiVersion: '2024-10-21',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsDialog API Base URL validation', () => {
|
||||
it('accepts public http/https URLs and loopback local providers', () => {
|
||||
expect(isValidApiBaseUrl('https://api.openai.com/v1')).toBe(true);
|
||||
expect(isValidApiBaseUrl('http://localhost:11434/v1')).toBe(true);
|
||||
expect(isValidApiBaseUrl('http://127.0.0.1:11434/v1')).toBe(true);
|
||||
expect(isValidApiBaseUrl('http://[::1]:11434/v1')).toBe(true);
|
||||
expect(isValidApiBaseUrl(' https://resource.openai.azure.com ')).toBe(true);
|
||||
|
||||
expect(isValidApiBaseUrl('ddddd')).toBe(false);
|
||||
expect(isValidApiBaseUrl('api.openai.com/v1')).toBe(false);
|
||||
expect(isValidApiBaseUrl('ftp://api.example.com')).toBe(false);
|
||||
expect(isValidApiBaseUrl('http:api.example.com')).toBe(false);
|
||||
expect(isValidApiBaseUrl('https://')).toBe(false);
|
||||
expect(isValidApiBaseUrl('http://10.0.0.5:11434/v1')).toBe(false);
|
||||
expect(isValidApiBaseUrl('http://169.254.1.5:11434/v1')).toBe(false);
|
||||
expect(isValidApiBaseUrl('http://172.16.0.5:11434/v1')).toBe(false);
|
||||
expect(isValidApiBaseUrl('http://192.168.1.5:11434/v1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SettingsDialog agent CLI env settings', () => {
|
||||
it('updates supported per-agent CLI env values without dropping sibling agents', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
mode: 'daemon',
|
||||
agentCliEnv: {
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
},
|
||||
};
|
||||
|
||||
const next = updateAgentCliEnvValue(
|
||||
config,
|
||||
'claude',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
' ~/.claude-2 ',
|
||||
);
|
||||
|
||||
expect(next.agentCliEnv).toEqual({
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('removes empty per-agent CLI env entries', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
mode: 'daemon',
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
},
|
||||
};
|
||||
|
||||
const next = updateAgentCliEnvValue(
|
||||
config,
|
||||
'claude',
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'',
|
||||
);
|
||||
|
||||
expect(next.agentCliEnv).toEqual({
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('passes pending CLI env prefs through agent rescan options', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
mode: 'daemon',
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(agentRefreshOptionsForConfig(config)).toEqual({
|
||||
throwOnError: true,
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-pending' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('passes an empty CLI env object through agent rescan after fields are cleared', () => {
|
||||
const config: AppConfig = {
|
||||
...baseConfig,
|
||||
mode: 'daemon',
|
||||
agentCliEnv: {},
|
||||
};
|
||||
|
||||
expect(agentRefreshOptionsForConfig(config)).toEqual({
|
||||
throwOnError: true,
|
||||
agentCliEnv: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AssistantMessage } from '../../src/components/AssistantMessage';
|
||||
import type { AgentEvent, ChatMessage } from '../../src/types';
|
||||
|
||||
function messageWithEvents(events: AgentEvent[]): ChatMessage {
|
||||
return {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
events,
|
||||
startedAt: 1_000,
|
||||
endedAt: 3_000,
|
||||
};
|
||||
}
|
||||
|
||||
describe('AssistantMessage unfinished todo state', () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it('keeps Done for a completed latest TodoWrite fixture', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Ship layout', status: 'completed' }] },
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Done')).toBeTruthy();
|
||||
expect(screen.queryByText('Stopped with unfinished work')).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows unfinished state and passes unfinished todos to the continue callback', () => {
|
||||
const onContinue = vi.fn();
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: {
|
||||
todos: [
|
||||
{ content: 'Draft layout', status: 'completed' },
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast
|
||||
onContinueRemainingTasks={onContinue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
|
||||
expect(screen.getByText('2 task(s) remain')).toBeTruthy();
|
||||
const remainingList = screen.getByText('2 task(s) remain').closest('.unfinished-todos');
|
||||
expect(remainingList).not.toBeNull();
|
||||
expect(within(remainingList as HTMLElement).getByText('Building components')).toBeTruthy();
|
||||
expect(within(remainingList as HTMLElement).getByText('Run QA')).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue remaining tasks' }));
|
||||
|
||||
expect(onContinue).toHaveBeenCalledWith([
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('hides the continue button on older assistant turns', () => {
|
||||
render(
|
||||
<AssistantMessage
|
||||
message={messageWithEvents([
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-1',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Run QA', status: 'pending' }] },
|
||||
},
|
||||
])}
|
||||
streaming={false}
|
||||
projectId="project-1"
|
||||
isLast={false}
|
||||
onContinueRemainingTasks={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Stopped with unfinished work')).toBeTruthy();
|
||||
expect(screen.getByText('1 task(s) remain')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Continue remaining tasks' })).toBeNull();
|
||||
});
|
||||
});
|
||||
101
apps/web/tests/components/auto-open-file.test.ts
Normal file
101
apps/web/tests/components/auto-open-file.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { decideAutoOpenAfterWrite } from '../../src/components/auto-open-file';
|
||||
|
||||
describe('decideAutoOpenAfterWrite', () => {
|
||||
it('returns shouldOpen=false when filePath is empty', () => {
|
||||
const result = decideAutoOpenAfterWrite('', [{ name: 'index.html' }]);
|
||||
expect(result).toEqual({ shouldOpen: false, fileName: null });
|
||||
});
|
||||
|
||||
it('returns shouldOpen=true when filePath equals a project file path', () => {
|
||||
const result = decideAutoOpenAfterWrite('index.html', [
|
||||
{ name: 'index.html', path: 'index.html' },
|
||||
{ name: 'styles.css', path: 'styles.css' },
|
||||
]);
|
||||
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
|
||||
});
|
||||
|
||||
it('returns shouldOpen=false when filePath has slashes but matches no project path', () => {
|
||||
// Regression: this is the "rogue empty tab" case — the agent edited a
|
||||
// file outside the project (e.g. an upstream repo's source file) and
|
||||
// we must NOT open a placeholder tab for it. filePath has a slash, so
|
||||
// the basename fallback is intentionally skipped.
|
||||
const result = decideAutoOpenAfterWrite(
|
||||
'/home/bryan/projects/open-design/apps/daemon/src/project-watchers.ts',
|
||||
[
|
||||
{ name: 'index.html', path: 'index.html' },
|
||||
{ name: 'App.jsx', path: 'App.jsx' },
|
||||
],
|
||||
);
|
||||
expect(result).toEqual({ shouldOpen: false, fileName: null });
|
||||
});
|
||||
|
||||
it('falls back to basename match when filePath is just a basename', () => {
|
||||
const result = decideAutoOpenAfterWrite('App.jsx', [
|
||||
{ name: 'index.html', path: 'index.html' },
|
||||
{ name: 'App.jsx', path: 'App.jsx' },
|
||||
{ name: 'styles.css', path: 'styles.css' },
|
||||
{ name: 'README.md', path: 'README.md' },
|
||||
]);
|
||||
expect(result).toEqual({ shouldOpen: true, fileName: 'App.jsx' });
|
||||
});
|
||||
|
||||
it('matches an absolute filePath via path-suffix against a nested project file', () => {
|
||||
// Real-world case: the agent passes an absolute file_path; the project
|
||||
// file lives at "prototype/App.jsx". The decision must still resolve
|
||||
// unambiguously, returning the project-relative file name.
|
||||
const result = decideAutoOpenAfterWrite(
|
||||
'/home/bryan/projects/open-design/.od/projects/abc/prototype/App.jsx',
|
||||
[
|
||||
{ name: 'index.html', path: 'index.html' },
|
||||
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
|
||||
],
|
||||
);
|
||||
expect(result).toEqual({ shouldOpen: true, fileName: 'prototype/App.jsx' });
|
||||
});
|
||||
|
||||
it('declines when an absolute filePath could match multiple nested project files (ambiguous)', () => {
|
||||
// Two project files share the basename "App.jsx" but live in different
|
||||
// subdirs. The agent's filePath ends with "/App.jsx" only, with no
|
||||
// disambiguating subdirectory match — refuse rather than open the wrong file.
|
||||
const result = decideAutoOpenAfterWrite(
|
||||
'/some/external/path/App.jsx',
|
||||
[
|
||||
{ name: 'src/App.jsx', path: 'src/App.jsx' },
|
||||
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
|
||||
],
|
||||
);
|
||||
expect(result).toEqual({ shouldOpen: false, fileName: null });
|
||||
});
|
||||
|
||||
it('declines when filePath has a slash and no project path is a suffix match', () => {
|
||||
// Agent edited /upstream/repo/App.jsx; project also has prototype/App.jsx.
|
||||
// The previous (basename-only) implementation would have opened the
|
||||
// wrong file; the path-suffix check leaves zero matches and the
|
||||
// basename fallback is intentionally skipped because filePath has a slash.
|
||||
const result = decideAutoOpenAfterWrite('/upstream/repo/App.jsx', [
|
||||
{ name: 'prototype/App.jsx', path: 'prototype/App.jsx' },
|
||||
]);
|
||||
expect(result).toEqual({ shouldOpen: false, fileName: null });
|
||||
});
|
||||
|
||||
it('still works when ProjectFile entries omit the optional path field', () => {
|
||||
// Defensive: ProjectFile.path is optional in the API contract. Fall
|
||||
// back to using `name` (which the daemon populates with the full
|
||||
// project-relative path) when path is missing.
|
||||
const result = decideAutoOpenAfterWrite('index.html', [
|
||||
{ name: 'index.html' },
|
||||
{ name: 'styles.css' },
|
||||
]);
|
||||
expect(result).toEqual({ shouldOpen: true, fileName: 'index.html' });
|
||||
});
|
||||
|
||||
it('declines a basename fallback when multiple project files share the basename', () => {
|
||||
const result = decideAutoOpenAfterWrite('App.jsx', [
|
||||
{ name: 'src/App.jsx', path: 'src/App.jsx' },
|
||||
{ name: 'lib/App.jsx', path: 'lib/App.jsx' },
|
||||
]);
|
||||
expect(result).toEqual({ shouldOpen: false, fileName: null });
|
||||
});
|
||||
});
|
||||
79
apps/web/tests/components/conversation-timestamps.test.tsx
Normal file
79
apps/web/tests/components/conversation-timestamps.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ChatPane } from '../../src/components/ChatPane';
|
||||
import type { ChatMessage } from '../../src/types';
|
||||
|
||||
function renderChatPane(messages: ChatMessage[]) {
|
||||
return render(
|
||||
<ChatPane
|
||||
messages={messages}
|
||||
streaming={false}
|
||||
error={null}
|
||||
projectId="project-1"
|
||||
projectFiles={[]}
|
||||
onEnsureProject={async () => 'project-1'}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
conversations={[]}
|
||||
activeConversationId={null}
|
||||
onSelectConversation={() => {}}
|
||||
onDeleteConversation={() => {}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('conversation timestamps', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows inline relative message times with exact hover text', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-15T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Create a landing page',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
createdAt: Date.parse('2025-01-15T12:01:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
const firstTime = screen.getByText('2h ago');
|
||||
expect(firstTime.tagName).toBe('TIME');
|
||||
expect(firstTime.getAttribute('title')).toContain('2025');
|
||||
expect(screen.getByText('1h ago').tagName).toBe('TIME');
|
||||
});
|
||||
|
||||
it('adds day separators when a conversation crosses days', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2025-01-16T14:00:00Z'));
|
||||
|
||||
renderChatPane([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'First request',
|
||||
createdAt: Date.parse('2025-01-15T12:00:00Z'),
|
||||
},
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'Follow-up',
|
||||
createdAt: Date.parse('2025-01-16T12:00:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
expect(screen.getAllByRole('separator')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
136
apps/web/tests/components/file-viewer-markdown-copy.test.tsx
Normal file
136
apps/web/tests/components/file-viewer-markdown-copy.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileViewer } from '../../src/components/FileViewer';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
import { fetchProjectFileText } from '../../src/providers/registry';
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/providers/registry')>(
|
||||
'../../src/providers/registry',
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchProjectFileText: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
|
||||
let writeTextMock: ReturnType<typeof vi.fn>;
|
||||
let originalClipboard: PropertyDescriptor | undefined;
|
||||
let originalExecCommand: PropertyDescriptor | undefined;
|
||||
|
||||
function baseFile(overrides: Partial<ProjectFile> = {}): ProjectFile {
|
||||
return {
|
||||
name: 'notes.md',
|
||||
path: 'notes.md',
|
||||
type: 'file',
|
||||
size: 256,
|
||||
mtime: 1710000000,
|
||||
kind: 'text',
|
||||
mime: 'text/markdown',
|
||||
artifactManifest: {
|
||||
version: 1,
|
||||
kind: 'markdown-document',
|
||||
title: 'Notes',
|
||||
entry: 'notes.md',
|
||||
renderer: 'markdown',
|
||||
exports: ['md'],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FileViewer markdown code block copy', () => {
|
||||
beforeEach(() => {
|
||||
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
|
||||
originalExecCommand = Object.getOwnPropertyDescriptor(document, 'execCommand');
|
||||
mockedFetchProjectFileText.mockResolvedValue('```ts\nconsole.log("copied")\n```');
|
||||
writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: writeTextMock,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', originalClipboard);
|
||||
} else {
|
||||
delete (navigator as { clipboard?: Clipboard }).clipboard;
|
||||
}
|
||||
if (originalExecCommand) {
|
||||
Object.defineProperty(document, 'execCommand', originalExecCommand);
|
||||
} else {
|
||||
delete (document as { execCommand?: typeof document.execCommand }).execCommand;
|
||||
}
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('copies fenced code blocks from the markdown preview', async () => {
|
||||
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
|
||||
});
|
||||
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
|
||||
expect(copyButton.tagName).toBe('BUTTON');
|
||||
|
||||
copyButton.focus();
|
||||
expect(copyButton).toBe(document.activeElement);
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith('console.log("copied")');
|
||||
});
|
||||
expect(copyButton).toBe(document.activeElement);
|
||||
await waitFor(() => {
|
||||
expect(copyButton.getAttribute('aria-label')).toBe('Copied!');
|
||||
});
|
||||
expect(screen.getByRole('status').textContent).toBe('Copied!');
|
||||
});
|
||||
|
||||
it('copies empty fenced code blocks instead of treating the button as broken', async () => {
|
||||
mockedFetchProjectFileText.mockResolvedValue('```ts\n```');
|
||||
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
|
||||
});
|
||||
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith('');
|
||||
});
|
||||
});
|
||||
|
||||
it('restores focus when the Clipboard API fails and the execCommand fallback succeeds', async () => {
|
||||
writeTextMock.mockRejectedValueOnce(new Error('clipboard unavailable'));
|
||||
Object.defineProperty(document, 'execCommand', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockReturnValue(true),
|
||||
});
|
||||
const execCommandSpy = vi.mocked(document.execCommand);
|
||||
const { container } = render(<FileViewer projectId="project-1" file={baseFile()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.markdown-code-copy')).toBeTruthy();
|
||||
});
|
||||
const copyButton = container.querySelector('.markdown-code-copy') as HTMLButtonElement;
|
||||
copyButton.focus();
|
||||
expect(copyButton).toBe(document.activeElement);
|
||||
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(execCommandSpy).toHaveBeenCalledWith('copy');
|
||||
});
|
||||
expect(copyButton).toBe(document.activeElement);
|
||||
});
|
||||
});
|
||||
73
apps/web/tests/components/file-viewer-render-mode.test.ts
Normal file
73
apps/web/tests/components/file-viewer-render-mode.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseForceInline, shouldUrlLoadHtmlPreview } from '../../src/components/file-viewer-render-mode';
|
||||
|
||||
describe('shouldUrlLoadHtmlPreview', () => {
|
||||
const base = { mode: 'preview' as const, isDeck: false, commentMode: false, forceInline: false };
|
||||
|
||||
it('URL-loads a plain HTML preview by default', () => {
|
||||
expect(shouldUrlLoadHtmlPreview(base)).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the file is a deck (deck bridge required)', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when comment mode is active (comment bridge required)', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to srcDoc when the user opts in via forceInline', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, forceInline: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not URL-load while the source-code tab is active', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, mode: 'source' })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats any disqualifying flag as sufficient on its own', () => {
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, commentMode: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, isDeck: true, forceInline: true })).toBe(false);
|
||||
expect(shouldUrlLoadHtmlPreview({ ...base, commentMode: true, forceInline: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseForceInline', () => {
|
||||
it('returns false when the parameter is absent', () => {
|
||||
expect(parseForceInline('')).toBe(false);
|
||||
expect(parseForceInline('?other=1')).toBe(false);
|
||||
expect(parseForceInline(null)).toBe(false);
|
||||
expect(parseForceInline(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for the documented opt-in values', () => {
|
||||
expect(parseForceInline('?forceInline=1')).toBe(true);
|
||||
expect(parseForceInline('?forceInline=true')).toBe(true);
|
||||
expect(parseForceInline('?forceInline=TRUE')).toBe(true);
|
||||
expect(parseForceInline('?forceInline=yes')).toBe(true);
|
||||
expect(parseForceInline('?forceInline=on')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for explicit opt-out values and unrelated strings', () => {
|
||||
expect(parseForceInline('?forceInline=0')).toBe(false);
|
||||
expect(parseForceInline('?forceInline=false')).toBe(false);
|
||||
expect(parseForceInline('?forceInline=no')).toBe(false);
|
||||
expect(parseForceInline('?forceInline=off')).toBe(false);
|
||||
expect(parseForceInline('?forceInline=banana')).toBe(false);
|
||||
});
|
||||
|
||||
it('treats an empty value as absent (defensive: ?forceInline= shows up as "")', () => {
|
||||
expect(parseForceInline('?forceInline=')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a pre-built URLSearchParams', () => {
|
||||
const params = new URLSearchParams('forceInline=1&other=foo');
|
||||
expect(parseForceInline(params)).toBe(true);
|
||||
});
|
||||
|
||||
it('survives surrounding whitespace in the value', () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('forceInline', ' 1 ');
|
||||
expect(parseForceInline(params)).toBe(true);
|
||||
});
|
||||
});
|
||||
111
apps/web/tests/components/preview-modal-fullscreen.test.tsx
Normal file
111
apps/web/tests/components/preview-modal-fullscreen.test.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PreviewModal } from '../../src/components/PreviewModal';
|
||||
|
||||
// Regression coverage for nexu-io/open-design#141: pressing Esc in fullscreen
|
||||
// used to require two presses because the browser exits its native fullscreen
|
||||
// element on the first press without delivering a keydown to JS, leaving the
|
||||
// React `fullscreen` state stuck on. The fix listens to fullscreenchange and
|
||||
// mirrors the native state into React.
|
||||
|
||||
const baseProps = {
|
||||
title: 'Sample',
|
||||
views: [{ id: 'main', label: 'Main', html: '<p>hi</p>' }],
|
||||
exportTitleFor: (id: string) => id,
|
||||
};
|
||||
|
||||
function dispatchFullscreenChange() {
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event('fullscreenchange'));
|
||||
});
|
||||
}
|
||||
|
||||
function setNativeFullscreenElement(el: Element | null) {
|
||||
Object.defineProperty(document, 'fullscreenElement', {
|
||||
configurable: true,
|
||||
get: () => el,
|
||||
});
|
||||
}
|
||||
|
||||
describe('PreviewModal fullscreen exit', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setNativeFullscreenElement(null);
|
||||
});
|
||||
|
||||
it('drops the fullscreen overlay when the browser exits native fullscreen', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
|
||||
// Click the Fullscreen button. jsdom does not implement requestFullscreen
|
||||
// on plain elements, so PreviewModal's fallback path runs and just sets
|
||||
// the React state — exactly matching what happens after a successful
|
||||
// browser fullscreen request.
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
expect(fsButton).toBeTruthy();
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// Simulate the user pressing Esc in browser fullscreen: the browser
|
||||
// exits its native fullscreen element and fires fullscreenchange, but
|
||||
// (in browsers like Firefox) does not deliver the keydown to JS.
|
||||
setNativeFullscreenElement(null);
|
||||
dispatchFullscreenChange();
|
||||
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the modal mounted on Esc while fullscreen, and closes only on a second Esc', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// First Esc — drops fullscreen, must not close the modal.
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(false);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Second Esc — closes the modal.
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores fullscreenchange when another element is still fullscreen', () => {
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<PreviewModal {...baseProps} onClose={onClose} />,
|
||||
);
|
||||
const fsButton = container.querySelector(
|
||||
'button[title="Fullscreen"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(fsButton);
|
||||
const stage = container.querySelector('.ds-modal') as HTMLElement;
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
|
||||
// Some other element is the active fullscreen target — our overlay must
|
||||
// not collapse to non-fullscreen on transitions that leave a different
|
||||
// element fullscreen.
|
||||
const other = document.createElement('div');
|
||||
document.body.appendChild(other);
|
||||
setNativeFullscreenElement(other);
|
||||
dispatchFullscreenChange();
|
||||
|
||||
expect(stage.classList.contains('ds-modal-fullscreen')).toBe(true);
|
||||
document.body.removeChild(other);
|
||||
});
|
||||
});
|
||||
69
apps/web/tests/edit-mode/bridge.test.ts
Normal file
69
apps/web/tests/edit-mode/bridge.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
buildManualEditBridge,
|
||||
isMeaningfulManualEditElement,
|
||||
isManualEditHostNode,
|
||||
isSourceMappableManualEditElement,
|
||||
manualEditDomPathForElement,
|
||||
manualEditStableIdForElement,
|
||||
} from '../../src/edit-mode/bridge';
|
||||
|
||||
describe('manual edit bridge target normalization', () => {
|
||||
it('prefers explicit data-od-id over generated ids', () => {
|
||||
const dom = new JSDOM('<main><h1 data-od-id="hero">Title</h1></main>');
|
||||
const target = dom.window.document.querySelector('h1')!;
|
||||
|
||||
expect(manualEditStableIdForElement(target)).toBe('hero');
|
||||
expect(target.getAttribute('data-od-runtime-id')).toBeNull();
|
||||
});
|
||||
|
||||
it('generates stable DOM path ids for unannotated elements', () => {
|
||||
const dom = new JSDOM('<main><section><p>First</p><p>Second</p></section></main>');
|
||||
const target = dom.window.document.querySelectorAll('p')[1]!;
|
||||
|
||||
expect(manualEditDomPathForElement(target)).toBe('path-0-0-1');
|
||||
expect(manualEditStableIdForElement(target)).toBe('path-0-0-1');
|
||||
expect(manualEditStableIdForElement(target)).toBe('path-0-0-1');
|
||||
expect(target.getAttribute('data-od-runtime-id')).toBe('path-0-0-1');
|
||||
});
|
||||
|
||||
it('generates DOM path ids against source-shaped children, ignoring host shim nodes', () => {
|
||||
const dom = new JSDOM(
|
||||
'<script data-od-sandbox-shim></script><main><section><p>First</p><p>Second</p></section></main><script data-od-edit-bridge></script>',
|
||||
);
|
||||
const target = dom.window.document.querySelectorAll('p')[1]!;
|
||||
|
||||
expect(isManualEditHostNode(dom.window.document.querySelector('[data-od-sandbox-shim]')!)).toBe(true);
|
||||
expect(manualEditDomPathForElement(target)).toBe('path-0-0-1');
|
||||
});
|
||||
|
||||
it('discovers meaningful elements and ignores tiny or irrelevant elements', () => {
|
||||
const dom = new JSDOM('<main><h1 data-od-source-path="path-0-0">Title</h1><script>1</script></main>');
|
||||
const title = dom.window.document.querySelector('h1')!;
|
||||
const script = dom.window.document.querySelector('script')!;
|
||||
|
||||
expect(isMeaningfulManualEditElement(title, { width: 80, height: 24 })).toBe(true);
|
||||
expect(isMeaningfulManualEditElement(title, { width: 3, height: 24 })).toBe(false);
|
||||
expect(isMeaningfulManualEditElement(script, { width: 80, height: 24 })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not expose path targets unless they carry a source path marker', () => {
|
||||
const dom = new JSDOM('<main><h1>Runtime title</h1><p data-od-source-path="path-0-1">Source text</p></main>');
|
||||
const runtimeTitle = dom.window.document.querySelector('h1')!;
|
||||
const sourceText = dom.window.document.querySelector('p')!;
|
||||
|
||||
expect(isSourceMappableManualEditElement(runtimeTitle)).toBe(false);
|
||||
expect(isSourceMappableManualEditElement(sourceText)).toBe(true);
|
||||
expect(isMeaningfulManualEditElement(runtimeTitle, { width: 80, height: 24 })).toBe(false);
|
||||
});
|
||||
|
||||
it('omits selected outerHTML from bulk target posts but includes it for selected targets', () => {
|
||||
const bridge = buildManualEditBridge(true);
|
||||
|
||||
expect(bridge).toContain('targets.push(targetFrom(nodes[i], false))');
|
||||
expect(bridge).toContain("target: targetFrom(el, true)");
|
||||
expect(bridge).toContain('if (!isSourceMappable(nodes[i])) continue;');
|
||||
expect(bridge).toContain('if (isPrimaryTarget(el)) return el;');
|
||||
});
|
||||
});
|
||||
188
apps/web/tests/edit-mode/source-patches.test.ts
Normal file
188
apps/web/tests/edit-mode/source-patches.test.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
applyManualEditPatch,
|
||||
readManualEditAttributes,
|
||||
readManualEditFields,
|
||||
readManualEditOuterHtml,
|
||||
readManualEditStyles,
|
||||
} from '../../src/edit-mode/source-patches';
|
||||
|
||||
const baseSource = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<style>:root { --brand: #111; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1 data-od-id="hero-title">Original title</h1>
|
||||
<a data-od-id="cta" href="/start">Start</a>
|
||||
<button data-od-id="button-cta">Start button</button>
|
||||
<a data-od-id="nested-cta" href="/nested"><span>Buy now</span><svg viewBox="0 0 1 1"></svg></a>
|
||||
<img data-od-id="hero-image" src="/old.png" alt="Old image">
|
||||
<section data-od-id="card" class="hero" style="color: red; padding: 8px;" data-keep="yes">Card</section>
|
||||
<p data-od-id="nested"><strong>Nested</strong> copy</p>
|
||||
<p>Generated path text</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe('manual edit source patches', () => {
|
||||
beforeEach(() => {
|
||||
const dom = new JSDOM('');
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
globalThis.CSS = { escape: (value: string) => value.replace(/"/g, '\\"') } as typeof CSS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis, 'DOMParser');
|
||||
Reflect.deleteProperty(globalThis, 'CSS');
|
||||
});
|
||||
|
||||
it('updates only the selected text target', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(readManualEditFields(result.source, 'hero-title').text).toBe('Edited title');
|
||||
expect(readManualEditFields(result.source, 'cta').text).toBe('Start');
|
||||
});
|
||||
|
||||
it('updates link label and href', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'cta', text: 'Buy now', href: '/buy' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(readManualEditFields(result.source, 'cta')).toEqual({ text: 'Buy now', href: '/buy' });
|
||||
});
|
||||
|
||||
it('treats buttons as label-only text targets instead of persisting href attributes', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'button-cta', value: 'Buy button' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const html = readManualEditOuterHtml(result.source, 'button-cta');
|
||||
expect(html).toContain('Buy button');
|
||||
expect(html).not.toContain('href=');
|
||||
expect(readManualEditFields(result.source, 'button-cta')).toEqual({ text: 'Buy button' });
|
||||
});
|
||||
|
||||
it('preserves nested link markup when only href changes', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'nested-cta', text: 'Buy now', href: '/buy' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const html = readManualEditOuterHtml(result.source, 'nested-cta');
|
||||
expect(html).toContain('href="/buy"');
|
||||
expect(html).toContain('<span>Buy now</span>');
|
||||
expect(html).toContain('<svg');
|
||||
});
|
||||
|
||||
it('rejects label edits for links with nested markup', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-link', id: 'nested-cta', text: 'Purchase', href: '/buy' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('nested markup');
|
||||
});
|
||||
|
||||
it('updates image src and alt', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-image', id: 'hero-image', src: '/new.png', alt: 'New image' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(readManualEditFields(result.source, 'hero-image')).toEqual({ src: '/new.png', alt: 'New image' });
|
||||
});
|
||||
|
||||
it('adds and removes inline style properties', () => {
|
||||
const result = applyManualEditPatch(baseSource, {
|
||||
kind: 'set-style',
|
||||
id: 'card',
|
||||
styles: { color: '', backgroundColor: 'blue', fontSize: '24px' },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const styles = readManualEditStyles(result.source, 'card');
|
||||
expect(styles.color).toBe('');
|
||||
expect(styles.backgroundColor).toBe('blue');
|
||||
expect(styles.fontSize).toBe('24px');
|
||||
expect(styles.padding).toBe('8px');
|
||||
});
|
||||
|
||||
it('applies attributes additively and preserves class/style unless explicitly updated', () => {
|
||||
const result = applyManualEditPatch(baseSource, {
|
||||
kind: 'set-attributes',
|
||||
id: 'card',
|
||||
attributes: { 'aria-label': 'Hero card', 'data-empty': '', 'data-od-id': 'blocked' },
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const attrs = readManualEditAttributes(result.source, 'card');
|
||||
expect(attrs['aria-label']).toBe('Hero card');
|
||||
expect(attrs.class).toBe('hero');
|
||||
expect(attrs.style).toContain('color: red');
|
||||
expect(attrs['data-od-id']).toBe('card');
|
||||
expect(attrs['data-empty']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('preserves data-od-id when selected outerHTML omits it', () => {
|
||||
const result = applyManualEditPatch(baseSource, {
|
||||
kind: 'set-outer-html',
|
||||
id: 'card',
|
||||
html: '<section class="replacement">Replaced</section>',
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const html = readManualEditOuterHtml(result.source, 'card');
|
||||
expect(html).toContain('data-od-id="card"');
|
||||
expect(html).toContain('class="replacement"');
|
||||
});
|
||||
|
||||
it('replaces full source for snapshot-based undo history', () => {
|
||||
const source = '<!doctype html><html><body><h1 data-od-id="hero-title">Snapshot</h1></body></html>';
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-full-source', source });
|
||||
|
||||
expect(result).toEqual({ ok: true, source });
|
||||
});
|
||||
|
||||
it('updates CSS tokens in style tags', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-token', token: '--brand', value: '#f00' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.source).toContain('--brand: #f00;');
|
||||
});
|
||||
|
||||
it('preserves fragment-shaped HTML when saving patches', () => {
|
||||
const source = '<main><h1 data-od-id="hero-title">Original title</h1></main>';
|
||||
const result = applyManualEditPatch(source, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.source).toBe('<main><h1 data-od-id="hero-title">Edited title</h1></main>');
|
||||
expect(result.source).not.toContain('<!doctype');
|
||||
expect(result.source).not.toContain('<html');
|
||||
expect(result.source).not.toContain('<body');
|
||||
});
|
||||
|
||||
it('preserves full documents with leading comments when saving patches', () => {
|
||||
const source = [
|
||||
'<!-- generated by open design -->',
|
||||
'<!doctype html><html><head><style>:root { --brand: #111; }</style></head>',
|
||||
'<body><main><h1 data-od-id="hero-title">Original title</h1></main></body></html>',
|
||||
].join('\n');
|
||||
const result = applyManualEditPatch(source, { kind: 'set-text', id: 'hero-title', value: 'Edited title' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.source).toContain('<!doctype html>');
|
||||
expect(result.source).toContain('<html>');
|
||||
expect(result.source).toContain('<head><style>:root { --brand: #111; }</style></head>');
|
||||
expect(result.source).toContain('<h1 data-od-id="hero-title">Edited title</h1>');
|
||||
});
|
||||
|
||||
it('addresses unannotated elements with generated DOM path ids', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'path-0-7', value: 'Path target' });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.source).toContain('Path target');
|
||||
});
|
||||
|
||||
it('rejects text patches for nested markup', () => {
|
||||
const result = applyManualEditPatch(baseSource, { kind: 'set-text', id: 'nested', value: 'Flat text' });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain('nested markup');
|
||||
});
|
||||
});
|
||||
33
apps/web/tests/i18n/design-files-agent-copy.test.ts
Normal file
33
apps/web/tests/i18n/design-files-agent-copy.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { de } from '../../src/i18n/locales/de';
|
||||
import { en } from '../../src/i18n/locales/en';
|
||||
import { esES } from '../../src/i18n/locales/es-ES';
|
||||
import { fa } from '../../src/i18n/locales/fa';
|
||||
import { fr } from '../../src/i18n/locales/fr';
|
||||
import { ja } from '../../src/i18n/locales/ja';
|
||||
import { ptBR } from '../../src/i18n/locales/pt-BR';
|
||||
import { ru } from '../../src/i18n/locales/ru';
|
||||
import { zhCN } from '../../src/i18n/locales/zh-CN';
|
||||
import { zhTW } from '../../src/i18n/locales/zh-TW';
|
||||
|
||||
const LOCALE_DICTS = {
|
||||
de,
|
||||
en,
|
||||
esES,
|
||||
fa,
|
||||
fr,
|
||||
ja,
|
||||
ptBR,
|
||||
ru,
|
||||
zhCN,
|
||||
zhTW,
|
||||
};
|
||||
|
||||
describe('Design Files agent copy', () => {
|
||||
it('uses neutral agent wording in shared helper text', () => {
|
||||
for (const [locale, dict] of Object.entries(LOCALE_DICTS)) {
|
||||
expect(dict['designFiles.dropDesc'], locale).not.toMatch(/claude/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
33
apps/web/tests/i18n/design-files-dropzone-copy.test.ts
Normal file
33
apps/web/tests/i18n/design-files-dropzone-copy.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { de } from '../../src/i18n/locales/de';
|
||||
import { en } from '../../src/i18n/locales/en';
|
||||
import { esES } from '../../src/i18n/locales/es-ES';
|
||||
import { fa } from '../../src/i18n/locales/fa';
|
||||
import { fr } from '../../src/i18n/locales/fr';
|
||||
import { ja } from '../../src/i18n/locales/ja';
|
||||
import { ptBR } from '../../src/i18n/locales/pt-BR';
|
||||
import { ru } from '../../src/i18n/locales/ru';
|
||||
import { zhCN } from '../../src/i18n/locales/zh-CN';
|
||||
import { zhTW } from '../../src/i18n/locales/zh-TW';
|
||||
|
||||
const LOCALE_DICTS = {
|
||||
de,
|
||||
en,
|
||||
esES,
|
||||
fa,
|
||||
fr,
|
||||
ja,
|
||||
ptBR,
|
||||
ru,
|
||||
zhCN,
|
||||
zhTW,
|
||||
};
|
||||
|
||||
describe('Design Files dropzone copy', () => {
|
||||
it('does not advertise unsupported Figma link drops', () => {
|
||||
for (const [locale, dict] of Object.entries(LOCALE_DICTS)) {
|
||||
expect(dict['designFiles.dropDesc'], locale).not.toMatch(/figma/i);
|
||||
}
|
||||
});
|
||||
});
|
||||
50
apps/web/tests/i18n/locales.test.ts
Normal file
50
apps/web/tests/i18n/locales.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { en } from '../../src/i18n/locales/en';
|
||||
import { LOCALES, LOCALE_LABEL, type Dict, type Locale } from '../../src/i18n/types';
|
||||
|
||||
const EXPECTED_LOCALES = ['en', 'de', 'zh-CN', 'zh-TW', 'pt-BR', 'es-ES', 'ru', 'fa', 'ar', 'ja', 'ko', 'pl', 'hu', 'fr', 'uk', 'tr'];
|
||||
|
||||
function placeholders(value: string): string[] {
|
||||
const names: string[] = [];
|
||||
for (const match of value.matchAll(/\{(\w+)\}/g)) {
|
||||
if (match[1]) {
|
||||
names.push(match[1]);
|
||||
}
|
||||
}
|
||||
return names.sort();
|
||||
}
|
||||
|
||||
async function loadDict(locale: Locale): Promise<Dict> {
|
||||
const module = await import(`../../src/i18n/locales/${locale}.ts`);
|
||||
const dict = Object.values(module).find((value): value is Dict => {
|
||||
return Boolean(value) && typeof value === 'object';
|
||||
});
|
||||
if (!dict) {
|
||||
throw new Error(`No dictionary export found for locale ${locale}`);
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
describe('i18n locales', () => {
|
||||
it('registers every supported locale in the language menu', () => {
|
||||
expect(LOCALES).toEqual(EXPECTED_LOCALES);
|
||||
expect((LOCALE_LABEL as Record<string, string>).de).toBe('Deutsch');
|
||||
expect((LOCALE_LABEL as Record<string, string>).ja).toBe('日本語');
|
||||
});
|
||||
|
||||
it('keeps locale dictionaries aligned with English keys and placeholders', async () => {
|
||||
const englishKeys = Object.keys(en).sort();
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const dict = await loadDict(locale);
|
||||
expect(Object.keys(dict).sort()).toEqual(englishKeys);
|
||||
|
||||
for (const key of englishKeys) {
|
||||
const dictKey = key as keyof Dict;
|
||||
expect(placeholders(dict[dictKey]), `${locale}.${key}`).toEqual(
|
||||
placeholders(en[dictKey]),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
27
apps/web/tests/providers/openai-compatible.test.ts
Normal file
27
apps/web/tests/providers/openai-compatible.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOpenAICompatible } from '../../src/providers/openai-compatible';
|
||||
|
||||
describe('isOpenAICompatible', () => {
|
||||
it('preserves explicit OpenAI model routing when the URL contains anthropic', () => {
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://anthropic-gateway.example.com/v1')).toBe(true);
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://api.example.com/anthropic-named/chat/v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes MiMo Anthropic-compatible endpoints away from OpenAI-compatible chat completions', () => {
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/anthropic')).toBe(false);
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/anthropic/v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves MiMo OpenAI-compatible endpoint routing', () => {
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes MiniMax Anthropic endpoint paths away from OpenAI-compatible chat completions', () => {
|
||||
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/v1/anthropic')).toBe(false);
|
||||
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/anthropic/v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets explicit OpenAI models win when only the host name contains anthropic', () => {
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://anthropic-proxy.example.com/v1')).toBe(true);
|
||||
});
|
||||
});
|
||||
266
apps/web/tests/providers/project-events.test.ts
Normal file
266
apps/web/tests/providers/project-events.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createProjectEventsConnection,
|
||||
projectEventsUrl,
|
||||
type ProjectEvent,
|
||||
} from '../../src/providers/project-events';
|
||||
|
||||
type Listener = (evt: unknown) => void;
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
url: string;
|
||||
listeners: Map<string, Set<Listener>> = new Map();
|
||||
closed = false;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
addEventListener(name: string, cb: Listener): void {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name)!.add(cb);
|
||||
}
|
||||
removeEventListener(name: string, cb: Listener): void {
|
||||
this.listeners.get(name)?.delete(cb);
|
||||
}
|
||||
dispatch(name: string, evt: unknown): void {
|
||||
for (const cb of this.listeners.get(name) ?? []) cb(evt);
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
// EventSource type compat
|
||||
get readyState(): number { return this.closed ? 2 : 1; }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('projectEventsUrl', () => {
|
||||
it('encodes project id segment', () => {
|
||||
expect(projectEventsUrl('818cf7a8-839/9'))
|
||||
.toBe('/api/projects/818cf7a8-839%2F9/events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProjectEventsConnection', () => {
|
||||
it('opens an EventSource against the events URL on creation', () => {
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0]!.url).toBe('/api/projects/p1/events');
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('invokes onChange with parsed payload on file-changed events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('file-changed', {
|
||||
data: JSON.stringify({ type: 'file-changed', path: 'a.html', kind: 'change' }),
|
||||
});
|
||||
es.dispatch('file-changed', {
|
||||
data: JSON.stringify({ type: 'file-changed', path: 'b.css', kind: 'add' }),
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
{ type: 'file-changed', path: 'a.html', kind: 'change' },
|
||||
{ type: 'file-changed', path: 'b.css', kind: 'add' },
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('ignores malformed payloads instead of throwing', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
expect(() => es.dispatch('file-changed', { data: '{not-json' })).not.toThrow();
|
||||
expect(seen).toEqual([]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('parses live_artifact events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('live_artifact', {
|
||||
data: JSON.stringify({
|
||||
type: 'live_artifact',
|
||||
action: 'updated',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
title: 'Status Board',
|
||||
refreshStatus: 'running',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'live_artifact',
|
||||
action: 'updated',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
title: 'Status Board',
|
||||
refreshStatus: 'running',
|
||||
},
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('parses live_artifact_refresh events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('live_artifact_refresh', {
|
||||
data: JSON.stringify({
|
||||
type: 'live_artifact_refresh',
|
||||
phase: 'succeeded',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
refreshId: 'refresh-000001',
|
||||
title: 'Status Board',
|
||||
refreshedSourceCount: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'live_artifact_refresh',
|
||||
phase: 'succeeded',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
refreshId: 'refresh-000001',
|
||||
title: 'Status Board',
|
||||
refreshedSourceCount: 1,
|
||||
},
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('reconnects with exponential backoff on error', () => {
|
||||
let nextDelay = 0;
|
||||
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
||||
nextDelay = ms;
|
||||
cb();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutFn = vi.fn();
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 800,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
|
||||
MockEventSource.instances[1]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(200);
|
||||
|
||||
MockEventSource.instances[2]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(400);
|
||||
|
||||
MockEventSource.instances[3]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(800);
|
||||
|
||||
MockEventSource.instances[4]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(800); // capped at maxBackoffMs
|
||||
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('resets backoff after a ready event', () => {
|
||||
let nextDelay = 0;
|
||||
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
||||
nextDelay = ms;
|
||||
cb();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
initialBackoffMs: 100,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
MockEventSource.instances[1]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(200);
|
||||
// Ready arrives → reset
|
||||
MockEventSource.instances[2]!.dispatch('ready', { data: '{}' });
|
||||
MockEventSource.instances[2]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('close() prevents further reconnects and closes the active source', () => {
|
||||
let scheduled: (() => void) | null = null;
|
||||
const setTimeoutFn = vi.fn((cb: () => void) => {
|
||||
scheduled = cb;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutFn = vi.fn();
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(scheduled).toBeTypeOf('function');
|
||||
|
||||
conn.close();
|
||||
expect(clearTimeoutFn).toHaveBeenCalled();
|
||||
// even if a stale timer fired, the connect is a no-op
|
||||
(scheduled as (() => void) | null)?.();
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a no-op connection when no EventSource constructor is available', () => {
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{ EventSourceCtor: undefined },
|
||||
);
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
expect(() => conn.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
190
apps/web/tests/providers/registry.test.ts
Normal file
190
apps/web/tests/providers/registry.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
fetchAppVersionInfo,
|
||||
fetchConnectorDiscovery,
|
||||
fetchProjectFileText,
|
||||
uploadProjectFiles,
|
||||
} from '../../src/providers/registry';
|
||||
|
||||
describe('fetchAppVersionInfo', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns version info from the daemon response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
version: { version: '1.2.3', channel: 'beta', packaged: true, platform: 'darwin', arch: 'arm64' },
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(fetchAppVersionInfo()).resolves.toEqual({
|
||||
version: '1.2.3',
|
||||
channel: 'beta',
|
||||
packaged: true,
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when version info is unavailable or malformed', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ version: { version: '1.2.3' } }), { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(fetchAppVersionInfo()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProjectFileText', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('can bypass caches when fetching source text', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('<svg />', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
fetchProjectFileText('project-1', 'diagram.svg', {
|
||||
cache: 'no-store',
|
||||
cacheBustKey: '1710000000-2',
|
||||
}),
|
||||
).resolves.toBe('<svg />');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects/project-1/raw/diagram.svg?cacheBust=1710000000-2',
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
});
|
||||
|
||||
it('logs HTTP failure context before returning null', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', { status: 404, statusText: 'Not Found' })));
|
||||
|
||||
await expect(fetchProjectFileText('project-1', 'missing.svg')).resolves.toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[fetchProjectFileText] failed:',
|
||||
expect.objectContaining({
|
||||
name: 'missing.svg',
|
||||
projectId: 'project-1',
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
url: '/api/projects/project-1/raw/missing.svg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs thrown fetch errors before returning null', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = new Error('network down');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
throw error;
|
||||
}));
|
||||
|
||||
await expect(fetchProjectFileText('project-1', 'diagram.svg')).resolves.toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[fetchProjectFileText] failed:',
|
||||
expect.objectContaining({
|
||||
error,
|
||||
name: 'diagram.svg',
|
||||
projectId: 'project-1',
|
||||
url: '/api/projects/project-1/raw/diagram.svg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchConnectorDiscovery', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('caches connector discovery after a successful fetch', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
connectors: [{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] }],
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchConnectorDiscovery({ refresh: true })).resolves.toEqual([
|
||||
{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] },
|
||||
]);
|
||||
await expect(fetchConnectorDiscovery()).resolves.toEqual([
|
||||
{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/connectors/discovery?refresh=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadProjectFiles', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('treats every response entry as a success regardless of originalName drift', async () => {
|
||||
// Simulates an encoding edge case: the browser File.name carries a
|
||||
// composed CJK name (NFC) but multer round-trips it through latin1 and
|
||||
// returns a slightly different decoded form. The old name-equality
|
||||
// matching marked these as failed even though the server stored them.
|
||||
const composed = '测试.pdf';
|
||||
const decomposed = '测试.pdf'; // pretend the server returned a normalized variant
|
||||
const file = new File(['hello'], composed, { type: 'application/pdf' });
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
name: 'mxk7-test.pdf',
|
||||
path: 'mxk7-test.pdf',
|
||||
size: 5,
|
||||
originalName: decomposed,
|
||||
},
|
||||
],
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await uploadProjectFiles('project-1', [file]);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.uploaded).toHaveLength(1);
|
||||
expect(result.uploaded[0]).toMatchObject({
|
||||
path: 'mxk7-test.pdf',
|
||||
name: decomposed,
|
||||
size: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the unmatched tail as failed when the server drops files mid-flight', async () => {
|
||||
const a = new File(['a'], 'a.txt', { type: 'text/plain' });
|
||||
const b = new File(['b'], 'b.txt', { type: 'text/plain' });
|
||||
const c = new File(['c'], 'c.txt', { type: 'text/plain' });
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
files: [
|
||||
{ name: 't1-a.txt', path: 't1-a.txt', size: 1, originalName: 'a.txt' },
|
||||
{ name: 't2-b.txt', path: 't2-b.txt', size: 1, originalName: 'b.txt' },
|
||||
],
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await uploadProjectFiles('project-1', [a, b, c]);
|
||||
|
||||
expect(result.uploaded).toHaveLength(2);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]).toMatchObject({ name: 'c.txt' });
|
||||
});
|
||||
});
|
||||
590
apps/web/tests/providers/sse.test.ts
Normal file
590
apps/web/tests/providers/sse.test.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { reattachDaemonRun, streamViaDaemon } from '../../src/providers/daemon';
|
||||
import { streamMessageOpenAI } from '../../src/providers/openai-compatible';
|
||||
import { parseSseFrame } from '../../src/providers/sse';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('parseSseFrame', () => {
|
||||
it('parses JSON event frames', () => {
|
||||
expect(parseSseFrame('id: 12\nevent: stdout\ndata: {"chunk":"hello"}')).toEqual({
|
||||
kind: 'event',
|
||||
id: '12',
|
||||
event: 'stdout',
|
||||
data: { chunk: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SSE comment frames', () => {
|
||||
expect(parseSseFrame(': keepalive')).toEqual({
|
||||
kind: 'comment',
|
||||
comment: 'keepalive',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty for frames without data or comments', () => {
|
||||
expect(parseSseFrame('')).toEqual({ kind: 'empty' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamViaDaemon', () => {
|
||||
it('ignores comment frames without notifying handlers', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n')));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onDelta).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onAgentEvent).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('continues normal stdout and end handling around comments', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
': keepalive',
|
||||
'',
|
||||
'event: start',
|
||||
'data: {"bin":"mock-agent"}',
|
||||
'',
|
||||
'event: stdout',
|
||||
'data: {"chunk":"hello"}',
|
||||
'',
|
||||
': keepalive',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {"code":0}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hello');
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('reads unified SSE error payload messages', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
'event: error',
|
||||
'data: {"message":"legacy message","error":{"code":"AGENT_UNAVAILABLE","message":"typed message"}}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('typed message'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the daemon run alive when the browser-side stream aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
controller.abort();
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: controller.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the daemon run when the explicit cancel signal aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const streamController = new AbortController();
|
||||
const cancelController = new AbortController();
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
cancelController.abort();
|
||||
streamController.abort();
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: streamController.signal,
|
||||
cancelSignal: cancelController.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/events', {
|
||||
method: 'GET',
|
||||
signal: streamController.signal,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the create-run request alive across browser-side stream aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') {
|
||||
controller.abort();
|
||||
return jsonResponse({ runId: 'run-1' });
|
||||
}
|
||||
if (url === '/api/runs/run-1/events') throw new DOMException('aborted', 'AbortError');
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: controller.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels an accepted daemon run when explicit cancel happens during create-run', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const streamController = new AbortController();
|
||||
const cancelController = new AbortController();
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') {
|
||||
cancelController.abort();
|
||||
streamController.abort();
|
||||
return jsonResponse({ runId: 'run-1' });
|
||||
}
|
||||
if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true });
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: streamController.signal,
|
||||
cancelSignal: cancelController.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks create-run HTTP failures as failed', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const onRunStatus = vi.fn();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('down', { status: 503 })));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
onRunStatus,
|
||||
});
|
||||
|
||||
expect(onRunStatus).toHaveBeenCalledWith('failed');
|
||||
expect(handlers.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'daemon 503: down' }));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks invalid create-run JSON as failed', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const onRunStatus = vi.fn();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('not json', { status: 202 })));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
onRunStatus,
|
||||
});
|
||||
|
||||
expect(onRunStatus).toHaveBeenCalledWith('failed');
|
||||
expect(handlers.onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconnects to a daemon run after an incomplete stream closes', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse('id: 1\nevent: stdout\ndata: {"chunk":"he"}\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse('id: 2\nevent: stdout\ndata: {"chunk":"llo"}\n\nid: 3\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=1', {
|
||||
method: 'GET',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('posts run correlation fields and reports run metadata callbacks', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse('id: 4\nevent: start\ndata: {"bin":"mock-agent"}\n\nid: 5\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
const onRunCreated = vi.fn();
|
||||
const onRunStatus = vi.fn();
|
||||
const onRunEventId = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
assistantMessageId: 'assistant-1',
|
||||
clientRequestId: 'client-1',
|
||||
onRunCreated,
|
||||
onRunStatus,
|
||||
onRunEventId,
|
||||
});
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
assistantMessageId: 'assistant-1',
|
||||
clientRequestId: 'client-1',
|
||||
});
|
||||
expect(onRunCreated).toHaveBeenCalledWith('run-1');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('queued');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('running');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('succeeded');
|
||||
expect(onRunEventId).toHaveBeenCalledWith('4');
|
||||
expect(onRunEventId).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
it('reattaches to an existing daemon run after the last stored event id', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(sseResponse('id: 8\nevent: stdout\ndata: {"chunk":"lo"}\n\nid: 9\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await reattachDaemonRun({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
initialLastEventId: '7',
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=7', {
|
||||
method: 'GET',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('lo');
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('lo');
|
||||
});
|
||||
|
||||
it('keeps reconnecting when quiet resumed streams only receive keepalives', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(7);
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('reports an error when reconnects are exhausted before an end event', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') return sseResponse('');
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('daemon stream disconnected before run completed'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes selected preview comments without requiring visible draft text', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
return sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: '' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
commentAttachments: [
|
||||
{
|
||||
id: 'c1',
|
||||
order: 1,
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
comment: 'Shorten the headline',
|
||||
currentText: 'A very long headline',
|
||||
pagePosition: { x: 12, y: 44, width: 500, height: 60 },
|
||||
htmlHint: '<h1 data-od-id="hero-title">',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [, createRunInit] = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit];
|
||||
const body = JSON.parse(String(createRunInit.body));
|
||||
expect(body.message).toBe('## user\n');
|
||||
expect(body.commentAttachments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'c1',
|
||||
elementId: 'hero-title',
|
||||
comment: 'Shorten the headline',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamMessageOpenAI', () => {
|
||||
it('ignores comments and keeps delta/end behavior unchanged', async () => {
|
||||
const handlers = createStreamHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sseResponse(
|
||||
[
|
||||
': keepalive',
|
||||
'',
|
||||
'event: delta',
|
||||
'data: {"text":"hi"}',
|
||||
'',
|
||||
': keepalive',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamMessageOpenAI(
|
||||
{
|
||||
mode: 'api',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.test',
|
||||
model: 'gpt-test',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
},
|
||||
'',
|
||||
[{ id: '1', role: 'user', content: 'hello' }],
|
||||
new AbortController().signal,
|
||||
handlers,
|
||||
);
|
||||
|
||||
expect(handlers.onDelta).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hi');
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hi');
|
||||
});
|
||||
|
||||
it('routes through the OpenAI-specific proxy endpoint and handles CRLF frames', async () => {
|
||||
const handlers = createStreamHandlers();
|
||||
const fetchMock = vi.fn(async () =>
|
||||
sseResponse(
|
||||
[
|
||||
'event: delta',
|
||||
'data: {"delta":"hi"}',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {}',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamMessageOpenAI(
|
||||
{
|
||||
mode: 'api',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.test',
|
||||
model: 'gpt-test',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
},
|
||||
'',
|
||||
[{ id: '1', role: 'user', content: 'hello' }],
|
||||
new AbortController().signal,
|
||||
handlers,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/proxy/openai/stream', expect.any(Object));
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hi');
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hi');
|
||||
});
|
||||
});
|
||||
|
||||
function createStreamHandlers() {
|
||||
return {
|
||||
onDelta: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDaemonHandlers() {
|
||||
return {
|
||||
...createStreamHandlers(),
|
||||
onAgentEvent: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function sseResponse(text: string): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 202,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
106
apps/web/tests/quickSwitcherRecents.test.ts
Normal file
106
apps/web/tests/quickSwitcherRecents.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
pushRecent,
|
||||
readRecents,
|
||||
RECENTS_LIMIT,
|
||||
} from '../src/quickSwitcherRecents';
|
||||
|
||||
// Tiny in-memory localStorage stub. Vitest runs in a node env (per
|
||||
// vitest.config.ts), so we provide just enough of the Storage interface
|
||||
// for the recents module to exercise its code paths.
|
||||
function createStorageStub() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => (store.has(key) ? store.get(key)! : null),
|
||||
setItem: (key: string, value: string) => { store.set(key, value); },
|
||||
removeItem: (key: string) => { store.delete(key); },
|
||||
clear: () => { store.clear(); },
|
||||
key: (i: number) => Array.from(store.keys())[i] ?? null,
|
||||
get length() { return store.size; },
|
||||
} satisfies Storage;
|
||||
}
|
||||
|
||||
describe('quickSwitcherRecents', () => {
|
||||
let storage: Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = createStorageStub();
|
||||
vi.stubGlobal('localStorage', storage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('readRecents', () => {
|
||||
it('returns an empty array when no entry exists for the project', () => {
|
||||
expect(readRecents('p1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns the stored list as-is when valid', () => {
|
||||
storage.setItem('od:qs-recents:p1', JSON.stringify(['a.html', 'b.html']));
|
||||
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
|
||||
});
|
||||
|
||||
it('returns an empty array for corrupt JSON instead of throwing', () => {
|
||||
storage.setItem('od:qs-recents:p1', '{not json');
|
||||
expect(readRecents('p1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out non-string entries (defends against schema drift)', () => {
|
||||
storage.setItem('od:qs-recents:p1', JSON.stringify(['a.html', 42, null, 'b.html']));
|
||||
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
|
||||
});
|
||||
|
||||
it('returns an empty array when the stored value is not an array', () => {
|
||||
storage.setItem('od:qs-recents:p1', JSON.stringify({ a: 1 }));
|
||||
expect(readRecents('p1')).toEqual([]);
|
||||
});
|
||||
|
||||
it('scopes recents per project (different keys, no cross-bleed)', () => {
|
||||
pushRecent('p1', 'a.html');
|
||||
pushRecent('p2', 'b.html');
|
||||
expect(readRecents('p1')).toEqual(['a.html']);
|
||||
expect(readRecents('p2')).toEqual(['b.html']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushRecent', () => {
|
||||
it('puts the most recent file at the head of the list', () => {
|
||||
pushRecent('p1', 'a.html');
|
||||
pushRecent('p1', 'b.html');
|
||||
expect(readRecents('p1')).toEqual(['b.html', 'a.html']);
|
||||
});
|
||||
|
||||
it('deduplicates: re-pushing an existing entry moves it to the head', () => {
|
||||
pushRecent('p1', 'a.html');
|
||||
pushRecent('p1', 'b.html');
|
||||
pushRecent('p1', 'a.html');
|
||||
expect(readRecents('p1')).toEqual(['a.html', 'b.html']);
|
||||
});
|
||||
|
||||
it(`caps the list at ${RECENTS_LIMIT} entries`, () => {
|
||||
for (let i = 0; i < RECENTS_LIMIT + 4; i++) {
|
||||
pushRecent('p1', `file-${i}.html`);
|
||||
}
|
||||
const recents = readRecents('p1');
|
||||
expect(recents).toHaveLength(RECENTS_LIMIT);
|
||||
// Most recent first; older entries fall off the tail.
|
||||
expect(recents[0]).toBe(`file-${RECENTS_LIMIT + 3}.html`);
|
||||
});
|
||||
|
||||
it('is a no-op when localStorage throws (quota exceeded / private mode)', () => {
|
||||
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceeded');
|
||||
});
|
||||
// Should not throw even though setItem does.
|
||||
expect(() => pushRecent('p1', 'a.html')).not.toThrow();
|
||||
setItem.mockRestore();
|
||||
// After restoring, the previous push left no record because the
|
||||
// throw aborted the write — recents stays empty.
|
||||
expect(readRecents('p1')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
244
apps/web/tests/runtime/exports.test.ts
Normal file
244
apps/web/tests/runtime/exports.test.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
archiveFilenameFrom,
|
||||
archiveRootFromFilePath,
|
||||
buildSandboxedPreviewDocument,
|
||||
exportAsMd,
|
||||
exportAsPdf,
|
||||
openSandboxedPreviewInNewTab,
|
||||
} from '../../src/runtime/exports';
|
||||
|
||||
function mockResponse(headers: Record<string, string>): Response {
|
||||
return { headers: new Headers(headers) } as Response;
|
||||
}
|
||||
|
||||
describe('archiveRootFromFilePath', () => {
|
||||
it('returns the top-level directory name when present', () => {
|
||||
expect(archiveRootFromFilePath('ui-design/index.html')).toBe('ui-design');
|
||||
expect(archiveRootFromFilePath('ui-design/src/app.css')).toBe('ui-design');
|
||||
});
|
||||
|
||||
it('returns empty for files at the project root', () => {
|
||||
expect(archiveRootFromFilePath('index.html')).toBe('');
|
||||
expect(archiveRootFromFilePath('README.md')).toBe('');
|
||||
});
|
||||
|
||||
it('strips a leading slash before scanning', () => {
|
||||
expect(archiveRootFromFilePath('/ui-design/index.html')).toBe('ui-design');
|
||||
expect(archiveRootFromFilePath('//ui-design/index.html')).toBe('ui-design');
|
||||
});
|
||||
|
||||
it('returns empty for empty/garbage input', () => {
|
||||
expect(archiveRootFromFilePath('')).toBe('');
|
||||
expect(archiveRootFromFilePath('/')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveFilenameFrom', () => {
|
||||
it('decodes the RFC 5987 UTF-8 filename* form (preserves multi-byte chars)', () => {
|
||||
// 'café-design.zip' encoded — the é is a 2-byte UTF-8 sequence (%C3%A9),
|
||||
// which is enough to fail under naive ASCII-only handling.
|
||||
const resp = mockResponse({
|
||||
'content-disposition':
|
||||
"attachment; filename=\"project.zip\"; filename*=UTF-8''caf%C3%A9-design.zip",
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('café-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the legacy quoted filename= when filename* is absent', () => {
|
||||
const resp = mockResponse({
|
||||
'content-disposition': 'attachment; filename="ui-design.zip"',
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the active root slug when the header is missing', () => {
|
||||
const resp = mockResponse({});
|
||||
expect(archiveFilenameFrom(resp, 'fallback-title', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
|
||||
it('falls back to the title slug when both header and root are absent', () => {
|
||||
const resp = mockResponse({});
|
||||
expect(archiveFilenameFrom(resp, 'My Artifact', '')).toBe('My-Artifact.zip');
|
||||
});
|
||||
|
||||
it('falls through to the slug when filename* is malformed', () => {
|
||||
// Truncated percent-escape — decodeURIComponent throws; we should not
|
||||
// surface the exception, just fall back to the next strategy.
|
||||
const resp = mockResponse({
|
||||
'content-disposition': "attachment; filename*=UTF-8''%E9%9D",
|
||||
});
|
||||
expect(archiveFilenameFrom(resp, 'fallback', 'ui-design')).toBe('ui-design.zip');
|
||||
});
|
||||
});
|
||||
|
||||
// `exportAsMd` is a pass-through (the file body is the artifact source
|
||||
// verbatim, only the extension and Content-Type flip). Tests exercise it
|
||||
// end-to-end by stubbing the few DOM globals `triggerDownload` touches —
|
||||
// we run under `environment: 'node'`, so `document` and `URL` aren't
|
||||
// available by default. See issue #279.
|
||||
describe('exportAsMd', () => {
|
||||
let capturedBlob: Blob | undefined;
|
||||
let capturedFilename: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = undefined;
|
||||
capturedFilename = undefined;
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test';
|
||||
},
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
createElement: () => {
|
||||
const anchor = { href: '', click: () => {} } as { href: string; download?: string; click: () => void };
|
||||
Object.defineProperty(anchor, 'download', {
|
||||
set(value: string) {
|
||||
capturedFilename = value;
|
||||
},
|
||||
get() {
|
||||
return capturedFilename ?? '';
|
||||
},
|
||||
});
|
||||
return anchor;
|
||||
},
|
||||
body: { appendChild: () => {}, removeChild: () => {} },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('downloads the source bytes verbatim under a `.md` extension', async () => {
|
||||
const source = '<!doctype html>\n<html lang="en"><body>hi</body></html>\n';
|
||||
|
||||
exportAsMd(source, 'TTC — Seed Round · 2026');
|
||||
|
||||
expect(capturedBlob).toBeDefined();
|
||||
expect(capturedBlob!.type).toBe('text/markdown;charset=utf-8');
|
||||
// Critical: no transformation, no normalization, no trimming. Whatever
|
||||
// the Source view shows is what lands in the .md.
|
||||
expect(await capturedBlob!.text()).toBe(source);
|
||||
expect(capturedFilename).toBe('TTC-Seed-Round-2026.md');
|
||||
});
|
||||
|
||||
it('falls back to "artifact.md" when the title is empty or unsafe', () => {
|
||||
exportAsMd('hello', '');
|
||||
expect(capturedFilename).toBe('artifact.md');
|
||||
|
||||
exportAsMd('hello', '???');
|
||||
expect(capturedFilename).toBe('artifact.md');
|
||||
});
|
||||
|
||||
it('keeps multi-byte content (UTF-8) intact end-to-end', async () => {
|
||||
const source = '# 中文标题\n\n这是 markdown 文件 — でも本当は HTML 源代码 (مرحبا)。\n';
|
||||
|
||||
exportAsMd(source, 'mixed');
|
||||
|
||||
expect(await capturedBlob!.text()).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sandboxed preview Blob exports', () => {
|
||||
let capturedBlob: Blob | undefined;
|
||||
let openedFeatures: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedBlob = undefined;
|
||||
openedFeatures = undefined;
|
||||
vi.stubGlobal('URL', {
|
||||
createObjectURL: (blob: Blob) => {
|
||||
capturedBlob = blob;
|
||||
return 'blob:test';
|
||||
},
|
||||
revokeObjectURL: () => {},
|
||||
});
|
||||
vi.stubGlobal('window', {
|
||||
open: (_url: string, _target: string, features?: string) => {
|
||||
openedFeatures = features;
|
||||
return null;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('wraps generated HTML in an opaque-origin sandbox for new-tab previews', async () => {
|
||||
openSandboxedPreviewInNewTab('<script>window.parent.localStorage.clear()</script>', 'Unsafe preview');
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<script>window.parent.localStorage.clear()</script>');
|
||||
expect(wrapper).not.toContain('<script>window.parent.localStorage.clear()</script>');
|
||||
});
|
||||
|
||||
it('passes srcdoc options through the sandboxed new-tab wrapper', async () => {
|
||||
openSandboxedPreviewInNewTab('<section class="slide">One</section>', 'Deck preview', {
|
||||
deck: true,
|
||||
baseHref: '/artifacts/project/assets/',
|
||||
initialSlideIndex: 2,
|
||||
});
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<base href="/artifacts/project/assets/">');
|
||||
expect(wrapper).toContain('od:slide');
|
||||
});
|
||||
|
||||
it('can build a print wrapper without granting same-origin access', () => {
|
||||
const wrapper = buildSandboxedPreviewDocument('<!doctype html><title>x</title>', 'Print', {
|
||||
allowModals: true,
|
||||
});
|
||||
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
});
|
||||
|
||||
it('uses a sandboxed noopener Blob wrapper by default for PDF exports', async () => {
|
||||
exportAsPdf('<script>window.parent.document.body.innerHTML="owned"</script>', 'PDF');
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||||
expect(wrapper).not.toContain('<script>window.parent.document.body.innerHTML="owned"</script>');
|
||||
});
|
||||
|
||||
it('preserves deck print handling inside sandboxed PDF exports', async () => {
|
||||
exportAsPdf('<section class="slide">One</section>', 'Deck PDF', { deck: true });
|
||||
|
||||
expect(openedFeatures).toBe('noopener,noreferrer');
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const wrapper = await capturedBlob!.text();
|
||||
expect(wrapper).toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(wrapper).not.toContain('allow-same-origin');
|
||||
expect(wrapper).toContain('data-deck-print="injected"');
|
||||
expect(wrapper).toContain('page-break-after: always;');
|
||||
});
|
||||
|
||||
it('allows explicit trusted PDF opt-out without changing the secure default', async () => {
|
||||
exportAsPdf('<main>Trusted local document</main>', 'Trusted PDF', {
|
||||
sandboxedPreview: false,
|
||||
});
|
||||
|
||||
expect(openedFeatures).toBeUndefined();
|
||||
expect(capturedBlob).toBeDefined();
|
||||
const doc = await capturedBlob!.text();
|
||||
expect(doc).not.toContain('sandbox="allow-scripts allow-modals"');
|
||||
expect(doc).toContain('<main>Trusted local document</main>');
|
||||
});
|
||||
});
|
||||
61
apps/web/tests/runtime/react-component.test.ts
Normal file
61
apps/web/tests/runtime/react-component.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildReactComponentSrcdoc, prepareReactComponentSource } from '../../src/runtime/react-component';
|
||||
|
||||
describe('prepareReactComponentSource', () => {
|
||||
it('adapts a default function export for iframe rendering', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
import React from 'react';
|
||||
export default function Card() {
|
||||
return <div>Card</div>;
|
||||
}
|
||||
`);
|
||||
expect(out).not.toContain('import React');
|
||||
expect(out).toContain('function Card()');
|
||||
expect(out).toContain('window.__OpenDesignComponent');
|
||||
expect(out).toContain("typeof Card !== 'undefined' ? Card : null");
|
||||
});
|
||||
|
||||
it('adapts a named component export for iframe rendering', () => {
|
||||
const out = prepareReactComponentSource('export const Preview = () => <main />;');
|
||||
expect(out).toContain('const Preview =');
|
||||
expect(out).toContain("typeof Preview !== 'undefined' ? Preview : null");
|
||||
});
|
||||
|
||||
it('preserves React hook imports as runtime bindings', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
import { useState, useEffect as useReactEffect } from 'react';
|
||||
export default function Counter() {
|
||||
const [count, setCount] = useState(0);
|
||||
useReactEffect(() => setCount(1), []);
|
||||
return <button>{count}</button>;
|
||||
}
|
||||
`);
|
||||
expect(out).not.toContain("import { useState");
|
||||
expect(out).toContain('const { useState, useEffect: useReactEffect } = window.React;');
|
||||
expect(out).toContain('function Counter()');
|
||||
});
|
||||
|
||||
it('detects default re-exports before removing export specifiers', () => {
|
||||
const out = prepareReactComponentSource(`
|
||||
const Foo = () => <main />;
|
||||
export { Foo as default };
|
||||
`);
|
||||
expect(out).not.toContain('export { Foo as default }');
|
||||
expect(out).toContain("typeof Foo !== 'undefined' ? Foo : null");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReactComponentSrcdoc', () => {
|
||||
it('builds a standalone sandbox document with React runtime scripts', () => {
|
||||
const doc = buildReactComponentSrcdoc('export default function App(){ return <div /> }', {
|
||||
title: 'App',
|
||||
});
|
||||
expect(doc).toContain('<!doctype html>');
|
||||
expect(doc).toContain('react@18/umd/react.development.js');
|
||||
expect(doc).toContain('@babel/standalone');
|
||||
expect(doc).toContain('artifact.tsx');
|
||||
expect(doc).toContain('sandboxed iframe');
|
||||
expect(doc).toContain('(0, eval)(compiled)');
|
||||
});
|
||||
});
|
||||
80
apps/web/tests/runtime/srcdoc.test.ts
Normal file
80
apps/web/tests/runtime/srcdoc.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { buildSrcdoc } from '../../src/runtime/srcdoc';
|
||||
|
||||
const deckHtml = `<!doctype html>
|
||||
<html>
|
||||
<head><title>Deck</title></head>
|
||||
<body>
|
||||
<section class="slide active">One</section>
|
||||
<section class="slide">Two</section>
|
||||
<section class="slide">Three</section>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
describe('buildSrcdoc', () => {
|
||||
it('injects an initial slide index for deck previews', () => {
|
||||
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: 2 });
|
||||
|
||||
expect(doc).toContain('var initialSlideIndex = 2;');
|
||||
expect(doc).toContain('setTimeout(restoreInitialSlide, 200)');
|
||||
expect(doc).toContain('setTimeout(restoreInitialSlide, 100)');
|
||||
});
|
||||
|
||||
it('clamps invalid initial slide indices before injecting deck bridge script', () => {
|
||||
const doc = buildSrcdoc(deckHtml, { deck: true, initialSlideIndex: -4 });
|
||||
|
||||
expect(doc).toContain('var initialSlideIndex = 0;');
|
||||
});
|
||||
|
||||
it('only uses directly mutable slide conventions for setActive support', () => {
|
||||
const srcdoc = buildSrcdoc(
|
||||
'<section class="slide">One</section><section class="slide">Two</section>',
|
||||
{ deck: true }
|
||||
);
|
||||
|
||||
const canSetActive = srcdoc.match(/function canSetActive\(list\)\{([\s\S]*?)\n \}/)?.[1] ?? '';
|
||||
|
||||
expect(canSetActive).toContain('findActiveByClass(list) >= 0');
|
||||
expect(canSetActive).toContain("list[i].style.display === 'none'");
|
||||
expect(canSetActive).toContain("list[i].style.visibility === 'hidden'");
|
||||
expect(canSetActive).toContain("list[i].hasAttribute('hidden')");
|
||||
expect(canSetActive).not.toContain('findActiveByVisibility');
|
||||
});
|
||||
|
||||
it('enables the comment bridge immediately when injected', () => {
|
||||
const srcdoc = buildSrcdoc('<main data-od-id="hero">Hero</main>', {
|
||||
commentBridge: true,
|
||||
});
|
||||
|
||||
expect(srcdoc).toContain('data-od-comment-bridge');
|
||||
expect(srcdoc).toContain('var enabled = true;');
|
||||
expect(srcdoc).toContain("var mode = 'picker';");
|
||||
expect(srcdoc).toContain("type: 'od:comment-target'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-hover'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-leave'");
|
||||
expect(srcdoc).toContain("type: 'od:comment-targets'");
|
||||
expect(srcdoc).toContain("postStroke('od:pod-stroke')");
|
||||
expect(srcdoc).toContain("postStroke('od:pod-select')");
|
||||
expect(srcdoc).toContain('data-od-comment-mode-kind');
|
||||
expect(srcdoc).toContain("body * { cursor: crosshair !important; }");
|
||||
expect(srcdoc).toContain('MutationObserver(schedulePostTargets)');
|
||||
expect(srcdoc).toContain("document.addEventListener('scroll', schedulePostTargets, true);");
|
||||
expect(srcdoc).toContain('data-od-comment-bridge-style');
|
||||
});
|
||||
|
||||
it('marks source-authored edit targets before runtime scripts can add nodes', () => {
|
||||
const dom = new JSDOM('');
|
||||
globalThis.DOMParser = dom.window.DOMParser;
|
||||
const srcdoc = buildSrcdoc(
|
||||
'<main><h1>Source title</h1><script>document.body.prepend(document.createElement("h1"));</script></main>',
|
||||
{ editBridge: true },
|
||||
);
|
||||
Reflect.deleteProperty(globalThis, 'DOMParser');
|
||||
|
||||
expect(srcdoc).toContain('data-od-source-path="path-0"');
|
||||
expect(srcdoc).toContain('data-od-source-path="path-0-0"');
|
||||
expect(srcdoc).not.toContain('<script data-od-source-path=');
|
||||
expect(srcdoc.indexOf('data-od-source-path="path-0"')).toBeLessThan(srcdoc.indexOf('document.body.prepend'));
|
||||
});
|
||||
});
|
||||
84
apps/web/tests/runtime/todos.test.ts
Normal file
84
apps/web/tests/runtime/todos.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
latestTodosFromEvents,
|
||||
parseTodoWriteInput,
|
||||
unfinishedTodosFromEvents,
|
||||
} from '../../src/runtime/todos';
|
||||
import type { AgentEvent } from '../../src/types';
|
||||
|
||||
const firstTodoInput = {
|
||||
todos: [
|
||||
{ content: 'Draft layout', status: 'completed' },
|
||||
{ content: 'Build components', status: 'in_progress', activeForm: 'Building components' },
|
||||
{ content: 'Run QA', status: 'pending' },
|
||||
{ content: '', status: 'pending' },
|
||||
{ content: 'Unknown status defaults pending', status: 'blocked' },
|
||||
null,
|
||||
],
|
||||
};
|
||||
|
||||
describe('todo event helpers', () => {
|
||||
it('normalizes TodoWrite input and ignores malformed items', () => {
|
||||
expect(parseTodoWriteInput(firstTodoInput)).toEqual([
|
||||
{ content: 'Draft layout', status: 'completed', activeForm: undefined },
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the latest TodoWrite event as the current todo truth', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'Working...' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
{
|
||||
kind: 'tool_use',
|
||||
id: 'todo-2',
|
||||
name: 'TodoWrite',
|
||||
input: { todos: [{ content: 'Final polish', status: 'pending' }] },
|
||||
},
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([
|
||||
{ content: 'Final polish', status: 'pending', activeForm: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats an empty latest TodoWrite event as authoritative', () => {
|
||||
const events: AgentEvent[] = [
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
{ kind: 'text', text: 'All done.' },
|
||||
{ kind: 'tool_use', id: 'todo-empty', name: 'TodoWrite', input: { todos: [] } },
|
||||
];
|
||||
|
||||
expect(latestTodosFromEvents(events)).toEqual([]);
|
||||
expect(unfinishedTodosFromEvents(events)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns only pending and in-progress todos as unfinished', () => {
|
||||
expect(unfinishedTodosFromEvents([
|
||||
{ kind: 'tool_use', id: 'todo-1', name: 'TodoWrite', input: firstTodoInput },
|
||||
])).toEqual([
|
||||
{
|
||||
content: 'Build components',
|
||||
status: 'in_progress',
|
||||
activeForm: 'Building components',
|
||||
},
|
||||
{ content: 'Run QA', status: 'pending', activeForm: undefined },
|
||||
{
|
||||
content: 'Unknown status defaults pending',
|
||||
status: 'pending',
|
||||
activeForm: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
203
apps/web/tests/runtime/tool-renderers.test.tsx
Normal file
203
apps/web/tests/runtime/tool-renderers.test.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import { useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ToolCard } from '../../src/components/ToolCard';
|
||||
import {
|
||||
clearToolRenderers,
|
||||
deriveToolStatus,
|
||||
getToolRenderer,
|
||||
registerToolRenderer,
|
||||
toRenderProps,
|
||||
} from '../../src/runtime/tool-renderers';
|
||||
import type { ToolRenderProps } from '../../src/runtime/tool-renderers';
|
||||
import type { AgentEvent } from '../../src/types';
|
||||
|
||||
type ToolUse = Extract<AgentEvent, { kind: 'tool_use' }>;
|
||||
type ToolResult = Extract<AgentEvent, { kind: 'tool_result' }>;
|
||||
|
||||
function use(input: unknown, name = 'render_chart', id = 't1'): ToolUse {
|
||||
return { kind: 'tool_use', id, name, input };
|
||||
}
|
||||
|
||||
function ok(content: string, id = 't1'): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: false };
|
||||
}
|
||||
|
||||
function err(content: string, id = 't1'): ToolResult {
|
||||
return { kind: 'tool_result', toolUseId: id, content, isError: true };
|
||||
}
|
||||
|
||||
describe('deriveToolStatus', () => {
|
||||
it('returns "executing" while the run is streaming and no result has arrived', () => {
|
||||
expect(deriveToolStatus(undefined, true)).toBe('executing');
|
||||
});
|
||||
|
||||
it('returns "inProgress" when the run died before the tool returned', () => {
|
||||
expect(deriveToolStatus(undefined, false)).toBe('inProgress');
|
||||
});
|
||||
|
||||
it('returns "complete" on a clean tool result', () => {
|
||||
expect(deriveToolStatus(ok('ok'), true)).toBe('complete');
|
||||
});
|
||||
|
||||
it('returns "error" when the tool result carries isError', () => {
|
||||
expect(deriveToolStatus(err('boom'), true)).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toRenderProps', () => {
|
||||
it('packs args / result / isError into the AG-UI render-prop shape', () => {
|
||||
const u = use({ city: 'SF' }, 'get_weather');
|
||||
const props = toRenderProps(u, ok('{"temp":61}'), true);
|
||||
expect(props).toEqual({
|
||||
status: 'complete',
|
||||
name: 'get_weather',
|
||||
args: { city: 'SF' },
|
||||
result: '{"temp":61}',
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits result while the tool is still running', () => {
|
||||
const u = use({ city: 'SF' }, 'get_weather');
|
||||
const props = toRenderProps(u, undefined, true);
|
||||
expect(props.status).toBe('executing');
|
||||
expect(props.result).toBeUndefined();
|
||||
expect(props.isError).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool renderer registry', () => {
|
||||
afterEach(() => clearToolRenderers());
|
||||
|
||||
it('registers, looks up, and unregisters renderers', () => {
|
||||
const r = () => null;
|
||||
expect(getToolRenderer('xyz')).toBeUndefined();
|
||||
const dispose = registerToolRenderer('xyz', r);
|
||||
expect(getToolRenderer('xyz')).toBe(r);
|
||||
dispose();
|
||||
expect(getToolRenderer('xyz')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('overwrites on re-registration (last writer wins)', () => {
|
||||
const a = () => null;
|
||||
const b = () => null;
|
||||
registerToolRenderer('xyz', a);
|
||||
registerToolRenderer('xyz', b);
|
||||
expect(getToolRenderer('xyz')).toBe(b);
|
||||
});
|
||||
|
||||
it('does not unregister a renderer that has been overwritten', () => {
|
||||
const a = () => null;
|
||||
const b = () => null;
|
||||
const disposeA = registerToolRenderer('xyz', a);
|
||||
registerToolRenderer('xyz', b);
|
||||
disposeA();
|
||||
expect(getToolRenderer('xyz')).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToolCard dispatch', () => {
|
||||
afterEach(() => clearToolRenderers());
|
||||
|
||||
it('routes unknown tool names through the registry', () => {
|
||||
registerToolRenderer('render_chart', ({ status, args }) => (
|
||||
<div data-testid="custom-chart" data-status={status}>
|
||||
{(args as { label?: string }).label}
|
||||
</div>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ label: 'Q3 revenue' })} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('data-testid="custom-chart"');
|
||||
expect(markup).toContain('data-status="executing"');
|
||||
expect(markup).toContain('Q3 revenue');
|
||||
});
|
||||
|
||||
it('passes the result content through as the `result` prop on completion', () => {
|
||||
registerToolRenderer('render_chart', ({ status, result }) => (
|
||||
<span data-testid="custom-chart" data-status={status}>
|
||||
{result}
|
||||
</span>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({})} result={ok('payload')} runStreaming={false} />,
|
||||
);
|
||||
expect(markup).toContain('data-status="complete"');
|
||||
expect(markup).toContain('payload');
|
||||
});
|
||||
|
||||
it('falls back to the built-in card when the registered renderer returns null', () => {
|
||||
registerToolRenderer('Bash', () => null);
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'ls' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('op-bash');
|
||||
expect(markup).toContain('ls');
|
||||
});
|
||||
|
||||
it('lets a registered renderer override a built-in family card', () => {
|
||||
registerToolRenderer('Bash', ({ args }) => (
|
||||
<pre data-testid="custom-bash">{(args as { command?: string }).command}</pre>
|
||||
));
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'whoami' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('data-testid="custom-bash"');
|
||||
expect(markup).not.toContain('op-bash');
|
||||
});
|
||||
|
||||
it('mounts hookful renderer output as a child component, surviving replace + dispose', () => {
|
||||
// The documented contract: renderers must be hook-free, but they may
|
||||
// return a component *element* whose body uses hooks. That child gets
|
||||
// mounted as its own component, so swapping the renderer (or letting
|
||||
// it return null) does not violate the Rules of Hooks on ToolCard.
|
||||
function HookfulCardA({ args }: ToolRenderProps) {
|
||||
const [count] = useState(() => (args as { start?: number }).start ?? 0);
|
||||
return <span data-testid="hookful-a">A:{count}</span>;
|
||||
}
|
||||
function HookfulCardB({ result }: ToolRenderProps) {
|
||||
const [label] = useState('mounted');
|
||||
return (
|
||||
<span data-testid="hookful-b">
|
||||
B:{label}:{result ?? ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const disposeA = registerToolRenderer('render_chart', (props) => <HookfulCardA {...props} />);
|
||||
const first = renderToStaticMarkup(
|
||||
<ToolCard use={use({ start: 7 })} runStreaming={true} />,
|
||||
);
|
||||
expect(first).toContain('data-testid="hookful-a"');
|
||||
expect(first).toContain('A:7');
|
||||
|
||||
// Swap to a renderer with a different hook shape. If the renderer
|
||||
// were called as a plain function inside ToolCard, this would shift
|
||||
// ToolCard's hook sequence; mounting as a child component isolates
|
||||
// each renderer's hooks to its own fiber.
|
||||
disposeA();
|
||||
registerToolRenderer('render_chart', (props) => <HookfulCardB {...props} />);
|
||||
const second = renderToStaticMarkup(
|
||||
<ToolCard use={use({})} result={ok('payload')} runStreaming={false} />,
|
||||
);
|
||||
expect(second).toContain('data-testid="hookful-b"');
|
||||
expect(second).toContain('B:mounted:payload');
|
||||
expect(second).not.toContain('hookful-a');
|
||||
});
|
||||
|
||||
it('falls back to the built-in card when a registered renderer throws', () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
registerToolRenderer('Bash', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<ToolCard use={use({ command: 'ls' }, 'Bash')} runStreaming={true} />,
|
||||
);
|
||||
expect(markup).toContain('op-bash');
|
||||
expect(markup).toContain('ls');
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
175
apps/web/tests/sidecar-proxy.test.ts
Normal file
175
apps/web/tests/sidecar-proxy.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createStandaloneBackendEnv,
|
||||
createStandaloneParentMonitorImport,
|
||||
createStandaloneServerArgs,
|
||||
normalizeDaemonProxyOriginHeader,
|
||||
resolveDaemonProxyTarget,
|
||||
resolveStandaloneBackendOrigin,
|
||||
resolveStandaloneServerEntry,
|
||||
} from '../sidecar/server';
|
||||
|
||||
describe('resolveDaemonProxyTarget', () => {
|
||||
it('proxies allowlisted relative paths to the daemon origin', () => {
|
||||
const target = resolveDaemonProxyTarget('http://127.0.0.1:7456', '/api/projects?limit=10');
|
||||
|
||||
expect(target?.href).toBe('http://127.0.0.1:7456/api/projects?limit=10');
|
||||
});
|
||||
|
||||
it('does not let absolute request URLs replace the daemon origin', () => {
|
||||
const target = resolveDaemonProxyTarget(
|
||||
'http://127.0.0.1:7456',
|
||||
'http://169.254.169.254/api/latest/meta-data?token=1',
|
||||
);
|
||||
|
||||
expect(target?.href).toBe('http://127.0.0.1:7456/api/latest/meta-data?token=1');
|
||||
});
|
||||
|
||||
it('rejects non-daemon paths', () => {
|
||||
expect(resolveDaemonProxyTarget('http://127.0.0.1:7456', '/settings')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStandaloneServerEntry', () => {
|
||||
it('resolves the traced monorepo standalone server entry', async () => {
|
||||
const previousDistDir = process.env.OD_WEB_DIST_DIR;
|
||||
delete process.env.OD_WEB_DIST_DIR;
|
||||
const webRoot = await mkdtemp(join(tmpdir(), 'open-design-web-standalone-'));
|
||||
const nestedRoot = join(webRoot, '.next', 'standalone', 'apps', 'web');
|
||||
const fallbackRoot = join(webRoot, '.next', 'standalone');
|
||||
|
||||
try {
|
||||
await mkdir(nestedRoot, { recursive: true });
|
||||
await mkdir(fallbackRoot, { recursive: true });
|
||||
await writeFile(join(nestedRoot, 'server.js'), '', 'utf8');
|
||||
await writeFile(join(fallbackRoot, 'server.js'), '', 'utf8');
|
||||
|
||||
expect(resolveStandaloneServerEntry(webRoot)).toBe(join(nestedRoot, 'server.js'));
|
||||
} finally {
|
||||
if (previousDistDir == null) {
|
||||
delete process.env.OD_WEB_DIST_DIR;
|
||||
} else {
|
||||
process.env.OD_WEB_DIST_DIR = previousDistDir;
|
||||
}
|
||||
await rm(webRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers a copied standalone resource root before package fallback entries', async () => {
|
||||
const previousDistDir = process.env.OD_WEB_DIST_DIR;
|
||||
delete process.env.OD_WEB_DIST_DIR;
|
||||
const webRoot = await mkdtemp(join(tmpdir(), 'open-design-web-package-'));
|
||||
const copiedRoot = await mkdtemp(join(tmpdir(), 'open-design-web-copied-'));
|
||||
const copiedWebRoot = join(copiedRoot, 'apps', 'web');
|
||||
const packageFallbackRoot = join(webRoot, '.next', 'standalone', 'apps', 'web');
|
||||
|
||||
try {
|
||||
await mkdir(copiedWebRoot, { recursive: true });
|
||||
await mkdir(packageFallbackRoot, { recursive: true });
|
||||
await writeFile(join(copiedWebRoot, 'server.js'), '', 'utf8');
|
||||
await writeFile(join(packageFallbackRoot, 'server.js'), '', 'utf8');
|
||||
|
||||
expect(resolveStandaloneServerEntry(webRoot, copiedRoot)).toBe(join(copiedWebRoot, 'server.js'));
|
||||
} finally {
|
||||
if (previousDistDir == null) {
|
||||
delete process.env.OD_WEB_DIST_DIR;
|
||||
} else {
|
||||
process.env.OD_WEB_DIST_DIR = previousDistDir;
|
||||
}
|
||||
await rm(webRoot, { force: true, recursive: true });
|
||||
await rm(copiedRoot, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStandaloneServerArgs', () => {
|
||||
it('preloads a parent monitor before running the standalone server entry', () => {
|
||||
const args = createStandaloneServerArgs('/tmp/open-design/server.js');
|
||||
|
||||
expect(args).toHaveLength(3);
|
||||
expect(args[0]).toBe('--import');
|
||||
expect(args[1]).toBe(createStandaloneParentMonitorImport());
|
||||
expect(args[2]).toBe('/tmp/open-design/server.js');
|
||||
});
|
||||
|
||||
it('uses a data import that exits when the recorded parent disappears', () => {
|
||||
const importSpecifier = createStandaloneParentMonitorImport('OD_TEST_PARENT_PID');
|
||||
const source = decodeURIComponent(importSpecifier.replace(/^data:text\/javascript,/, ''));
|
||||
|
||||
expect(importSpecifier).toMatch(/^data:text\/javascript,/);
|
||||
expect(source).toContain('process.env["OD_TEST_PARENT_PID"]');
|
||||
expect(source).toContain('process.ppid === parentPid');
|
||||
expect(source).toContain('process.kill(parentPid, 0)');
|
||||
expect(source).toContain('process.exit(0)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('standalone backend binding', () => {
|
||||
it('keeps the hidden standalone backend on loopback even when the public sidecar host is wider', () => {
|
||||
const env = createStandaloneBackendEnv({
|
||||
baseEnv: { ...process.env, OD_HOST: '0.0.0.0' },
|
||||
parentPid: 1234,
|
||||
port: 5876,
|
||||
});
|
||||
|
||||
expect(resolveStandaloneBackendOrigin(5876)).toBe('http://127.0.0.1:5876');
|
||||
expect(env.HOSTNAME).toBe('127.0.0.1');
|
||||
expect(env.PORT).toBe('5876');
|
||||
expect(env.NODE_ENV).toBe('production');
|
||||
expect(env.OD_STANDALONE_PARENT_PID).toBe('1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeDaemonProxyOriginHeader', () => {
|
||||
it('normalizes the current web origin to the daemon origin', () => {
|
||||
expect(
|
||||
normalizeDaemonProxyOriginHeader({
|
||||
daemonOrigin: 'http://127.0.0.1:7456',
|
||||
origin: 'http://127.0.0.1:3000',
|
||||
webPort: 3000,
|
||||
}),
|
||||
).toBe('http://127.0.0.1:7456');
|
||||
});
|
||||
|
||||
it('accepts localhost as an equivalent loopback web origin', () => {
|
||||
expect(
|
||||
normalizeDaemonProxyOriginHeader({
|
||||
daemonOrigin: 'http://127.0.0.1:7456',
|
||||
origin: 'http://localhost:3000',
|
||||
webPort: 3000,
|
||||
}),
|
||||
).toBe('http://127.0.0.1:7456');
|
||||
});
|
||||
|
||||
it('does not rewrite unrelated browser origins', () => {
|
||||
expect(
|
||||
normalizeDaemonProxyOriginHeader({
|
||||
daemonOrigin: 'http://127.0.0.1:7456',
|
||||
origin: 'https://example.com',
|
||||
webPort: 3000,
|
||||
}),
|
||||
).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('preserves absent and null origins for daemon policy to handle', () => {
|
||||
expect(
|
||||
normalizeDaemonProxyOriginHeader({
|
||||
daemonOrigin: 'http://127.0.0.1:7456',
|
||||
origin: undefined,
|
||||
webPort: 3000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
normalizeDaemonProxyOriginHeader({
|
||||
daemonOrigin: 'http://127.0.0.1:7456',
|
||||
origin: 'null',
|
||||
webPort: 3000,
|
||||
}),
|
||||
).toBe('null');
|
||||
});
|
||||
});
|
||||
73
apps/web/tests/state/appearance.test.ts
Normal file
73
apps/web/tests/state/appearance.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyAppearanceToDocument,
|
||||
normalizeAccentColor,
|
||||
} from '../../src/state/appearance';
|
||||
|
||||
describe('normalizeAccentColor', () => {
|
||||
it('accepts six-digit hex colors and normalizes casing', () => {
|
||||
expect(normalizeAccentColor(' #4F46E5 ')).toBe('#4f46e5');
|
||||
});
|
||||
|
||||
it('rejects invalid accent colors', () => {
|
||||
expect(normalizeAccentColor('blue')).toBeNull();
|
||||
expect(normalizeAccentColor('#123')).toBeNull();
|
||||
expect(normalizeAccentColor('#12345g')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyAppearanceToDocument', () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.style.removeProperty('--accent');
|
||||
document.documentElement.style.removeProperty('--accent-strong');
|
||||
document.documentElement.style.removeProperty('--accent-soft');
|
||||
document.documentElement.style.removeProperty('--accent-tint');
|
||||
document.documentElement.style.removeProperty('--accent-hover');
|
||||
});
|
||||
|
||||
it('applies the saved theme and accent variables to the root element', () => {
|
||||
applyAppearanceToDocument({ theme: 'dark', accentColor: '#4F46E5' });
|
||||
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#4f46e5');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#4f46e5');
|
||||
});
|
||||
|
||||
it('applies accent variables while clearing an explicit theme for system mode', () => {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
|
||||
applyAppearanceToDocument({ theme: 'system', accentColor: '#10B981' });
|
||||
|
||||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#10b981');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-strong')).toContain('#10b981');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-soft')).toContain('#10b981');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-tint')).toContain('#10b981');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#10b981');
|
||||
});
|
||||
|
||||
it('replaces existing accent variables when the saved color changes', () => {
|
||||
applyAppearanceToDocument({ theme: 'light', accentColor: '#4F46E5' });
|
||||
|
||||
applyAppearanceToDocument({ theme: 'light', accentColor: '#EF4444' });
|
||||
|
||||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('#ef4444');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-strong')).toContain('#ef4444');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-strong')).not.toContain('#4f46e5');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-soft')).toContain('#ef4444');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-tint')).toContain('#ef4444');
|
||||
expect(document.documentElement.style.getPropertyValue('--accent-hover')).toContain('#ef4444');
|
||||
});
|
||||
|
||||
it('clears accent overrides when no valid accent is configured', () => {
|
||||
document.documentElement.style.setProperty('--accent', '#4f46e5');
|
||||
|
||||
applyAppearanceToDocument({ theme: 'system', accentColor: 'not-a-color' });
|
||||
|
||||
expect(document.documentElement.hasAttribute('data-theme')).toBe(false);
|
||||
expect(document.documentElement.style.getPropertyValue('--accent')).toBe('');
|
||||
});
|
||||
});
|
||||
272
apps/web/tests/state/config.test.ts
Normal file
272
apps/web/tests/state/config.test.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
loadConfig,
|
||||
mergeDaemonConfig,
|
||||
syncComposioConfigToDaemon,
|
||||
syncConfigToDaemon,
|
||||
} from '../../src/state/config';
|
||||
import type { AppConfig } from '../../src/types';
|
||||
|
||||
const store = new Map<string, string>();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
store.clear();
|
||||
}),
|
||||
});
|
||||
|
||||
describe('syncComposioConfigToDaemon', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal('fetch', originalFetch);
|
||||
});
|
||||
|
||||
it('sends a pending Composio API key to the daemon', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await syncComposioConfigToDaemon({ apiKey: 'cmp_secret', apiKeyConfigured: false });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/connectors/composio/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: 'cmp_secret' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not clear a daemon-saved key when local state only has the saved marker', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await syncComposioConfigToDaemon({ apiKey: '', apiKeyConfigured: true, apiKeyTail: 'test' });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/connectors/composio/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncConfigToDaemon', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.stubGlobal('fetch', originalFetch);
|
||||
});
|
||||
|
||||
it('syncs per-agent CLI env prefs to the daemon app config', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await syncConfigToDaemon({
|
||||
...DEFAULT_CONFIG,
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(url).toBe('/api/app-config');
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(init.headers).toEqual({ 'content-type': 'application/json' });
|
||||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||||
onboardingCompleted: DEFAULT_CONFIG.onboardingCompleted,
|
||||
agentId: DEFAULT_CONFIG.agentId,
|
||||
agentModels: DEFAULT_CONFIG.agentModels,
|
||||
skillId: DEFAULT_CONFIG.skillId,
|
||||
designSystemId: DEFAULT_CONFIG.designSystemId,
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-2' },
|
||||
codex: { CODEX_HOME: '~/.codex-alt' },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeDaemonConfig', () => {
|
||||
it('clears stale local CLI env prefs when the daemon has none', () => {
|
||||
const merged = mergeDaemonConfig(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-old' },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: 'codex',
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.agentId).toBe('codex');
|
||||
expect(merged.agentCliEnv).toEqual({});
|
||||
});
|
||||
|
||||
it('uses daemon CLI env prefs instead of merging with stale local entries', () => {
|
||||
const merged = mergeDaemonConfig(
|
||||
{
|
||||
...DEFAULT_CONFIG,
|
||||
agentCliEnv: {
|
||||
claude: { CLAUDE_CONFIG_DIR: '~/.claude-old' },
|
||||
},
|
||||
},
|
||||
{
|
||||
agentCliEnv: {
|
||||
codex: { CODEX_HOME: '~/.codex-new' },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(merged.agentCliEnv).toEqual({
|
||||
codex: { CODEX_HOME: '~/.codex-new' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.clear();
|
||||
});
|
||||
|
||||
describe('loadConfig', () => {
|
||||
it('migrates legacy OpenAI-compatible API configs to an explicit apiProtocol', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('api');
|
||||
expect(config.baseUrl).toBe('https://api.deepseek.com');
|
||||
expect(config.model).toBe('deepseek-chat');
|
||||
expect(config.apiProtocol).toBe('openai');
|
||||
expect(config.configMigrationVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('migrates legacy Anthropic API configs to an explicit apiProtocol', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
model: 'claude-sonnet-4-5',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('infers protocol for legacy daemon-mode API fields without changing mode', () => {
|
||||
const daemonConfig: Partial<AppConfig> = {
|
||||
mode: 'daemon',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: 'codex',
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(daemonConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('daemon');
|
||||
expect(config.apiProtocol).toBe('openai');
|
||||
expect(config.configMigrationVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('does not overwrite an already explicit apiProtocol', () => {
|
||||
const explicitConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiProtocol: 'anthropic',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
model: 'deepseek-chat',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(explicitConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('preserves saved settings when migration sees a malformed base URL', () => {
|
||||
const legacyConfig: Partial<AppConfig> = {
|
||||
mode: 'api',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://[broken-ipv6',
|
||||
model: 'custom-model',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(legacyConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.mode).toBe('api');
|
||||
expect(config.apiKey).toBe('sk-test');
|
||||
expect(config.baseUrl).toBe('https://[broken-ipv6');
|
||||
expect(config.model).toBe('custom-model');
|
||||
expect(config.apiProtocol).toBe('anthropic');
|
||||
});
|
||||
|
||||
it('preserves a valid saved accent color', () => {
|
||||
const savedConfig: Partial<AppConfig> = {
|
||||
theme: 'dark',
|
||||
accentColor: '#4F46E5',
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(savedConfig));
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
expect(config.theme).toBe('dark');
|
||||
expect(config.accentColor).toBe('#4f46e5');
|
||||
});
|
||||
|
||||
it('falls back to the default accent color for malformed saved colors', () => {
|
||||
const savedConfig: Partial<AppConfig> = {
|
||||
accentColor: 'blue',
|
||||
};
|
||||
store.set('open-design:config', JSON.stringify(savedConfig));
|
||||
|
||||
expect(loadConfig().accentColor).toBe(DEFAULT_CONFIG.accentColor);
|
||||
});
|
||||
|
||||
it('returns defaults for malformed localStorage JSON', () => {
|
||||
store.set('open-design:config', '{broken-json');
|
||||
|
||||
expect(loadConfig()).toEqual(DEFAULT_CONFIG);
|
||||
});
|
||||
|
||||
it('sets an explicit apiProtocol for new default configs', () => {
|
||||
expect(DEFAULT_CONFIG.apiProtocol).toBe('anthropic');
|
||||
expect(DEFAULT_CONFIG.configMigrationVersion).toBe(1);
|
||||
});
|
||||
});
|
||||
82
apps/web/tests/state/maxTokens.test.ts
Normal file
82
apps/web/tests/state/maxTokens.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import litellmData from '../../src/state/litellm-models.json';
|
||||
import {
|
||||
effectiveMaxTokens,
|
||||
FALLBACK_MAX_TOKENS,
|
||||
MAX_MAX_TOKENS,
|
||||
MIN_MAX_TOKENS,
|
||||
modelMaxTokensDefault,
|
||||
} from '../../src/state/maxTokens';
|
||||
|
||||
describe('modelMaxTokensDefault', () => {
|
||||
it('falls through to LiteLLM data for canonical Anthropic ids', () => {
|
||||
// 64k for the 4.5 line is the upstream value; this guards against the
|
||||
// sync script silently dropping or rewriting these entries.
|
||||
expect(modelMaxTokensDefault('claude-sonnet-4-5')).toBe(64000);
|
||||
expect(modelMaxTokensDefault('claude-opus-4-5')).toBe(64000);
|
||||
expect(modelMaxTokensDefault('claude-haiku-4-5')).toBe(64000);
|
||||
});
|
||||
|
||||
it('lets OVERRIDES win over LiteLLM data', () => {
|
||||
// mimo-v2.5-pro is not in LiteLLM, so this asserts the OVERRIDES path
|
||||
// (not the LiteLLM path) supplied the answer.
|
||||
expect((litellmData.models as Record<string, number>)['mimo-v2.5-pro']).toBeUndefined();
|
||||
expect(modelMaxTokensDefault('mimo-v2.5-pro')).toBe(32768);
|
||||
});
|
||||
|
||||
it('returns FALLBACK_MAX_TOKENS for unknown ids', () => {
|
||||
expect(modelMaxTokensDefault('definitely-not-a-real-model-x9z')).toBe(FALLBACK_MAX_TOKENS);
|
||||
expect(FALLBACK_MAX_TOKENS).toBe(8192);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveMaxTokens', () => {
|
||||
it('honors an explicit user override over the model default', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 12345, model: 'claude-sonnet-4-5' })).toBe(12345);
|
||||
});
|
||||
|
||||
it('uses the model default when no override is set', () => {
|
||||
expect(effectiveMaxTokens({ model: 'mimo-v2.5-pro' })).toBe(32768);
|
||||
expect(effectiveMaxTokens({ model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('falls back to FALLBACK_MAX_TOKENS for unknown models with no override', () => {
|
||||
expect(effectiveMaxTokens({ model: 'unknown-model' })).toBe(FALLBACK_MAX_TOKENS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveMaxTokens override validation', () => {
|
||||
// Stale localStorage, hand-edited config, or future schema drift can put
|
||||
// anything in cfg.maxTokens. The Settings UI advertises a [1024, 200000]
|
||||
// integer-stepped range, and the daemon proxy already clamps `> 0`, so
|
||||
// we tighten this entry point to match the advertised contract.
|
||||
|
||||
it('rejects negative overrides and falls back to the model default', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: -5, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects zero', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 0, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects overrides below MIN_MAX_TOKENS', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS - 1, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects overrides above MAX_MAX_TOKENS', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS + 1, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: 999_999_999, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('rejects non-integer overrides', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: 123.9, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: Number.NaN, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
expect(effectiveMaxTokens({ maxTokens: Number.POSITIVE_INFINITY, model: 'claude-sonnet-4-5' })).toBe(64000);
|
||||
});
|
||||
|
||||
it('accepts the boundary values exactly', () => {
|
||||
expect(effectiveMaxTokens({ maxTokens: MIN_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MIN_MAX_TOKENS);
|
||||
expect(effectiveMaxTokens({ maxTokens: MAX_MAX_TOKENS, model: 'claude-sonnet-4-5' })).toBe(MAX_MAX_TOKENS);
|
||||
});
|
||||
});
|
||||
42
apps/web/tests/utils/apiProtocol.test.ts
Normal file
42
apps/web/tests/utils/apiProtocol.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { apiProtocolLabel, apiProtocolModelLabel } from '../../src/utils/apiProtocol';
|
||||
import {
|
||||
agentDisplayName,
|
||||
agentModelDisplayName,
|
||||
exactAgentDisplayName,
|
||||
} from '../../src/utils/agentLabels';
|
||||
|
||||
describe('api protocol labels', () => {
|
||||
it('labels the selected API protocol instead of assuming Anthropic', () => {
|
||||
expect(apiProtocolLabel('openai')).toBe('OpenAI API');
|
||||
expect(apiProtocolLabel('google')).toBe('Google Gemini');
|
||||
expect(apiProtocolLabel(undefined)).toBe('Anthropic API');
|
||||
});
|
||||
|
||||
it('includes the selected model when labeling API assistant messages', () => {
|
||||
expect(apiProtocolModelLabel('openai', 'google/gemma-4-e4b')).toBe(
|
||||
'OpenAI API · google/gemma-4-e4b',
|
||||
);
|
||||
expect(apiProtocolModelLabel('azure', ' ')).toBe('Azure OpenAI');
|
||||
});
|
||||
|
||||
it('includes explicit local CLI models when labeling agent messages', () => {
|
||||
expect(agentModelDisplayName('claude', 'Claude Code', 'claude-sonnet-4-6')).toBe(
|
||||
'Claude · claude-sonnet-4-6',
|
||||
);
|
||||
expect(agentModelDisplayName('claude', 'Claude Code', 'default')).toBe('Claude');
|
||||
});
|
||||
|
||||
it('normalizes Qoder local CLI ids, aliases, and executable paths', () => {
|
||||
expect(agentDisplayName('qoder')).toBe('Qoder');
|
||||
expect(exactAgentDisplayName('qodercli')).toBe('Qoder');
|
||||
expect(exactAgentDisplayName('Qoder CLI')).toBe('Qoder');
|
||||
expect(agentDisplayName('/opt/homebrew/bin/qodercli')).toBe('Qoder');
|
||||
expect(agentDisplayName('C:\\Tools\\qodercli.cmd')).toBe('Qoder');
|
||||
});
|
||||
|
||||
it('includes explicit Qoder models but hides the default model', () => {
|
||||
expect(agentModelDisplayName('qoder', 'Qoder CLI', 'ultimate')).toBe('Qoder · ultimate');
|
||||
expect(agentModelDisplayName('qoder', 'Qoder CLI', 'default')).toBe('Qoder');
|
||||
});
|
||||
});
|
||||
31
apps/web/tests/utils/chatTime.test.ts
Normal file
31
apps/web/tests/utils/chatTime.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { ChatMessage } from '../../src/types';
|
||||
import { messageTime } from '../../src/utils/chatTime';
|
||||
|
||||
describe('messageTime', () => {
|
||||
it('uses assistant startedAt before persisted createdAt', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
startedAt: 100,
|
||||
createdAt: 200,
|
||||
endedAt: 300,
|
||||
};
|
||||
|
||||
expect(messageTime(message)).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps user createdAt as the primary timestamp', () => {
|
||||
const message: ChatMessage = {
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: 'Build this',
|
||||
startedAt: 100,
|
||||
createdAt: 200,
|
||||
};
|
||||
|
||||
expect(messageTime(message)).toBe(200);
|
||||
});
|
||||
});
|
||||
97
apps/web/tests/utils/notifications.test.ts
Normal file
97
apps/web/tests/utils/notifications.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { showCompletionNotification } from '../../src/utils/notifications';
|
||||
|
||||
type NotificationOptionsWithRenotify = NotificationOptions & { renotify?: boolean };
|
||||
|
||||
class MockNotification {
|
||||
static permission: NotificationPermission = 'granted';
|
||||
static instances: MockNotification[] = [];
|
||||
|
||||
onclose: (() => void) | null = null;
|
||||
onclick: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
public title: string,
|
||||
public options?: NotificationOptionsWithRenotify,
|
||||
) {
|
||||
MockNotification.instances.push(this);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Fire synchronously so tests can observe cleanup without browser events.
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
MockNotification.permission = 'granted';
|
||||
MockNotification.instances = [];
|
||||
});
|
||||
|
||||
describe('showCompletionNotification', () => {
|
||||
it('creates a renotifying desktop notification when permission is granted', async () => {
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'succeeded',
|
||||
title: 'Task completed',
|
||||
body: 'Done',
|
||||
});
|
||||
|
||||
expect(result).toBe('shown');
|
||||
expect(MockNotification.instances).toHaveLength(1);
|
||||
expect(MockNotification.instances[0]!.title).toBe('Task completed');
|
||||
expect(MockNotification.instances[0]!.options).toMatchObject({
|
||||
body: 'Done',
|
||||
tag: 'od-task-succeeded',
|
||||
renotify: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the service worker notification API when available', async () => {
|
||||
const showNotification = vi.fn().mockResolvedValue(undefined);
|
||||
const registration = { showNotification };
|
||||
const register = vi.fn().mockResolvedValue(registration);
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
vi.stubGlobal('navigator', {
|
||||
serviceWorker: {
|
||||
register,
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'succeeded',
|
||||
title: 'Task completed',
|
||||
body: 'Done',
|
||||
});
|
||||
|
||||
expect(result).toBe('shown');
|
||||
expect(register).toHaveBeenCalledWith('/od-notifications-sw.js');
|
||||
expect(showNotification).toHaveBeenCalledWith(
|
||||
'Task completed',
|
||||
expect.objectContaining({
|
||||
body: 'Done',
|
||||
tag: 'od-task-succeeded',
|
||||
renotify: true,
|
||||
}),
|
||||
);
|
||||
expect(MockNotification.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not create a notification when permission is not granted', async () => {
|
||||
MockNotification.permission = 'denied';
|
||||
vi.stubGlobal('Notification', MockNotification as unknown as typeof Notification);
|
||||
|
||||
const result = await showCompletionNotification({
|
||||
status: 'failed',
|
||||
title: 'Task failed',
|
||||
body: 'Error',
|
||||
});
|
||||
|
||||
expect(result).toBe('permission-denied');
|
||||
expect(MockNotification.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user