Skip to content

Commit b8be2f0

Browse files
authored
fix(docs): preserve clipboard activation and validate copied content (#9295)
* fix(docs): cover clipboard activation and exported content * fix(docs): preserve clipboard activation and reject blank exports * fix(docs): isolate clipboard permissions in browser checks
1 parent cb5027a commit b8be2f0

2 files changed

Lines changed: 134 additions & 35 deletions

File tree

‎apps/docs/components/page-actions.tsx‎

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useRef, useState } from 'react'
44
import { Chip, useCopyToClipboard } from '@sim/emcn'
55
import { Check, Duplicate } from '@sim/emcn/icons'
66

@@ -10,31 +10,38 @@ interface LLMCopyButtonProps {
1010

1111
export function LLMCopyButton({ markdownUrl }: LLMCopyButtonProps) {
1212
const { copied, copy } = useCopyToClipboard()
13-
const [pending, setPending] = useState(false)
14-
const [failed, setFailed] = useState(false)
15-
const checked = copied && !pending && !failed
13+
const [status, setStatus] = useState<'idle' | 'pending' | 'ready' | 'failed'>('idle')
14+
const preparedMarkdown = useRef<string | null>(null)
15+
const pending = status === 'pending'
16+
const checked = copied && status === 'idle'
1617

1718
async function onClick() {
18-
setPending(true)
19-
setFailed(false)
19+
setStatus('pending')
2020
const prepare = async () => {
2121
const response = await fetch(markdownUrl, {
2222
signal: typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(10000) : undefined,
2323
})
2424
if (!response.ok) throw new Error('Could not load page Markdown')
25-
return response.text()
25+
const markdown = await response.text()
26+
if (!markdown.trim()) throw new Error('Page Markdown is empty')
27+
return markdown
2628
}
2729
try {
2830
// Promise-backed writes preserve Safari's transient user activation during the fetch.
29-
const success =
30-
typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function'
31-
? await copy({ prepare, fallback: '' })
32-
: await copy(await prepare())
33-
setFailed(!success)
31+
if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function') {
32+
const success = await copy({ prepare, fallback: '' })
33+
setStatus(success ? 'idle' : 'failed')
34+
return
35+
}
36+
if (preparedMarkdown.current === null) {
37+
preparedMarkdown.current = await prepare()
38+
setStatus('ready')
39+
return
40+
}
41+
const success = await copy(preparedMarkdown.current)
42+
setStatus(success ? 'idle' : 'failed')
3443
} catch {
35-
setFailed(true)
36-
} finally {
37-
setPending(false)
44+
setStatus('failed')
3845
}
3946
}
4047

@@ -50,7 +57,11 @@ export function LLMCopyButton({ markdownUrl }: LLMCopyButtonProps) {
5057
{checked ? 'Copied' : pending ? 'Copying…' : 'Copy page'}
5158
</Chip>
5259
<span role='status' className='absolute top-full right-0 whitespace-nowrap text-caption'>
53-
{failed ? 'Could not copy. Try again.' : ''}
60+
{status === 'failed'
61+
? 'Could not copy. Try again.'
62+
: status === 'ready'
63+
? 'Click again to copy.'
64+
: ''}
5465
</span>
5566
</div>
5667
)

‎apps/docs/scripts/test-navigation-e2e.ts‎

Lines changed: 107 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) {
1616
}
1717

