page-markdown-actions.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. // @vitest-environment jsdom
  2. /** Browser-action feedback and route-owned asynchronous clipboard work. */
  3. import assert from 'node:assert/strict'
  4. import { createApp, createSSRApp, h, nextTick, reactive, ref, type App, type Slots } from 'vue'
  5. import { renderToString } from 'vue/server-renderer'
  6. import { fireEvent, getByRole, queryAllByRole, queryByRole, waitFor } from '@testing-library/dom'
  7. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  8. import Theme from '../.vitepress/theme/index.ts'
  9. vi.mock('vitepress', () => ({ useData: () => data, useRoute: () => route }))
  10. vi.mock('vitepress/theme', () => ({ default: { Layout: {
  11. setup: (_props: unknown, { slots }: { slots: Slots }) => () => h('main', null, slots['doc-before']?.()),
  12. } } }))
  13. vi.mock('../.vitepress/theme/mermaid-viewer.ts', () => ({ installMermaidViewer: () => ({ refresh() {}, dispose() {} }) }))
  14. const data = {
  15. lang: ref('en-US'), isDark: ref(false),
  16. frontmatter: ref<Record<string, unknown>>({}),
  17. site: ref({ base: '/' }), page: ref({ isNotFound: false }),
  18. }
  19. const route = reactive({ path: '/en/guide/quickstart' })
  20. const fetchMock = vi.fn<typeof fetch>()
  21. const write = vi.fn<(items: ClipboardItem[]) => Promise<void>>()
  22. let app: App | undefined
  23. let host: HTMLDivElement
  24. let copied: Blob | undefined
  25. let activeWrite: Promise<void> | undefined
  26. const deferredWork: { promise: Promise<unknown>; reject: (reason: unknown) => void }[] = []
  27. class TestClipboardItem {
  28. constructor(readonly data: Record<string, Promise<Blob>>) {}
  29. }
  30. function deferred<T>() {
  31. let resolve!: (value: T) => void
  32. let reject!: (reason: unknown) => void
  33. const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
  34. deferredWork.push({ promise, reject })
  35. void promise.catch((_error: unknown) => {
  36. // Teardown can reject a barrier before an assertion reaches its consumer.
  37. })
  38. return { promise, resolve, reject }
  39. }
  40. function response(text = '# Quickstart\n\n[Reference](../reference/index.md)\n', status = 200, type = 'text/markdown') {
  41. return new Response(text, { status, headers: { 'content-type': `${type}; charset=utf-8` } })
  42. }
  43. async function mount() {
  44. app = createApp(Theme.Layout)
  45. app.mount(host)
  46. await nextTick()
  47. }
  48. async function openMenu() {
  49. const toggle = getByRole(host, 'button', { name: 'More page actions' })
  50. fireEvent.click(toggle)
  51. await nextTick()
  52. return toggle
  53. }
  54. beforeEach(() => {
  55. host = document.createElement('div')
  56. document.body.append(host)
  57. data.lang.value = 'en-US'
  58. data.site.value = { base: '/' }
  59. data.frontmatter.value = { rawMarkdownPath: 'en/guide/quickstart.md' }
  60. data.page.value = { isNotFound: false }
  61. route.path = '/en/guide/quickstart'
  62. copied = undefined
  63. activeWrite = undefined
  64. fetchMock.mockReset().mockResolvedValue(response())
  65. write.mockReset().mockImplementation((items) => {
  66. const item = items[0] as unknown as TestClipboardItem
  67. const content = item.data['text/plain']
  68. assert(content !== undefined)
  69. activeWrite = content.then((blob) => { copied = blob })
  70. return activeWrite
  71. })
  72. vi.stubGlobal('fetch', fetchMock)
  73. vi.stubGlobal('ClipboardItem', TestClipboardItem)
  74. vi.stubGlobal('navigator', { clipboard: { write } })
  75. })
  76. afterEach(async () => {
  77. app?.unmount()
  78. app = undefined
  79. const work = deferredWork.splice(0)
  80. for (const pending of work) pending.reject(new Error('Test disposed'))
  81. await Promise.allSettled([...work.map(pending => pending.promise), activeWrite])
  82. host.remove()
  83. vi.unstubAllGlobals()
  84. })
  85. describe('page Markdown actions', () => {
  86. it.each(['en-US', 'zh-CN'])('preserves the accessible controls and status in %s', async (lang) => {
  87. data.lang.value = lang
  88. data.frontmatter.value = { rawMarkdownPath: `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md` }
  89. await mount()
  90. await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-actions.${lang}.html`)
  91. fireEvent.click(getByRole(host, 'button', { name: lang === 'en-US' ? 'More page actions' : '更多页面操作' }))
  92. await nextTick()
  93. await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-menu.${lang}.html`)
  94. })
  95. it.each(['en-US', 'zh-CN'])('offers a working raw link before hydration in %s', async (lang) => {
  96. data.lang.value = lang
  97. data.site.value = { base: '/deepseek-harness/' }
  98. const path = `${lang === 'en-US' ? 'en/' : ''}guide/quickstart.md`
  99. data.frontmatter.value = { rawMarkdownPath: path }
  100. host.innerHTML = await renderToString(createSSRApp(Theme.Layout))
  101. expect(queryAllByRole(host, 'button')).toHaveLength(0)
  102. const link = getByRole(host, 'link')
  103. expect(link.getAttribute('href')).toBe(`/deepseek-harness/${path}`)
  104. expect(link.getAttribute('target')).toBe('_blank')
  105. expect(link.getAttribute('aria-label')).toBe(lang === 'en-US'
  106. ? 'View as Markdown (opens in a new tab)' : '以 Markdown 格式查看(在新标签页打开)')
  107. await expect(`${host.innerHTML}\n`).toMatchFileSnapshot(`./expected/page-markdown-static.${lang}.html`)
  108. expect(fetchMock).not.toHaveBeenCalled()
  109. })
  110. it('opens from the keyboard, moves between actions, and restores focus on Escape', async () => {
  111. await mount()
  112. const toggle = getByRole(host, 'button', { name: 'More page actions' })
  113. toggle.focus()
  114. fireEvent.keyDown(toggle, { key: 'ArrowDown' })
  115. await nextTick()
  116. const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
  117. const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
  118. expect(document.activeElement).toBe(copy)
  119. expect(toggle.getAttribute('aria-expanded')).toBe('true')
  120. fireEvent.keyDown(copy, { key: 'ArrowUp' })
  121. expect(document.activeElement).toBe(view)
  122. fireEvent.keyDown(view, { key: 'ArrowDown' })
  123. expect(document.activeElement).toBe(copy)
  124. fireEvent.keyDown(copy, { key: 'End' })
  125. expect(document.activeElement).toBe(view)
  126. fireEvent.keyDown(view, { key: 'Home' })
  127. expect(document.activeElement).toBe(copy)
  128. fireEvent.keyDown(copy, { key: 'Escape' })
  129. await nextTick()
  130. expect(queryByRole(host, 'menu')).toBeNull()
  131. expect(document.activeElement).toBe(toggle)
  132. expect(toggle.getAttribute('aria-expanded')).toBe('false')
  133. fireEvent.keyDown(toggle, { key: 'ArrowUp' })
  134. await nextTick()
  135. expect(document.activeElement).toBe(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }))
  136. expect(fetchMock).not.toHaveBeenCalled()
  137. })
  138. it('dismisses on an outside pointer, focus leaving, and a second toggle click', async () => {
  139. await mount()
  140. const toggle = await openMenu()
  141. fireEvent.pointerDown(document.body)
  142. await nextTick()
  143. expect(queryByRole(host, 'menu')).toBeNull()
  144. await openMenu()
  145. fireEvent.focusOut(getByRole(host, 'menuitem', { name: 'Copy page' }), { relatedTarget: document.body })
  146. await nextTick()
  147. expect(queryByRole(host, 'menu')).toBeNull()
  148. await openMenu()
  149. fireEvent.click(toggle)
  150. await nextTick()
  151. expect(queryByRole(host, 'menu')).toBeNull()
  152. })
  153. it.each(['copy', 'view', 'toggle'])('keeps %s activation available when pointer clicks do not focus controls', async (action) => {
  154. await mount()
  155. const toggle = await openMenu()
  156. const target = action === 'toggle' ? toggle : getByRole(host, 'menuitem', {
  157. name: action === 'copy' ? 'Copy page' : 'View as Markdown (opens in a new tab)',
  158. })
  159. let activated = false
  160. target.addEventListener('click', (event) => {
  161. activated = true
  162. if (action === 'view') event.preventDefault()
  163. }, { once: true })
  164. fireEvent.pointerDown(target)
  165. // Safari can blur the focused menu item without focusing the pressed control.
  166. if (fireEvent.mouseDown(target)) (document.activeElement as HTMLElement).blur()
  167. await nextTick()
  168. expect(target.isConnected).toBe(true)
  169. expect(queryByRole(host, 'menu')).not.toBeNull()
  170. fireEvent.mouseUp(target)
  171. fireEvent.click(target)
  172. await nextTick()
  173. expect(activated).toBe(true)
  174. expect(queryByRole(host, 'menu')).toBeNull()
  175. expect(document.activeElement).toBe(toggle)
  176. if (action === 'copy') {
  177. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
  178. expect(write).toHaveBeenCalledOnce()
  179. } else {
  180. expect(write).not.toHaveBeenCalled()
  181. }
  182. })
  183. it('copies from the menu in the same click and prevents another copy while it is pending', async () => {
  184. const pending = deferred<Response>()
  185. fetchMock.mockReturnValueOnce(pending.promise)
  186. await mount()
  187. const toggle = await openMenu()
  188. fireEvent.click(getByRole(host, 'menuitem', { name: 'Copy page' }))
  189. expect(write).toHaveBeenCalledOnce()
  190. await nextTick()
  191. expect(queryByRole(host, 'menu')).toBeNull()
  192. expect(document.activeElement).toBe(toggle)
  193. await openMenu()
  194. const copy = getByRole(host, 'menuitem', { name: 'Copy page' })
  195. expect(copy.getAttribute('aria-disabled')).toBe('true')
  196. fireEvent.click(copy)
  197. expect(write).toHaveBeenCalledOnce()
  198. expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' }).getAttribute('href')).toBe('/en/guide/quickstart.md')
  199. pending.resolve(response())
  200. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
  201. })
  202. it('opens the raw link from the menu and returns focus to the toggle', async () => {
  203. await mount()
  204. const toggle = await openMenu()
  205. const view = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
  206. view.addEventListener('click', (event) => { event.preventDefault() }, { once: true })
  207. fireEvent.keyDown(view, { key: ' ' })
  208. await nextTick()
  209. expect(queryByRole(host, 'menu')).toBeNull()
  210. expect(document.activeElement).toBe(toggle)
  211. expect(fetchMock).not.toHaveBeenCalled()
  212. })
  213. it('removes outside-pointer listeners and closes its menu when the route changes', async () => {
  214. await mount()
  215. const add = vi.spyOn(document, 'addEventListener')
  216. const remove = vi.spyOn(document, 'removeEventListener')
  217. try {
  218. await openMenu()
  219. const listener = add.mock.calls.find(([name]) => name === 'pointerdown')?.[1]
  220. expect(listener).toBeTypeOf('function')
  221. route.path = '/en/reference/'
  222. data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
  223. await nextTick()
  224. expect(queryByRole(host, 'menu')).toBeNull()
  225. expect(remove).toHaveBeenCalledWith('pointerdown', listener, true)
  226. await openMenu()
  227. app?.unmount()
  228. app = undefined
  229. expect(remove.mock.calls.filter(([name]) => name === 'pointerdown')).toHaveLength(2)
  230. fireEvent.pointerDown(document.body)
  231. expect(host.textContent).toBe('')
  232. } finally {
  233. add.mockRestore()
  234. remove.mockRestore()
  235. }
  236. })
  237. it.each(['/', '/deepseek-harness/'])('uses the manifest index route under base %s, independent of the visible URL', async (base) => {
  238. data.site.value = { base }
  239. data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
  240. route.path = `${base}en/reference/index.html?from=nav#api`
  241. await mount()
  242. await openMenu()
  243. const link = getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })
  244. expect(link.getAttribute('href')).toBe(`${base}en/reference/index.md`)
  245. expect(link.getAttribute('target')).toBe('_blank')
  246. expect(fetchMock).not.toHaveBeenCalled()
  247. })
  248. it.each(['home', '404'])('omits actions on %s', async (kind) => {
  249. if (kind === 'home') data.frontmatter.value = { layout: false }
  250. else data.page.value = { isNotFound: true }
  251. await mount()
  252. expect(queryByRole(host, 'button')).toBeNull()
  253. expect(queryByRole(host, 'link')).toBeNull()
  254. })
  255. it('starts clipboard.write during the click and copies the fetched plain text before reporting success', async () => {
  256. const pending = deferred<Response>()
  257. fetchMock.mockReturnValue(pending.promise)
  258. await mount()
  259. const button = getByRole(host, 'button', { name: 'Copy page' })
  260. button.focus()
  261. fireEvent.click(button)
  262. expect(write).toHaveBeenCalledOnce()
  263. expect(copied).toBeUndefined()
  264. fireEvent.click(button)
  265. expect(fetchMock).toHaveBeenCalledOnce()
  266. expect(fetchMock.mock.calls[0]?.[0]).toBe('/en/guide/quickstart.md?dsh-raw=1')
  267. expect(fetchMock.mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal)
  268. await nextTick()
  269. expect(button).toHaveProperty('disabled', false)
  270. expect(button.getAttribute('aria-disabled')).toBe('true')
  271. expect(button.getAttribute('aria-busy')).toBe('true')
  272. expect(document.activeElement).toBe(button)
  273. expect(getByRole(host, 'status').textContent).toBe('Copying…')
  274. pending.resolve(response())
  275. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
  276. expect(copied?.type).toBe('text/plain')
  277. const reader = new FileReader()
  278. const text = new Promise((resolve) => { reader.addEventListener('load', () => { resolve(reader.result) }, { once: true }) })
  279. assert(copied !== undefined)
  280. reader.readAsText(copied)
  281. expect(await text).toBe('# Quickstart\n\n[Reference](../reference/index.md)\n')
  282. expect(button.getAttribute('aria-disabled')).toBe('false')
  283. expect(button.getAttribute('aria-busy')).toBe('false')
  284. expect(document.activeElement).toBe(button)
  285. })
  286. it.each(['network', '404', 'html'])('reports a %s response failure without success and allows retry', async (failure) => {
  287. if (failure === 'network') fetchMock.mockRejectedValueOnce(new Error('offline'))
  288. else fetchMock.mockResolvedValueOnce(response('not Markdown', failure === '404' ? 404 : 200, failure === 'html' ? 'text/html' : 'text/markdown'))
  289. await mount()
  290. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  291. await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
  292. expect(copied).toBeUndefined()
  293. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  294. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
  295. })
  296. it('accepts static hosting that serves Markdown as plain text', async () => {
  297. fetchMock.mockResolvedValueOnce(response('# Plain text hosting\n', 200, 'text/plain'))
  298. await mount()
  299. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  300. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe('Markdown copied.') })
  301. })
  302. it.each(['application/javascript', 'application/octet-stream', undefined])('rejects a %s content type without copying it', async (type) => {
  303. fetchMock.mockResolvedValueOnce(new Response(new TextEncoder().encode('# Body\n'), {
  304. headers: type === undefined ? {} : { 'content-type': type },
  305. }))
  306. await mount()
  307. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  308. await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
  309. expect(copied).toBeUndefined()
  310. })
  311. it('reports failure when reading the response body rejects', async () => {
  312. const raw = response()
  313. vi.spyOn(raw, 'text').mockRejectedValueOnce(new Error('Connection closed'))
  314. fetchMock.mockResolvedValueOnce(raw)
  315. await mount()
  316. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  317. await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not load Markdown.') })
  318. expect(copied).toBeUndefined()
  319. })
  320. it('keeps new-page feedback independent of a previous page clipboard write', async () => {
  321. const writeDone = deferred<void>()
  322. write.mockImplementationOnce(() => writeDone.promise)
  323. await mount()
  324. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  325. route.path = '/en/reference/'
  326. data.frontmatter.value = { rawMarkdownPath: 'en/reference/index.md' }
  327. await nextTick()
  328. writeDone.resolve()
  329. await writeDone.promise
  330. await nextTick()
  331. expect(getByRole(host, 'status').textContent).toBe('')
  332. })
  333. it.each(['write', 'item'])('provides manual-copy feedback when the %s API is missing', async (missing) => {
  334. if (missing === 'item') vi.stubGlobal('ClipboardItem', undefined)
  335. else vi.stubGlobal('navigator', {})
  336. await mount()
  337. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  338. await nextTick()
  339. expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
  340. await openMenu()
  341. expect(getByRole(host, 'menuitem', { name: 'View as Markdown (opens in a new tab)' })).toBeTruthy()
  342. expect(fetchMock).not.toHaveBeenCalled()
  343. })
  344. it('aborts an unconsumed fetch when the clipboard rejects before consuming its data', async () => {
  345. const pending = deferred<Response>()
  346. fetchMock.mockReturnValue(pending.promise)
  347. write.mockRejectedValueOnce(new DOMException('Denied', 'NotAllowedError'))
  348. await mount()
  349. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  350. await waitFor(() => { expect(getByRole(host, 'status').textContent).toContain('Could not copy.') })
  351. expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
  352. pending.reject(new DOMException('Aborted', 'AbortError'))
  353. await pending.promise.catch((error: unknown) => { void error })
  354. await nextTick()
  355. expect(getByRole(host, 'status').textContent).toContain('Could not copy.')
  356. })
  357. it.each(['route', 'locale', 'unmount'])('cancels old data and ignores its completion after %s changes', async (change) => {
  358. const pending = deferred<Response>()
  359. fetchMock.mockReturnValueOnce(pending.promise)
  360. await mount()
  361. fireEvent.click(getByRole(host, 'button', { name: 'Copy page' }))
  362. const oldWrite = activeWrite
  363. assert(oldWrite !== undefined)
  364. const signal = fetchMock.mock.calls[0]?.[1]?.signal
  365. if (change === 'unmount') { app?.unmount(); app = undefined }
  366. else {
  367. route.path = change === 'route' ? '/en/reference/' : '/guide/quickstart'
  368. data.lang.value = change === 'route' ? 'en-US' : 'zh-CN'
  369. data.frontmatter.value = { rawMarkdownPath: change === 'route' ? 'en/reference/index.md' : 'guide/quickstart.md' }
  370. }
  371. await nextTick()
  372. expect(signal?.aborted).toBe(true)
  373. pending.resolve(response('# Old page\n'))
  374. await expect(oldWrite).rejects.toThrow()
  375. await nextTick()
  376. expect(copied).toBeUndefined()
  377. if (change === 'unmount') expect(host.textContent).toBe('')
  378. else {
  379. expect(getByRole(host, 'status').textContent).toBe('')
  380. fireEvent.click(getByRole(host, 'button', { name: change === 'route' ? 'Copy page' : '复制页面' }))
  381. await waitFor(() => { expect(getByRole(host, 'status').textContent).toBe(change === 'route' ? 'Markdown copied.' : '已复制 Markdown。') })
  382. expect(fetchMock.mock.calls[1]?.[0]).toBe(change === 'route' ? '/en/reference/index.md?dsh-raw=1' : '/guide/quickstart.md?dsh-raw=1')
  383. }
  384. })
  385. })