| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413 |
- // @vitest-environment jsdom
- /** Browser-action feedback and route-owned asynchronous clipboard work. */
- import assert from 'node:assert/strict'
- import { createApp, createSSRApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue'
- import { renderToString } from 'vue/server-renderer'
- import { fireEvent, getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom'
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import Theme from '../.vitepress/theme/index.ts'
- vi.mock('vitepress', () => ({ useData: () => data, useRoute: () => route }))
- vi.mock('vitepress/theme', () => ({ default: { Layout: {
- setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()),
- } } }))
- vi.mock('../.vitepress/theme/mermaid-viewer.ts', () => ({ installMermaidViewer: () => ({ refresh() {}, dispose() {} }) }))
- const data = {
- lang: ref('en-US'), isDark: ref(false),
- frontmatter: ref<Record<string, unknown>>({}),
- site: ref({ base: '/' }), page: ref({ isNotFound: false }),
- }
- const route = reactive({ path: '/en/guide/quickstart' })
- const fetchMock = vi.fn<typeof fetch>()
- const write = vi.fn<(items: ClipboardItem[]) => Promise<void>>()
- let app: App | undefined
- let host: HTMLDivElement
- let copied: Blob | undefined
- let activeWrite: Promise<void> | undefined
- const deferredWork: { promise: Promise<unknown>; reject: (reason: unknown) => void }[] = []
- class TestClipboardItem {
- constructor(readonly data: Record<string, Promise<Blob>>) {}
- }
- function deferred<T>() {
- let resolve!: (value: T) => void
- let reject!: (reason: unknown) => void
- const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
- deferredWork.push({ promise, reject })
- void promise.catch((_error: unknown) => {
- // Teardown can reject a barrier before an assertion reaches its consumer.
- })
- return { promise, resolve, reject }
- }
- function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n', status = 200, type = 'text/markdown') {
- return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } })
- }
- async function mount() {
- app = createApp(Theme.Layout)
- app.mount(host)
- await nextTick()
- }
- async function openMenu() {
- const toggle = getByRole(host, 'button', { name: 'More page actions' })
- fireEvent.click(toggle)
- await nextTick()
- return toggle
- }
- beforeEach(() => {
- host = document.createElement('div')
- document.body.append(host)
- data.lang.value = 'en-US'
- data.site.value = { base: '/' }
- data.frontmatter.value = { rawMarkdownPath: 'en/guide/quickstart.md' }
- data.page.value = { isNotFound: false }
- route.path = '/en/guide/quickstart'
- copied = undefined
- activeWrite = undefined
- fetchMock.mockReset().mockResolvedValue(response())
- write.mockReset().mockImplementation((items) => {
- const item = items[0] as unknown as TestClipboardItem
- const content = item.data['text/plain']
- assert(content !== undefined)
- activeWrite = content.then((blob) => { copied = blob })
- return activeWrite
- })
- vi.stubGlobal('fetch', fetchMock)
- vi.stubGlobal('ClipboardItem', TestClipboardItem)
- vi.stubGlobal('navigator', { clipboard: { write } })
- })
- afterEach(async () => {
- app?.unmount()
- app = undefined
- const work = deferredWork.splice(0)
- for (const pending of work) pending.reject(new Error('Test disposed'))
- await Promise.allSettled([...work.map(pending => pending.promise), activeWrite])
- host.remove()
- vi.unstubAllGlobals()
- })
- describe('page Markdown actions', () => {
- it.each(['en-US', 'zh-CN'])('preserves the accessible controls and status in %s', async (lang) => {
- data.lang.value = lang
- data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` }
- await mount()
- await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`)
- fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' }))
- await nextTick()
- await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`)
- })
- it.each(['en-US', 'zh-CN'])('offers a working raw link before hydration in %s', async (lang) => {
- data.lang.value = lang
- data.site.value = { base: '/deepseek-harness/' }
- const path = `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md`
- data.frontmatter.value = { rawMarkdownPath: path }
- host.innerHTML = await renderToString(createSSRApp(Theme.Layout))
- expect(queryAllByRole(host, 'button')).toHaveLength(0)
- const link = getByRole(host, 'link')
- expect(link.getAttribute('href')).toBe(`/deepseek-harness/${path}`)
- expect(link.getAttribute('target')).toBe('_blank')
- expect(link.getAttribute('aria-label')).toBe(lang === 'en-US'
- ? 'View as Markdown (opens in a new tab)' : '以 Markdown 格式查看(在新标签页打开)')
- await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-static.${lang}.html`)
- expect(fetchMock).not.toHaveBeenCalled()
- })
- it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => {
- await mount()
- const toggle = getByRole(host, 'button', { name: 'More page actions' })
- toggle.focus()
- fireEvent.keyDown(toggle, { key: 'ArrowDown' })
- await nextTick()
- const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
- const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
- expect(document.activeElement).toBe(copy)
- expect(toggle.getAttribute('aria-expanded')).toBe('true')
- fireEvent.keyDown(copy, { key: 'ArrowUp' })
- expect(document.activeElement).toBe(view)
- fireEvent.keyDown(view, { key: 'ArrowDown' })
- expect(document.activeElement).toBe(copy)
- fireEvent.keyDown(copy, { key: 'End' })
- expect(document.activeElement).toBe(view)
- fireEvent.keyDown(view, { key: 'Home' })
- expect(document.activeElement).toBe(copy)
- fireEvent.keyDown(copy, { key: 'Escape' })
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- expect(document.activeElement).toBe(toggle)
- expect(toggle.getAttribute('aria-expanded')).toBe('false')
- fireEvent.keyDown(toggle, { key: 'ArrowUp' })
- await nextTick()
- expect(document.activeElement).toBe(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }))
- expect(fetchMock).not.toHaveBeenCalled()
- })
- it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => {
- await mount()
- const toggle = await openMenu()
- fireEvent.pointerDown(document.body)
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- await openMenu()
- fireEvent.focusOut(getByRole(host, 'menuitem', { name: 'Copy page' }), { relatedTarget: document.body })
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- await openMenu()
- fireEvent.click(toggle)
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- })
- it.each(['copy', 'view', 'toggle'])('keeps %s activation available when pointer clicks do not focus controls', async (action) => {
- await mount()
- const toggle = await openMenu()
- const target = action === 'toggle' ? toggle : getByRole(host, 'menuitem', {
- name: action === 'copy' ? 'Copy page' : 'View as Markdown (opens in a new tab)',
- })
- let activated = false
- target.addEventListener('click', (event) => {
- activated = true
- if (action === 'view') event.preventDefault()
- }, { once: true })
- fireEvent.pointerDown(target)
- // Safari can blur the focused menu item without focusing the pressed control.
- if (fireEvent.mouseDown(target)) (document.activeElement as HTMLElement).blur()
- await nextTick()
- expect(target.isConnected).toBe(true)
- expect(queryByRole(host, 'menu')).not.toBeNull()
- fireEvent.mouseUp(target)
- fireEvent.click(target)
- await nextTick()
- expect(activated).toBe(true)
- expect(queryByRole(host, 'menu')).toBeNull()
- expect(document.activeElement).toBe(toggle)
- if (action === 'copy') {
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
- expect(write).toHaveBeenCalledOnce()
- } else {
- expect(write).not.toHaveBeenCalled()
- }
- })
- it('copies from the menu in the same click and prevents another copy while it is pending', async () => {
- const pending = deferred<Response>()
- fetchMock.mockReturnValueOnce(pending.promise)
- await mount()
- const toggle = await openMenu()
- fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' }))
- expect(write).toHaveBeenCalledOnce()
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- expect(document.activeElement).toBe(toggle)
- await openMenu()
- const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
- expect(copy.getAttribute('aria-disabled')).toBe('true')
- fireEvent.click(copy)
- expect(write).toHaveBeenCalledOnce()
- expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')).toBe('/en/guide/quickstart.md')
- pending.resolve(response())
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
- })
- it('opens the raw link from the menu and returns focus to the toggle', async () => {
- await mount()
- const toggle = await openMenu()
- const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
- view.addEventListener('click', (event) => { event.preventDefault() }, { once: true })
- fireEvent.keyDown(view, { key: ' ' })
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- expect(document.activeElement).toBe(toggle)
- expect(fetchMock).not.toHaveBeenCalled()
- })
- it('removes outside-pointer listeners and closes its menu when the route changes', async () => {
- await mount()
- const add = vi.spyOn(document, 'addEventListener')
- const remove = vi.spyOn(document, 'removeEventListener')
- try {
- await openMenu()
- const listener = add.mock.calls.find(([name]) => name === 'pointerdown')?.[1]
- expect(listener).toBeTypeOf('function')
- route.path = '/en/reference/'
- data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
- await nextTick()
- expect(queryByRole(host, 'menu')).toBeNull()
- expect(remove).toHaveBeenCalledWith('pointerdown', listener, true)
- await openMenu()
- app?.unmount()
- app = undefined
- expect(remove.mock.calls.filter(([name]) => name === 'pointerdown')).toHaveLength(2)
- fireEvent.pointerDown(document.body)
- expect(host.textContent).toBe('')
- } finally {
- add.mockRestore()
- remove.mockRestore()
- }
- })
- it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', async (base) => {
- data.site.value = { base }
- data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
- route.path = `${base}en/reference/index.html?from=nav#api`
- await mount()
- await openMenu()
- const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
- expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`)
- expect(link.getAttribute('target')).toBe('_blank')
- expect(fetchMock).not.toHaveBeenCalled()
- })
- it.each(['home', '404'])('omits actions on %s', async (kind) => {
- if (kind === 'home') data.frontmatter.value = { layout: false }
- else data.page.value = { isNotFound: true }
- await mount()
- expect(queryByRole(host, 'button')).toBeNull()
- expect(queryByRole(host, 'link')).toBeNull()
- })
- it('starts clipboard.write during the click and copies the fetched plain text before reporting success', async () => {
- const pending = deferred<Response>()
- fetchMock.mockReturnValue(pending.promise)
- await mount()
- const button = getByRole(host, 'button', { name: 'Copy page' })
- button.focus()
- fireEvent.click(button)
- expect(write).toHaveBeenCalledOnce()
- expect(copied).toBeUndefined()
- fireEvent.click(button)
- expect(fetchMock).toHaveBeenCalledOnce()
- expect(fetchMock.mock.calls[0]?.[0]).toBe('/en/guide/quickstart.md?dsh-raw=1')
- expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
- await nextTick()
- expect(button).toHaveProperty('disabled', false)
- expect(button.getAttribute('aria-disabled')).toBe('true')
- expect(button.getAttribute('aria-busy')).toBe('true')
- expect(document.activeElement).toBe(button)
- expect(getByRole(host, 'status').textContent).toBe('Copying…')
- pending.resolve(response())
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
- expect(copied?.type).toBe('text/plain')
- const reader = new FileReader()
- const text = new Promise((resolve) => { reader.addEventListener('load', () => { resolve(reader.result) }, { once: true }) })
- assert(copied !== undefined)
- reader.readAsText(copied)
- expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n')
- expect(button.getAttribute('aria-disabled')).toBe('false')
- expect(button.getAttribute('aria-busy')).toBe('false')
- expect(document.activeElement).toBe(button)
- })
- it.each(['network', '404', 'html'])('reports a %s response failure without success and allows retry', async (failure) => {
- if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline'))
- else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown'))
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
- expect(copied).toBeUndefined()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
- })
- it('accepts static hosting that serves Markdown as plain text', async () => {
- fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain'))
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
- })
- it.each(['application/javascript', 'application/octet-stream', undefined])('rejects a %s content type without copying it', async (type) => {
- fetchMock.mockResolvedValueOnce(new Response(new TextEncoder().encode('# Body\n'), {
- headers: type === undefined ? {} : { 'content-type': type },
- }))
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
- expect(copied).toBeUndefined()
- })
- it('reports failure when reading the response body rejects', async () => {
- const raw = response()
- vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed'))
- fetchMock.mockResolvedValueOnce(raw)
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
- expect(copied).toBeUndefined()
- })
- it('keeps new-page feedback independent of a previous page clipboard write', async () => {
- const writeDone = deferred<void>()
- write.mockImplementationOnce(() => writeDone.promise)
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- route.path = '/en/reference/'
- data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
- await nextTick()
- writeDone.resolve()
- await writeDone.promise
- await nextTick()
- expect(getByRole(host, 'status').textContent).toBe('')
- })
- it.each(['write', 'item'])('provides manual-copy feedback when the %s API is missing', async (missing) => {
- if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined)
- else vi.stubGlobal('navigator', {})
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await nextTick()
- expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
- await openMenu()
- expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })).toBeTruthy()
- expect(fetchMock).not.toHaveBeenCalled()
- })
- it('aborts an unconsumed fetch when the clipboard rejects before consuming its data', async () => {
- const pending = deferred<Response>()
- fetchMock.mockReturnValue(pending.promise)
- write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError'))
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') })
- expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
- pending.reject(new DOMException('Aborted', 'AbortError'))
- await pending.promise.catch((error: unknown) => { void error })
- await nextTick()
- expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
- })
- it.each(['route', 'locale', 'unmount'])('cancels old data and ignores its completion after %s changes', async (change) => {
- const pending = deferred<Response>()
- fetchMock.mockReturnValueOnce(pending.promise)
- await mount()
- fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
- const oldWrite = activeWrite
- assert(oldWrite !== undefined)
- const signal = fetchMock.mock.calls[0]?.[1]?.signal
- if (change === 'unmount') { app?.unmount(); app = undefined }
- else {
- route.path = change === 'route' ? '/en/reference/' : '/guide/quickstart'
- data.lang.value = change === 'route' ? 'en-US' : 'zh-CN'
- data.frontmatter.value = { rawMarkdownPath: change === 'route' ? 'en/reference/index.md' : 'guide/quickstart.md' }
- }
- await nextTick()
- expect(signal?.aborted).toBe(true)
- pending.resolve(response('# Old page\n'))
- await expect(oldWrite).rejects.toThrow()
- await nextTick()
- expect(copied).toBeUndefined()
- if (change === 'unmount') expect(host.textContent).toBe('')
- else {
- expect(getByRole(host, 'status').textContent).toBe('')
- fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy page' : '复制页面' }))
- await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe(change === 'route' ? 'Markdown copied.' : '已复制 Markdown。') })
- expect(fetchMock.mock.calls[1]?.[0]).toBe(change === 'route' ? '/en/reference/index.md?dsh-raw=1' : '/guide/quickstart.md?dsh-raw=1')
- }
- })
- })
|