1818
const pages = [
19-
'/introduction',
20-
'/getting-started',
21-
'/files',
22-
'/integrations/apollo',
23-
'/api-reference/workflows/listWorkflows',
19+
{ path: '/introduction', title: 'Introduction', content: '## Models and integrations' },
20+
{ path: '/getting-started', title: 'Getting started', content: '## Start in Chat' },
21+
{ path: '/files', title: 'Files', content: '## Upload or create a file' },
22+
{ path: '/integrations/apollo', title: 'Apollo', content: '### Apollo People Search' },
23+
{
24+
path: '/api-reference/workflows/listWorkflows',
25+
title: 'List workflows',
26+
content: '`GET /api/v2/workflows`',
27+
},
2428
] as const
2529
const viewports = [
2630
{ name: 'desktop', width: 1440, height: 1000 },
@@ -33,7 +37,7 @@ let browser: Browser | undefined
3337
try {
3438
browser = await chromium.launch()
3539
for (const viewport of viewports) {
36-
for (const path of pages) {
40+
for (const { path, title, content } of pages) {
3741
const started = performance.now()
3842
const context = await browser.newContext({ viewport, reducedMotion: 'reduce' })
3943
const page = await context.newPage()
@@ -85,6 +89,9 @@ try {
8589
const markdown = await page.request.get(markdownUrl)
8690
expect(markdown.ok()).toBe(true)
8791
const expected = await markdown.text()
92+
expect(expected).toContain(`# ${title} (${path})`)
93+
expect(expected).toContain(content)
94+
if (path.startsWith('/api-reference/')) expect(expected).toContain('## Responses')
8895
const copy = page.getByRole('button', { name: 'Copy page content', exact: true })
8996
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
9097
expect(markdownRequests).toEqual([])
@@ -94,18 +101,21 @@ try {
94101
await expect
95102
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
96103
.toBe(expected)
97-
await page.route(markdownUrl, (route) =>
98-
route.fulfill({ status: 503, body: 'Unavailable' })
99-
)
100-
await page.getByRole('button', { name: 'Copied to clipboard' }).click()
101-
await expect(page.getByRole('status')).toHaveText('Could not copy. Try again.')
102-
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toHaveCount(0)
103-
await expect
104-
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
105-
.toBe(expected)
106-
await page.unroute(markdownUrl)
107-
await copy.click()
108-
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
104+
for (const response of [
105+
{ status: 503, body: 'Unavailable' },
106+
{ status: 200, body: ' \n ' },
107+
]) {
108+
await page.route(markdownUrl, (route) => route.fulfill(response))
109+
await page.getByRole('button', { name: 'Copied to clipboard' }).click()
110+
await expect(page.getByRole('status')).toHaveText('Could not copy. Try again.')
111+
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toHaveCount(0)
112+
await expect
113+
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
114+
.toBe(expected)
115+
await page.unroute(markdownUrl)
116+
await copy.click()
117+
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
118+
}
109119
}
110120
await page.screenshot({
111121
path: join(dirname(reportPath), `${viewport.name}${path.replaceAll('/', '-')}.png`),
@@ -146,11 +156,14 @@ try {
146156
new URL('/llms.mdx/platform/costs', origin).toString()
147157
)
148158
expect(markdown.ok()).toBe(true)
159+
const expected = await markdown.text()
160+
expect(expected).toContain('# Credits, billing and limits (/platform/costs)')
161+
expect(expected).toContain('## What a workflow run costs')
149162
await page.getByRole('button', { name: 'Copy page content', exact: true }).click()
150163
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
151164
await expect
152165
.poll(() => page.evaluate(() => navigator.clipboard.readText()))
153-
.toBe(await markdown.text())
166+
.toBe(expected)
154167
}
155168
}
156169
results.push({
@@ -176,6 +189,81 @@ try {
176189
}
177190
}
178191
}
192+
const started = performance.now()
193+
const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] })
194+
try {
195+
await context.addInitScript(() => {
196+
Object.defineProperty(window, 'ClipboardItem', { value: undefined })
197+
const writeText = navigator.clipboard.writeText.bind(navigator.clipboard)
198+
navigator.clipboard.writeText = (text) => {
199+
document.documentElement.dataset.clipboardActivation = String(
200+
navigator.userActivation.isActive
201+
)
202+
return writeText(text)
203+
}
204+
})
205+
const page = await context.newPage()
206+
const markdownUrl = new URL('/llms.mdx/introduction', origin).toString()
207+
const requests: string[] = []
208+
await page.route('**/llms.mdx/**', async (route) => {
209+
requests.push(route.request().url())
210+
await page.waitForFunction(() => !navigator.userActivation.isActive)
211+
await route.continue()
212+
})
213+
await page.goto(new URL('/introduction', origin).toString(), { waitUntil: 'networkidle' })
214+
const copy = page.getByRole('button', { name: /^(Copy page content|Copied to clipboard)$/ })
215+
const loaded = page.waitForResponse(markdownUrl)
216+
await copy.click()
217+
await (await loaded).finished()
218+
await expect(copy).toBeEnabled()
219+
await expect(page.locator('html')).not.toHaveAttribute('data-clipboard-activation')
220+
await page.screenshot({ path: join(dirname(reportPath), 'legacy-clipboard-ready.png') })
221+
await copy.click()
222+
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
223+
await expect(page.locator('html')).toHaveAttribute('data-clipboard-activation', 'true')
224+
expect(requests).toEqual([markdownUrl])
225+
const copied = await page.evaluate(() => navigator.clipboard.readText())
226+
expect(copied).toContain('# Introduction (/introduction)')
227+
expect(copied).toContain('## Models and integrations')
228+
await page.screenshot({ path: join(dirname(reportPath), 'legacy-clipboard.png') })
229+
const navigation = page.getByRole('complementary', { name: 'Documentation navigation' })
230+
await navigation.getByRole('button', { name: 'Platform', exact: true }).click()
231+
await navigation.getByRole('link', { name: 'Credits, billing and limits', exact: true }).click()
232+
await expect(page).toHaveURL(new URL('/platform/costs', origin).toString())
233+
await expect(
234+
page.getByRole('heading', { level: 1, name: 'Credits, billing and limits', exact: true })
235+
).toBeVisible()
236+
const nextMarkdownUrl = new URL('/llms.mdx/platform/costs', origin).toString()
237+
const nextLoaded = page.waitForResponse(nextMarkdownUrl)
238+
await copy.click()
239+
await (await nextLoaded).finished()
240+
await expect(copy).toBeEnabled()
241+
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(copied)
242+
await copy.click()
243+
await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
244+
await expect(page.locator('html')).toHaveAttribute('data-clipboard-activation', 'true')
245+
expect(requests).toEqual([markdownUrl, nextMarkdownUrl])
246+
const nextCopied = await page.evaluate(() => navigator.clipboard.readText())
247+
expect(nextCopied).toContain('# Credits, billing and limits (/platform/costs)')
248+
expect(nextCopied).toContain('## What a workflow run costs')
249+
results.push({
250+
path: '/introduction',
251+
viewport: 'legacy-clipboard',
252+
status: 'passed',
253+
durationMs: Math.round(performance.now() - started),
254+
})
255+
} catch (error) {
256+
failed = true
257+
results.push({
258+
path: '/introduction',
259+
viewport: 'legacy-clipboard',
260+
status: 'failed',
261+
durationMs: Math.round(performance.now() - started),
262+
error: getErrorMessage(error),
263+
})
264+
} finally {
265+
await context.close()
266+
}
179267
} catch (error) {
180268
failed = true
181269
results.push({ path: 'browser', status: 'failed', error: getErrorMessage(error) })

0 commit comments

Comments
 (0)