// @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([]); }); });