Files
nexu-io-open-design/apps/web/tests/components/DesignBrowserPanel.test.tsx
2026-06-11 11:51:26 +00:00

561 lines
20 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
BROWSER_USE_ACTION_TOTAL,
BROWSER_USE_CATEGORIES,
REFERENCE_GROUPS,
REFERENCE_TOTAL,
browserUseActionById,
browserUsePrompt,
browserFileName,
faviconUrl,
filterBrowserUseCategories,
filterReferenceGroups,
formatAddressDisplay,
formatAddressDisplayParts,
hostnameFromUrl,
isHistoryEntry,
isHistoryUrl,
labelFromUrl,
loadHistory,
normalizeBrowserAddress,
pageBriefMarkdown,
referenceIconUrl,
sameUrl,
saveHistory,
} from '../../src/components/DesignBrowserPanel';
import {
browserCommentFilePath,
isProjectHtmlBrowserUrl,
projectRelativePathFromBrowserUrl,
} from '../../src/components/design-browser-tools';
describe('normalizeBrowserAddress', () => {
it('passes through absolute http URLs unchanged', () => {
expect(normalizeBrowserAddress('http://example.com/page')).toBe('http://example.com/page');
});
it('passes through absolute https URLs unchanged', () => {
expect(normalizeBrowserAddress('https://example.com/page')).toBe('https://example.com/page');
});
it('passes through file URLs unchanged', () => {
expect(normalizeBrowserAddress('file:///Users/me/page.html')).toBe('file:///Users/me/page.html');
});
it('trims surrounding whitespace before matching', () => {
expect(normalizeBrowserAddress(' https://example.com ')).toBe('https://example.com');
});
it('promotes a bare domain to https', () => {
expect(normalizeBrowserAddress('example.com')).toBe('https://example.com');
});
it('promotes a bare domain with a path and port to https', () => {
expect(normalizeBrowserAddress('example.com:8080/path')).toBe('https://example.com:8080/path');
});
it('maps localhost to http', () => {
expect(normalizeBrowserAddress('localhost')).toBe('http://localhost');
expect(normalizeBrowserAddress('localhost:3000/dash')).toBe('http://localhost:3000/dash');
});
it('maps loopback IPs to http', () => {
expect(normalizeBrowserAddress('127.0.0.1')).toBe('http://127.0.0.1');
expect(normalizeBrowserAddress('127.0.0.1:5173')).toBe('http://127.0.0.1:5173');
expect(normalizeBrowserAddress('0.0.0.0:8000')).toBe('http://0.0.0.0:8000');
});
it('resolves /api, /artifacts, /frames paths against the page origin', () => {
const origin = window.location.origin;
expect(normalizeBrowserAddress('/api/runs')).toBe(`${origin}/api/runs`);
expect(normalizeBrowserAddress('/artifacts/x.png')).toBe(`${origin}/artifacts/x.png`);
expect(normalizeBrowserAddress('/frames/1')).toBe(`${origin}/frames/1`);
});
it('maps other absolute paths to file URLs', () => {
expect(normalizeBrowserAddress('/Users/me/page.html')).toBe('file:///Users/me/page.html');
expect(normalizeBrowserAddress('/some path/with space')).toBe(`file://${encodeURI('/some path/with space')}`);
});
it('treats free text as a Google search', () => {
expect(normalizeBrowserAddress('design inspiration')).toBe(
'https://www.google.com/search?q=design%20inspiration',
);
});
it('maps an empty string to about:blank', () => {
expect(normalizeBrowserAddress('')).toBe('about:blank');
expect(normalizeBrowserAddress(' ')).toBe('about:blank');
});
it('passes through an explicit about:blank', () => {
expect(normalizeBrowserAddress('about:blank')).toBe('about:blank');
});
});
describe('inspiration action prompts', () => {
it('keeps the inspiration catalogue at the intended 43 actions', () => {
expect(BROWSER_USE_ACTION_TOTAL).toBe(43);
expect(BROWSER_USE_CATEGORIES.map((category) => category.id)).toEqual([
'assets',
'tokens',
'motion',
'visual',
'structure',
'project',
'general',
]);
expect(BROWSER_USE_CATEGORIES[0]?.actions.slice(0, 3).map((action) => action.id)).toEqual([
'extract_logo',
'list_images',
'download_assets',
]);
expect(BROWSER_USE_CATEGORIES[BROWSER_USE_CATEGORIES.length - 1]?.actions.map((action) => action.id)).toContain('navigate');
expect(browserUseActionById('extract_fonts')?.output).toContain('typography.json');
expect(browserUseActionById('extract_og_metadata')?.output).toContain('OG/Twitter');
expect(browserUseActionById('audit_accessibility')?.output).toContain('A11y');
});
it('filters inspiration actions by action evidence and category title', () => {
const visualCategory = BROWSER_USE_CATEGORIES.find((category) => category.id === 'visual');
const categoryTitle = (category: (typeof BROWSER_USE_CATEGORIES)[number]) =>
({
assets: '素材提取',
tokens: '设计语言',
motion: '动效',
visual: '视觉校验',
structure: '组件结构',
project: '项目运行',
general: '通用操作',
})[category.id] ?? category.title;
expect(visualCategory).toBeTruthy();
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, 'a11y', categoryTitle)).toEqual([
{
...visualCategory!,
actions: [browserUseActionById('audit_accessibility')!],
},
]);
expect(
filterBrowserUseCategories(
BROWSER_USE_CATEGORIES,
'字体',
categoryTitle,
(action) => (action.id === 'extract_fonts' ? ['字体家族、字号、字重和 @font-face'] : []),
)[0]?.actions,
).toEqual([browserUseActionById('extract_fonts')!]);
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, '通用操作', categoryTitle)[0]?.id).toBe('general');
expect(filterBrowserUseCategories(BROWSER_USE_CATEGORIES, 'no-such-action', categoryTitle)).toEqual([]);
});
it('builds an agent-browser prompt bound to the current browser tab context', () => {
const action = browserUseActionById('extract_colors');
expect(action).not.toBeNull();
const prompt = browserUsePrompt(action!, {
browserFilePath: 'browser:https://example.com',
projectId: 'proj-1',
resolvedDir: '/tmp/open-design/project',
tabLabel: 'Example landing',
title: 'Example',
url: 'https://example.com',
});
expect(prompt).toContain('@agent-browser');
expect(prompt).toContain('Use the selected Open Design Browser tab as the bound target.');
expect(prompt).toContain('- tab: Example landing');
expect(prompt).toContain('- url: https://example.com');
expect(prompt).toContain('Operation: extract_colors');
expect(prompt).toContain('browser-use / browser-harness style evidence');
});
});
describe('browser tool file targeting', () => {
it('treats localhost dev-server pages as browser targets, not single-file HTML saves', () => {
const url = 'http://localhost:3000/src/App.jsx';
expect(browserCommentFilePath(url, '/Users/me/project')).toBe(`browser:${url}`);
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBeNull();
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(false);
});
it('maps project-local file HTML pages back to editable project files', () => {
const url = 'file:///Users/me/project/dist/index.html';
expect(browserCommentFilePath(url, '/Users/me/project')).toBe('dist/index.html');
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBe('dist/index.html');
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(true);
});
it('keeps project-local non-HTML files as commentable browser targets without save affordance', () => {
const url = 'file:///Users/me/project/src/App.jsx';
expect(browserCommentFilePath(url, '/Users/me/project')).toBe('src/App.jsx');
expect(projectRelativePathFromBrowserUrl(url, '/Users/me/project')).toBe('src/App.jsx');
expect(isProjectHtmlBrowserUrl(url, '/Users/me/project')).toBe(false);
});
});
describe('sameUrl', () => {
it('treats trailing slashes as equivalent', () => {
expect(sameUrl('https://example.com', 'https://example.com/')).toBe(true);
expect(sameUrl('https://example.com///', 'https://example.com')).toBe(true);
});
it('distinguishes different paths', () => {
expect(sameUrl('https://example.com/a', 'https://example.com/b')).toBe(false);
});
});
describe('labelFromUrl', () => {
it('returns New Tab for the blank URL', () => {
expect(labelFromUrl('about:blank')).toBe('New Tab');
});
it('strips the www. prefix from the host', () => {
expect(labelFromUrl('https://www.example.com/page')).toBe('example.com');
expect(labelFromUrl('https://sub.example.com/')).toBe('sub.example.com');
});
it('falls back to the raw value when the URL cannot be parsed', () => {
expect(labelFromUrl('not a url')).toBe('not a url');
});
});
describe('formatAddressDisplay', () => {
it('keeps the URL alone when the title is only the host fallback', () => {
expect(formatAddressDisplay('https://www.example.com/path', 'example.com')).toBe('https://www.example.com/path');
});
it('appends a real page title for the passive address display', () => {
expect(formatAddressDisplay('https://www.baidu.com/', '百度一下,你就知道')).toBe(
'https://www.baidu.com / 百度一下,你就知道',
);
});
it('exposes URL and title as separate passive display parts', () => {
expect(formatAddressDisplayParts('https://brandfetch.com/', 'Just a moment...')).toEqual({
url: 'https://brandfetch.com',
title: 'Just a moment...',
});
});
it('keeps the blank tab display empty', () => {
expect(formatAddressDisplay('about:blank', 'New Tab')).toBe('');
});
});
describe('hostnameFromUrl', () => {
it('returns a compact hostname without www', () => {
expect(hostnameFromUrl('https://www.example.com/docs')).toBe('example.com');
});
it('falls back to the raw value when parsing fails', () => {
expect(hostnameFromUrl('not a url')).toBe('not a url');
});
});
describe('faviconUrl', () => {
it('derives a same-origin favicon URL for http pages', () => {
expect(faviconUrl('https://www.example.com/docs')).toBe('https://www.example.com/favicon.ico');
});
it('skips non-http urls', () => {
expect(faviconUrl('file:///Users/me/page.html')).toBeUndefined();
});
});
describe('referenceIconUrl', () => {
it('routes a reference site through the favicon service with its hostname', () => {
expect(referenceIconUrl('https://dribbble.com/')).toBe(
'https://www.google.com/s2/favicons?sz=64&domain=dribbble.com',
);
});
it('preserves subdomains and accepts a custom size', () => {
expect(referenceIconUrl('https://styles.refero.design/', 32)).toBe(
'https://www.google.com/s2/favicons?sz=32&domain=styles.refero.design',
);
});
it('returns a resolvable icon for every catalogued reference site', () => {
for (const group of REFERENCE_GROUPS) {
for (const site of group.sites) {
expect(referenceIconUrl(site.url)).toMatch(
/^https:\/\/www\.google\.com\/s2\/favicons\?sz=64&domain=/,
);
}
}
});
it('skips non-http urls so the globe fallback still applies', () => {
expect(referenceIconUrl('file:///Users/me/page.html')).toBeUndefined();
});
});
describe('isHistoryUrl', () => {
it('accepts http(s) and file URLs', () => {
expect(isHistoryUrl('https://example.com')).toBe(true);
expect(isHistoryUrl('http://localhost:3000')).toBe(true);
expect(isHistoryUrl('file:///Users/me/x.html')).toBe(true);
});
it('rejects the blank URL', () => {
expect(isHistoryUrl('about:blank')).toBe(false);
});
it('rejects non http/file schemes', () => {
expect(isHistoryUrl('data:text/html,hi')).toBe(false);
expect(isHistoryUrl('mailto:hi@example.com')).toBe(false);
});
});
describe('pageBriefMarkdown', () => {
it('renders title, source, and populated sections while skipping empty ones', () => {
const md = pageBriefMarkdown(
{
title: 'Example',
url: 'https://example.com',
description: 'A description',
headings: ['Hero', ' ', 'Features'],
images: [],
links: [{ text: 'Docs', url: 'https://example.com/docs' }],
colors: [{ value: 'rgb(0, 0, 0)', count: 4 }],
},
'https://fallback.example.com',
);
expect(md).toContain('# Example');
expect(md).toContain('Source: https://example.com');
expect(md).toContain('## Description');
expect(md).toContain('## Headings');
expect(md).toContain('- Hero');
expect(md).toContain('- Features');
expect(md).not.toContain('## Images');
expect(md).toContain('## Links');
expect(md).toContain('- Docs - https://example.com/docs');
expect(md).toContain('## Colors');
expect(md).toContain('- rgb(0, 0, 0) (4)');
expect(md).not.toContain('Browser Harness');
});
it('falls back to label and url when the brief omits them', () => {
const md = pageBriefMarkdown({}, 'https://www.fallback.example.com/path');
expect(md).toContain('# fallback.example.com');
expect(md).toContain('Source: https://www.fallback.example.com/path');
});
});
describe('browserFileName', () => {
it('sanitizes the host and includes the prefix and extension', () => {
const name = browserFileName('browser-capture', 'https://www.example.com/page', 'png');
expect(name).toMatch(/^browser\/browser-capture-example\.com-[\dTZ-]+\.png$/);
});
it('uses a page fallback when the host sanitizes to empty', () => {
const name = browserFileName('browser-brief', 'about:blank', 'md');
// about:blank -> labelFromUrl 'New Tab' -> 'New-Tab'
expect(name).toMatch(/^browser\/browser-brief-New-Tab-[\dTZ-]+\.md$/);
});
});
describe('isHistoryEntry', () => {
it('accepts a well-formed entry', () => {
expect(
isHistoryEntry({ url: 'https://x', title: 'X', iconUrl: 'https://x/favicon.ico', lastVisitedAt: 1, visitCount: 1 }),
).toBe(true);
});
it('rejects malformed values', () => {
expect(isHistoryEntry(null)).toBe(false);
expect(isHistoryEntry([])).toBe(false);
expect(isHistoryEntry('x')).toBe(false);
expect(isHistoryEntry({ url: 1, title: 'X', lastVisitedAt: 1, visitCount: 1 })).toBe(false);
expect(isHistoryEntry({ url: 'x', title: 'X', lastVisitedAt: 1 })).toBe(false);
});
});
describe('loadHistory / saveHistory round-trip', () => {
const projectId = 'proj-history';
beforeEach(() => {
window.localStorage.clear();
});
afterEach(() => {
window.localStorage.clear();
});
it('returns an empty array when nothing is stored', () => {
expect(loadHistory(projectId)).toEqual([]);
});
it('round-trips entries and sorts by lastVisitedAt descending', () => {
saveHistory(projectId, [
{ url: 'https://a.com', title: 'A', lastVisitedAt: 100, visitCount: 1 },
{ url: 'https://b.com', title: 'B', lastVisitedAt: 300, visitCount: 2 },
{ url: 'https://c.com', title: 'C', lastVisitedAt: 200, visitCount: 1 },
]);
const loaded = loadHistory(projectId);
expect(loaded.map((entry) => entry.url)).toEqual([
'https://b.com',
'https://c.com',
'https://a.com',
]);
});
it('drops malformed entries on load', () => {
window.localStorage.setItem(
`od:design-browser:${projectId}:history:v1`,
JSON.stringify([
{ url: 'https://ok.com', title: 'OK', lastVisitedAt: 1, visitCount: 1 },
{ url: 123, title: 'bad', lastVisitedAt: 1, visitCount: 1 },
]),
);
const loaded = loadHistory(projectId);
expect(loaded).toHaveLength(1);
expect(loaded[0]?.url).toBe('https://ok.com');
});
it('returns an empty array for corrupt or non-array JSON', () => {
const key = `od:design-browser:${projectId}:history:v1`;
window.localStorage.setItem(key, 'not json');
expect(loadHistory(projectId)).toEqual([]);
window.localStorage.setItem(key, JSON.stringify({ not: 'an array' }));
expect(loadHistory(projectId)).toEqual([]);
});
it('caps stored history at the HISTORY_LIMIT on save and load', () => {
const many = Array.from({ length: 120 }, (_, index) => ({
url: `https://site-${index}.com`,
title: `Site ${index}`,
lastVisitedAt: index,
visitCount: 1,
}));
saveHistory(projectId, many);
expect(loadHistory(projectId)).toHaveLength(80);
});
});
describe('REFERENCE_GROUPS catalogue', () => {
it('exposes every documented designer reference category', () => {
const ids = REFERENCE_GROUPS.map((group) => group.id);
expect(ids).toEqual([
'inspiration',
'interfaces',
'motion',
'color',
'type',
'icons',
'illustration',
'photography',
'3d',
'mockups',
'systems',
'components',
'guidelines',
'tools',
]);
});
it('gives every group a title and at least one absolute https/http site', () => {
for (const group of REFERENCE_GROUPS) {
expect(group.title.length).toBeGreaterThan(0);
expect(group.sites.length).toBeGreaterThan(0);
for (const site of group.sites) {
expect(site.label.length).toBeGreaterThan(0);
expect(site.detail.length).toBeGreaterThan(0);
expect(site.url).toMatch(/^https?:\/\//);
}
}
});
it('uses unique category ids and unique site urls', () => {
const ids = REFERENCE_GROUPS.map((group) => group.id);
expect(new Set(ids).size).toBe(ids.length);
const urls = REFERENCE_GROUPS.flatMap((group) => group.sites.map((site) => site.url));
expect(new Set(urls).size).toBe(urls.length);
});
it('keeps REFERENCE_TOTAL in sync with the catalogue', () => {
const counted = REFERENCE_GROUPS.reduce((sum, group) => sum + group.sites.length, 0);
expect(REFERENCE_TOTAL).toBe(counted);
});
it('includes the handoff and component-library reference URLs', () => {
const urls = new Set(REFERENCE_GROUPS.flatMap((group) => group.sites.map((site) => site.url)));
expect(Array.from(urls)).toEqual(expect.arrayContaining([
'https://thesvg.org/',
'https://unsplash.com/',
'https://motionsites.ai/',
'https://motion.page/showcase/',
'https://styles.refero.design/',
'https://brandfetch.com/',
'https://gsap.com/',
'https://transitions.dev/',
'https://fonts.google.com/',
'https://animography.net/',
'https://reactbits.dev/text-animations/shiny-text',
'https://toolfolio.io/',
'https://www.whirrls.com/',
'https://startups.gallery/',
'https://www.worldindots.com/',
'https://getdesign.md/',
'https://github.com/superset-sh/superset',
'https://svglogos.dev/',
'https://icons.lobehub.com/',
'https://animations.dev/',
'https://impeccable.style/',
'https://www.tasteskill.dev/',
'https://base-ui.com/',
'https://ui.shadcn.com/',
'https://www.heroui.com/',
]));
});
});
describe('filterReferenceGroups', () => {
it('returns every group untouched for the "all" category and an empty query', () => {
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', '');
expect(result.map((group) => group.id)).toEqual(REFERENCE_GROUPS.map((group) => group.id));
expect(result).toEqual(REFERENCE_GROUPS);
});
it('narrows to a single group when a category id is active', () => {
const result = filterReferenceGroups(REFERENCE_GROUPS, 'motion', '');
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe('motion');
expect(result[0]?.sites.length).toBeGreaterThan(0);
});
it('matches sites by label, hostname, or detail across all categories', () => {
const byLabel = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'dribbble');
expect(byLabel.flatMap((group) => group.sites.map((site) => site.label))).toContain('Dribbble');
const byHostname = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'unsplash.com');
expect(byHostname.flatMap((group) => group.sites.map((site) => site.label))).toContain('Unsplash');
const byDetail = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'contrast');
expect(byDetail.flatMap((group) => group.sites.map((site) => site.label))).toContain('WebAIM Contrast');
});
it('keeps an entire group when the query matches its title', () => {
const color = REFERENCE_GROUPS.find((group) => group.id === 'color');
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'color');
const matchedColor = result.find((group) => group.id === 'color');
expect(matchedColor?.sites).toEqual(color?.sites);
});
it('drops groups with no surviving sites and is case-insensitive', () => {
const result = filterReferenceGroups(REFERENCE_GROUPS, 'all', 'COOLORS');
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe('color');
expect(result[0]?.sites.map((site) => site.label)).toEqual(['Coolors']);
});
it('returns an empty array when nothing matches', () => {
expect(filterReferenceGroups(REFERENCE_GROUPS, 'all', 'zzz-no-such-reference')).toEqual([]);
});
});