diff --git a/packages/playwright-core/src/server/har/harTracer.ts b/packages/playwright-core/src/server/har/harTracer.ts index 268da9e84d..cb423a144d 100644 --- a/packages/playwright-core/src/server/har/harTracer.ts +++ b/packages/playwright-core/src/server/har/harTracer.ts @@ -31,7 +31,7 @@ import { getPlaywrightVersion } from '../../utils/userAgent'; import { urlMatches } from '../../utils/network'; import { Frame } from '../frames'; import type { HeadersArray, LifecycleEvent } from '../types'; -import { isTextualMimeType } from '../../utils/mimeType'; +import { isTextualMimeType } from '../../utils/isomorphic/mimeType'; const FALLBACK_HTTP_VERSION = 'HTTP/1.1'; @@ -674,4 +674,4 @@ function safeDateToISOString(value: string | number) { } } -const startedDateSymbol = Symbol('startedDate'); \ No newline at end of file +const startedDateSymbol = Symbol('startedDate'); diff --git a/packages/playwright-core/src/utils/index.ts b/packages/playwright-core/src/utils/index.ts index a5219cda6c..67ec09d91d 100644 --- a/packages/playwright-core/src/utils/index.ts +++ b/packages/playwright-core/src/utils/index.ts @@ -26,7 +26,7 @@ export * from './headers'; export * from './hostPlatform'; export * from './httpServer'; export * from './manualPromise'; -export * from './mimeType'; +export * from './isomorphic/mimeType'; export * from './multimap'; export * from './network'; export * from './processLauncher'; diff --git a/packages/playwright-core/src/utils/mimeType.ts b/packages/playwright-core/src/utils/isomorphic/mimeType.ts similarity index 100% rename from packages/playwright-core/src/utils/mimeType.ts rename to packages/playwright-core/src/utils/isomorphic/mimeType.ts diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index 43291d5f1a..66a94121b5 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -45,3 +45,7 @@ max-width: 80%; box-shadow: 0 12px 28px 0 rgba(0,0,0,.2), 0 2px 4px 0 rgba(0,0,0,.1); } + +a.codicon-cloud-download:hover{ + background-color: var(--vscode-list-inactiveSelectionBackground) +} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 179e6e5c91..f1a5411b8c 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -20,9 +20,55 @@ import { ImageDiffView } from '@web/shared/imageDiffView'; import type { MultiTraceModel } from './modelUtil'; import { PlaceholderPanel } from './placeholderPanel'; import type { AfterActionTraceEventAttachment } from '@trace/trace'; +import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; +import { isTextualMimeType } from '@isomorphic/mimeType'; +import { Expandable } from '@web/components/expandable'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; +type ExpandableAttachmentProps = { + attachment: Attachment; +}; + +const ExpandableAttachment: React.FunctionComponent = ({ attachment }) => { + const [expanded, setExpanded] = React.useState(false); + const [loaded, setLoaded] = React.useState(false); + const [attachmentText, setAttachmentText] = React.useState(null); + const [emptyContentReason, setEmptyContentReason] = React.useState(''); + + React.useMemo(() => { + if (!isTextualMimeType(attachment.contentType)) { + setEmptyContentReason('no preview available'); + return; + } + if (expanded && !loaded) { + setEmptyContentReason('loading...'); + fetch(attachmentURL(attachment)).then(response => response.text()).then(text => { + setAttachmentText(text); + setLoaded(true); + }).catch(err => setEmptyContentReason('failed to load: ' + err.message)); + } + }, [attachment, expanded, loaded]); + + return + {attachment.name} + $event.stopPropagation()}> + + + } expanded={expanded} expandOnTitleClick={true} setExpanded={exp => setExpanded(exp)}> +
+ { attachmentText ? + : + {emptyContentReason} + } +
+
; +}; + export const AttachmentsTab: React.FunctionComponent<{ model: MultiTraceModel | undefined, }> = ({ model }) => { @@ -82,7 +128,7 @@ export const AttachmentsTab: React.FunctionComponent<{ {attachments.size ?
Attachments
: undefined} {[...attachments.values()].map((a, i) => { return
- {a.name} +
; })} ; diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 71b67e3707..fa685dd612 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -25,6 +25,7 @@ test('should contain text attachment', async ({ runUITest }) => { test('attach test', async () => { await test.info().attach('note', { path: __filename }); await test.info().attach('🎭', { body: 'hi tester!', contentType: 'text/plain' }); + await test.info().attach('escaped', { body: '## Header\\n\\n> TODO: some todo\\n- _Foo_\\n- **Bar**', contentType: 'text/plain' }); }); `, }); @@ -32,13 +33,19 @@ test('should contain text attachment', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); await page.getByText('Attachments').click(); - for (const { name, content } of [ - { name: 'note', content: 'attach test' }, - { name: '🎭', content: 'hi tester!' } + for (const { name, content, displayedAsText } of [ + { name: 'note', content: 'attach test', displayedAsText: false }, + { name: '🎭', content: 'hi tester!', displayedAsText: true }, + { name: 'escaped', content: '## Header\n\n> TODO: some todo\n- _Foo_\n- **Bar**', displayedAsText: true }, ]) { await page.getByText(`attach "${name}"`, { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: name }).click(); + await page.locator('.expandable-title', { hasText: name }).click(); + await expect(page.getByLabel(name)).toContainText(displayedAsText ? + content.split('\n')?.[0] : + 'no preview available' + ); + await page.locator('.expandable-title', { hasText: name }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe(name); expect((await readAllFromStream(await download.createReadStream())).toString()).toContain(content); @@ -60,7 +67,7 @@ test('should contain binary attachment', async ({ runUITest }) => { await page.getByText('Attachments').click(); await page.getByText('attach "data"', { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'data' }).click(); + await page.locator('.expandable-title', { hasText: 'data' }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('data'); expect(await readAllFromStream(await download.createReadStream())).toEqual(Buffer.from([1, 2, 3])); @@ -81,7 +88,7 @@ test('should contain string attachment', async ({ runUITest }) => { await page.getByText('Attachments').click(); await page.getByText('attach "note"', { exact: true }).click(); const downloadPromise = page.waitForEvent('download'); - await page.getByRole('link', { name: 'note' }).click(); + await page.locator('.expandable-title', { hasText: 'note' }).getByRole('link').click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe('note'); expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42');