|
| 1 | +// @vitest-environment jsdom |
| 2 | +import { afterEach, describe, expect, it, vi } from 'vitest' |
| 3 | +import { TooltipProvider } from '@felinic/ui' |
| 4 | +import { createApp, h, nextTick, reactive } from 'vue' |
| 5 | +import MarkdownRender from 'markstream-vue' |
| 6 | +import { registerSharedMarkdownComponents } from './index' |
| 7 | +import { clearSiteIconLookups } from '@/utils/site-icon' |
| 8 | + |
| 9 | +const mocks = vi.hoisted(() => ({ siteIcon: vi.fn(async () => ({ data: { light: 'https://site.example/favicon.svg', dark: 'https://site.example/favicon-dark.svg' } })), openFile: vi.fn(() => true), openBrowserAt: vi.fn(() => true), error: vi.fn() })) |
| 10 | +vi.mock('@memohai/sdk', () => ({ getSiteIcon: mocks.siteIcon })) |
| 11 | +const settings = reactive({ resolvedColorMode: 'dark' }) |
| 12 | +vi.mock('@/store/settings', () => ({ useSettingsStore: () => settings })) |
| 13 | +vi.mock('@/store/workspace-tabs', () => ({ useWorkspaceTabsStore: () => mocks })) |
| 14 | +vi.mock('@felinic/ui', async (importOriginal) => ({ |
| 15 | + ...await importOriginal<typeof import('@felinic/ui')>(), toast: { error: mocks.error }, |
| 16 | +})) |
| 17 | +vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })) |
| 18 | + |
| 19 | +let unmount: (() => void) | undefined |
| 20 | +afterEach(() => { unmount?.(); settings.resolvedColorMode = 'dark'; document.body.innerHTML = ''; clearSiteIconLookups(); vi.clearAllMocks() }) |
| 21 | + |
| 22 | +async function render(content: string, { showTooltips = false, customId = 'link-test' } = {}) { |
| 23 | + registerSharedMarkdownComponents('link-test') |
| 24 | + const host = document.createElement('div') |
| 25 | + document.body.append(host) |
| 26 | + const app = createApp({ render: () => h(TooltipProvider, {}, () => h(MarkdownRender, { |
| 27 | + content, customId, final: true, typewriter: false, smoothStreaming: false, showTooltips, |
| 28 | + })) }) |
| 29 | + app.mount(host) |
| 30 | + unmount = () => app.unmount() |
| 31 | + await nextTick() |
| 32 | + await vi.waitFor(() => expect(host.querySelector('a')).not.toBeNull()) |
| 33 | + return host |
| 34 | +} |
| 35 | + |
| 36 | +describe('chat Markdown delivery links', () => { |
| 37 | + it('preserves inline rich labels, shows icons and opens each target in its own surface', async () => { |
| 38 | + const host = await render('做好了,可以[**打开试玩**](http://localhost:8080/index.html),也可以[查看源文件](/data/counter/index.html)或[查阅官网](https://example.com)。') |
| 39 | + const links = host.querySelectorAll('a') |
| 40 | + expect(links).toHaveLength(3) |
| 41 | + // Link previews must not fall back to the delayed native browser title. |
| 42 | + expect(links[0]!.getAttribute('title')).toBeNull() |
| 43 | + expect(host.querySelectorAll('svg')).toHaveLength(3) |
| 44 | + expect(links[0]!.querySelector('strong')?.textContent).toContain('打开试玩') |
| 45 | + links[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) |
| 46 | + expect(mocks.openBrowserAt).toHaveBeenCalledWith('localhost:8080/index.html') |
| 47 | + links[1]!.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) |
| 48 | + expect(mocks.openFile).toHaveBeenCalledWith('/data/counter/index.html') |
| 49 | + expect(links[2]!.target).toBe('_blank') |
| 50 | + expect(links[2]!.rel).toContain('noopener') |
| 51 | + }) |
| 52 | + it('keeps the renderer tooltip off even when the page enables markstream tooltips', async () => { |
| 53 | + // markstream lets an injected setting override LinkNode's prop; if that |
| 54 | + // stops holding, its popover opens next to the shared tooltip. An open |
| 55 | + // popover marks the anchor with aria-describedby. The stock renderer opens |
| 56 | + // it first, proving the check can see it and loading the lazily mounted |
| 57 | + // popover, so the second check only has to pass its 80ms open delay. |
| 58 | + const hover = new MouseEvent('mouseenter', { clientX: 1, clientY: 1 }) |
| 59 | + let anchor = (await render('[官网](https://site.example/docs)', { showTooltips: true, customId: 'stock' })).querySelector('a')! |
| 60 | + anchor.dispatchEvent(hover) |
| 61 | + await vi.waitFor(() => expect(anchor.getAttribute('aria-describedby')).not.toBeNull()) |
| 62 | + unmount?.() |
| 63 | + anchor = (await render('[官网](https://site.example/docs)', { showTooltips: true })).querySelector('a')! |
| 64 | + vi.useFakeTimers() |
| 65 | + try { |
| 66 | + anchor.dispatchEvent(hover) |
| 67 | + await vi.advanceTimersByTimeAsync(200) |
| 68 | + } finally { |
| 69 | + vi.useRealTimers() |
| 70 | + } |
| 71 | + expect(anchor.getAttribute('aria-describedby')).toBeNull() |
| 72 | + expect(anchor.getAttribute('title')).toBeNull() |
| 73 | + }) |
| 74 | + it('uses a loaded site icon and falls back when the icon is unavailable', async () => { |
| 75 | + const host = await render('[站点](https://site.example/path?q=private) [预览](http://localhost:8080/)') |
| 76 | + await vi.waitFor(() => expect(host.querySelector('img')).not.toBeNull()) |
| 77 | + // Only the origin leaves the browser; the shared path and query stay private. |
| 78 | + expect(mocks.siteIcon).toHaveBeenCalledWith({ query: { url: 'https://site.example' } }) |
| 79 | + const image = host.querySelector('img')! |
| 80 | + expect(image.src).toBe('https://site.example/favicon-dark.svg') |
| 81 | + expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') |
| 82 | + expect(host.querySelectorAll('img')).toHaveLength(1) |
| 83 | + image.dispatchEvent(new Event('load')) |
| 84 | + await nextTick() |
| 85 | + expect(image.classList.contains('invisible')).toBe(false) |
| 86 | + expect(host.querySelectorAll('svg')).toHaveLength(1) |
| 87 | + image.dispatchEvent(new Event('error')) |
| 88 | + await nextTick() |
| 89 | + expect(host.querySelector('img')).toBeNull() |
| 90 | + }) |
| 91 | + it('looks up each site once and switches icons with the theme without refetching', async () => { |
| 92 | + const host = await render('[a](https://site.example/a) [b](https://site.example/b?x=1) [c](https://site.example)') |
| 93 | + await vi.waitFor(() => expect(host.querySelectorAll('img')).toHaveLength(3)) |
| 94 | + expect(mocks.siteIcon).toHaveBeenCalledTimes(1) |
| 95 | + settings.resolvedColorMode = 'light' |
| 96 | + await vi.waitFor(() => expect(host.querySelector('img')?.src).toBe('https://site.example/favicon.svg')) |
| 97 | + expect(host.querySelector('img')?.style.colorScheme).toBe('light') |
| 98 | + expect(mocks.siteIcon).toHaveBeenCalledTimes(1) |
| 99 | + }) |
| 100 | + it('asks again for a site whose lookup came back empty', async () => { |
| 101 | + mocks.siteIcon.mockResolvedValueOnce({ data: { light: '', dark: '' } }) |
| 102 | + let host = await render('[a](https://site.example/a)') |
| 103 | + await vi.waitFor(() => expect(mocks.siteIcon).toHaveBeenCalledTimes(1)) |
| 104 | + expect(host.querySelector('img')).toBeNull() |
| 105 | + unmount?.() |
| 106 | + host = await render('[a](https://site.example/a)') |
| 107 | + await vi.waitFor(() => expect(host.querySelector('img')).not.toBeNull()) |
| 108 | + expect(mocks.siteIcon).toHaveBeenCalledTimes(2) |
| 109 | + }) |
| 110 | + it('keeps modifier and middle clicks in the workspace instead of navigating to the host', async () => { |
| 111 | + const host = await render('[文件](/data/a%20b.md)') |
| 112 | + const anchor = host.querySelector('a')! |
| 113 | + for (const event of [new MouseEvent('click', { bubbles: true, cancelable: true, ctrlKey: true }), new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 })]) { |
| 114 | + anchor.dispatchEvent(event) |
| 115 | + expect(event.defaultPrevented).toBe(true) |
| 116 | + } |
| 117 | + expect(mocks.openFile).toHaveBeenCalledWith('/data/a b.md') |
| 118 | + }) |
| 119 | + it('reports an unavailable preview without falling back to the user localhost', async () => { |
| 120 | + const host = await render('[预览](http://localhost:8080/)') |
| 121 | + mocks.openBrowserAt.mockReturnValueOnce(false) |
| 122 | + const event = new MouseEvent('click', { bubbles: true, cancelable: true }) |
| 123 | + host.querySelector('a')!.dispatchEvent(event) |
| 124 | + expect(event.defaultPrevented).toBe(true) |
| 125 | + expect(mocks.error).toHaveBeenCalledWith('chat.workspaceLinkUnavailable') |
| 126 | + }) |
| 127 | +}) |
0 commit comments