diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 13bdbcefcbf7b..375f28cb6550b 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -23,6 +23,7 @@ import type { AfterActionTraceEventAttachment } from '@trace/trace'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; import { isTextualMimeType } from '@isomorphic/mimeType'; import { Expandable } from '@web/components/expandable'; +import { linkifyText } from '@web/renderUtils'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; @@ -36,6 +37,7 @@ const ExpandableAttachment: React.FunctionComponent = const [placeholder, setPlaceholder] = React.useState(null); const isTextAttachment = isTextualMimeType(attachment.contentType); + const hasContent = !!attachment.sha1 || !!attachment.path; React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { @@ -50,10 +52,10 @@ const ExpandableAttachment: React.FunctionComponent = }, [expanded, attachmentText, placeholder, attachment]); const title = - {attachment.name} download + {linkifyText(attachment.name)} {hasContent && download} ; - if (!isTextAttachment) + if (!isTextAttachment || !hasContent) return
{title}
; return <> @@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent = {expanded && attachmentText !== null && } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 79594fb8c4b20..388e84c51f665 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -19,7 +19,6 @@ import * as React from 'react'; import './networkResourceDetails.css'; import { TabbedPane } from '@web/components/tabbedPane'; import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper'; -import type { Language } from '@web/components/codeMirrorWrapper'; import { ToolbarButton } from '@web/components/toolbarButton'; export const NetworkResourceDetails: React.FunctionComponent<{ @@ -55,19 +54,18 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const RequestTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null); + const [requestBody, setRequestBody] = React.useState<{ text: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { if (resource.request.postData) { const requestContentTypeHeader = resource.request.headers.find(q => q.name === 'Content-Type'); const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; - const language = mimeTypeToHighlighter(requestContentType); if (resource.request.postData._sha1) { const response = await fetch(`sha1/${resource.request.postData._sha1}`); - setRequestBody({ text: formatBody(await response.text(), requestContentType), language }); + setRequestBody({ text: formatBody(await response.text(), requestContentType), mimeType: requestContentType }); } else { - setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language }); + setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType }); } } else { setRequestBody(null); @@ -87,7 +85,7 @@ const RequestTab: React.FunctionComponent<{
Request Headers
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
{requestBody &&
Request Body
} - {requestBody && } + {requestBody && } ; }; @@ -103,7 +101,7 @@ const ResponseTab: React.FunctionComponent<{ const BodyTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null); + const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string } | null>(null); React.useEffect(() => { const readResources = async () => { @@ -118,8 +116,7 @@ const BodyTab: React.FunctionComponent<{ setResponseBody({ dataUrl: (await eventPromise).target.result }); } else { const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); - const language = mimeTypeToHighlighter(resource.response.content.mimeType); - setResponseBody({ text: formattedBody, language }); + setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType }); } } }; @@ -130,7 +127,7 @@ const BodyTab: React.FunctionComponent<{ return
{!resource.response.content._sha1 &&
Response body is not available for this request.
} {responseBody && responseBody.dataUrl && } - {responseBody && responseBody.text && } + {responseBody && responseBody.text && }
; }; @@ -163,12 +160,3 @@ function formatBody(body: string | null, contentType: string): string { return bodyStr; } - -function mimeTypeToHighlighter(mimeType: string): Language | undefined { - if (mimeType.includes('javascript') || mimeType.includes('json')) - return 'javascript'; - if (mimeType.includes('html')) - return 'html'; - if (mimeType.includes('css')) - return 'css'; -} diff --git a/packages/web/src/components/codeMirrorModule.tsx b/packages/web/src/components/codeMirrorModule.tsx index 56686c45b65cb..4376e0a18fb17 100644 --- a/packages/web/src/components/codeMirrorModule.tsx +++ b/packages/web/src/components/codeMirrorModule.tsx @@ -23,6 +23,8 @@ import 'codemirror-shadow-1/mode/htmlmixed/htmlmixed'; import 'codemirror-shadow-1/mode/javascript/javascript'; import 'codemirror-shadow-1/mode/python/python'; import 'codemirror-shadow-1/mode/clike/clike'; +import 'codemirror-shadow-1/mode/markdown/markdown'; +import 'codemirror-shadow-1/addon/mode/simple'; export type CodeMirror = typeof codemirrorType; export default codemirror; diff --git a/packages/web/src/components/codeMirrorWrapper.css b/packages/web/src/components/codeMirrorWrapper.css index 1e79c182743d3..6988ab6506061 100644 --- a/packages/web/src/components/codeMirrorWrapper.css +++ b/packages/web/src/components/codeMirrorWrapper.css @@ -174,3 +174,9 @@ body.dark-mode .CodeMirror span.cm-type { margin: 3px 10px; padding: 5px; } + +.CodeMirror span.cm-link, span.cm-linkified { + color: var(--vscode-textLink-foreground); + text-decoration: underline; + cursor: pointer; +} diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx index 738f0cfff1b4a..f8180f2e25bec 100644 --- a/packages/web/src/components/codeMirrorWrapper.tsx +++ b/packages/web/src/components/codeMirrorWrapper.tsx @@ -18,7 +18,7 @@ import './codeMirrorWrapper.css'; import * as React from 'react'; import type { CodeMirror } from './codeMirrorModule'; import { ansi2html } from '../ansi2html'; -import { useMeasure } from '../uiUtils'; +import { useMeasure, kWebLinkRe } from '../uiUtils'; export type SourceHighlight = { line: number; @@ -26,11 +26,13 @@ export type SourceHighlight = { message?: string; }; -export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css'; +export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl' | 'html' | 'css' | 'markdown'; export interface SourceProps { text: string; language?: Language; + mimeType?: string; + linkify?: boolean; readOnly?: boolean; // 1-based highlight?: SourceHighlight[]; @@ -45,6 +47,8 @@ export interface SourceProps { export const CodeMirrorWrapper: React.FC = ({ text, language, + mimeType, + linkify, readOnly, highlight, revealLine, @@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC = ({ (async () => { // Always load the module first. const CodeMirror = await modulePromise; + defineCustomMode(CodeMirror); const element = codemirrorElement.current; if (!element) return; - let mode = ''; - if (language === 'javascript') - mode = 'javascript'; - if (language === 'python') - mode = 'python'; - if (language === 'java') - mode = 'text/x-java'; - if (language === 'csharp') - mode = 'text/x-csharp'; - if (language === 'html') - mode = 'htmlmixed'; - if (language === 'css') - mode = 'css'; + const mode = languageToMode(language) || mimeTypeToMode(mimeType) || (linkify ? 'text/linkified' : ''); if (codemirrorRef.current && mode === codemirrorRef.current.cm.getOption('mode') @@ -106,7 +99,7 @@ export const CodeMirrorWrapper: React.FC = ({ setCodemirror(cm); return cm; })(); - }, [modulePromise, codemirror, codemirrorElement, language, lineNumbers, wrapLines, readOnly, isFocused]); + }, [modulePromise, codemirror, codemirrorElement, language, mimeType, linkify, lineNumbers, wrapLines, readOnly, isFocused]); React.useEffect(() => { if (codemirrorRef.current) @@ -175,5 +168,69 @@ export const CodeMirrorWrapper: React.FC = ({ }; }, [codemirror, text, highlight, revealLine, focusOnChange, onChange]); - return
; + return
; }; + +function onCodeMirrorClick(event: React.MouseEvent) { + if (!(event.target instanceof HTMLElement)) + return; + let url: string | undefined; + if (event.target.classList.contains('cm-linkified')) { + // 'text/linkified' custom mode + url = event.target.textContent!; + } else if (event.target.classList.contains('cm-link') && event.target.nextElementSibling?.classList.contains('cm-url')) { + // 'markdown' mode + url = event.target.nextElementSibling.textContent!.slice(1, -1); + } + if (url) { + event.preventDefault(); + event.stopPropagation(); + window.open(url, '_blank'); + } +} + +let customModeDefined = false; +function defineCustomMode(cm: CodeMirror) { + if (customModeDefined) + return; + customModeDefined = true; + (cm as any).defineSimpleMode('text/linkified', { + start: [ + { regex: kWebLinkRe, token: 'linkified' }, + ], + }); +} + +function mimeTypeToMode(mimeType: string | undefined): string | undefined { + if (!mimeType) + return; + if (mimeType.includes('javascript') || mimeType.includes('json')) + return 'javascript'; + if (mimeType.includes('python')) + return 'python'; + if (mimeType.includes('csharp')) + return 'text/x-csharp'; + if (mimeType.includes('java')) + return 'text/x-java'; + if (mimeType.includes('markdown')) + return 'markdown'; + if (mimeType.includes('html') || mimeType.includes('svg')) + return 'htmlmixed'; + if (mimeType.includes('css')) + return 'css'; +} + +function languageToMode(language: Language | undefined): string | undefined { + if (!language) + return; + return { + javascript: 'javascript', + jsonl: 'javascript', + python: 'python', + csharp: 'text/x-csharp', + java: 'text/x-java', + markdown: 'markdown', + html: 'htmlmixed', + css: 'css', + }[language]; +} diff --git a/packages/web/src/renderUtils.tsx b/packages/web/src/renderUtils.tsx index e8d92dc609232..71deb5af533b9 100644 --- a/packages/web/src/renderUtils.tsx +++ b/packages/web/src/renderUtils.tsx @@ -14,15 +14,14 @@ * limitations under the License. */ -export function linkifyText(description: string) { - const CONTROL_CODES = '\\u0000-\\u0020\\u007f-\\u009f'; - const WEB_LINK_REGEX = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + CONTROL_CODES + '"]{2,}[^\\s' + CONTROL_CODES + '"\')}\\],:;.!?]', 'ug'); +import { kWebLinkRe } from './uiUtils'; +export function linkifyText(description: string) { const result = []; let currentIndex = 0; let match; - while ((match = WEB_LINK_REGEX.exec(description)) !== null) { + while ((match = kWebLinkRe.exec(description)) !== null) { const stringBeforeMatch = description.substring(currentIndex, match.index); if (stringBeforeMatch) result.push(stringBeforeMatch); diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 6abe445749147..b590d75b7b214 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -195,4 +195,7 @@ export const settings = new Settings(); // inspired by https://www.npmjs.com/package/clsx export function clsx(...classes: (string | undefined | false)[]) { return classes.filter(Boolean).join(' '); -} \ No newline at end of file +} + +const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; +export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 0b04cce012fca..7016a36115781 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -99,6 +99,55 @@ test('should contain string attachment', async ({ runUITest }) => { expect((await readAllFromStream(await download.createReadStream())).toString()).toEqual('text42'); }); +test('should linkify string attachments', async ({ runUITest, server }) => { + server.setRoute('/one.html', (req, res) => res.end()); + server.setRoute('/two.html', (req, res) => res.end()); + server.setRoute('/three.html', (req, res) => res.end()); + + const { page } = await runUITest({ + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('attach test', async () => { + await test.info().attach('Inline url: ${server.PREFIX + '/one.html'}'); + await test.info().attach('Second', { body: 'Inline link ${server.PREFIX + '/two.html'} to be highlighted.' }); + await test.info().attach('Third', { body: '[markdown link](${server.PREFIX + '/three.html'})', contentType: 'text/markdown' }); + }); + `, + }); + await page.getByText('attach test').click(); + await page.getByTitle('Run all').click(); + await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)'); + await page.getByText('Attachments').click(); + + const attachmentsPane = page.locator('.attachments-tab'); + + { + const url = server.PREFIX + '/one.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Second download').click(); + const url = server.PREFIX + '/two.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText(url).click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } + + { + await attachmentsPane.getByText('Third download').click(); + const url = server.PREFIX + '/three.html'; + const promise = page.waitForEvent('popup'); + await attachmentsPane.getByText('[markdown link]').click(); + const popup = await promise; + await expect(popup).toHaveURL(url); + } +}); + function readAllFromStream(stream: NodeJS.ReadableStream): Promise { return new Promise(resolve => { const chunks: Buffer[] = [];