feat(ui mode): linkify attachment names and content (#31960)

- Pass `contentType` to the CodeMirror.
- Support `text/markdown` mode.
- Custom mode for non-supported types that linkifies urls.
This commit is contained in:
Dmitry Gozman 2024-08-01 09:27:45 -07:00 committed by GitHub
parent 76cca7fc2c
commit a541751657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 151 additions and 43 deletions

View File

@ -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<ExpandableAttachmentProps> =
const [placeholder, setPlaceholder] = React.useState<string | null>(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<ExpandableAttachmentProps> =
}, [expanded, attachmentText, placeholder, attachment]);
const title = <span style={{ marginLeft: 5 }}>
{attachment.name} <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>
{linkifyText(attachment.name)} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>;
if (!isTextAttachment)
if (!isTextAttachment || !hasContent)
return <div style={{ marginLeft: 20 }}>{title}</div>;
return <>
@ -63,6 +65,8 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
{expanded && attachmentText !== null && <CodeMirrorWrapper
text={attachmentText}
readOnly
mimeType={attachment.contentType}
linkify={true}
lineNumbers={true}
wrapLines={false}>
</CodeMirrorWrapper>}

View File

@ -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<{
<div className='network-request-details-header'>Request Headers</div>
<div className='network-request-details-headers'>{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
{requestBody && <div className='network-request-details-header'>Request Body</div>}
{requestBody && <CodeMirrorWrapper text={requestBody.text} language={requestBody.language} readOnly lineNumbers={true}/>}
{requestBody && <CodeMirrorWrapper text={requestBody.text} mimeType={requestBody.mimeType} readOnly lineNumbers={true}/>}
</div>;
};
@ -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 <div className='network-request-details-tab'>
{!resource.response.content._sha1 && <div>Response body is not available for this request.</div>}
{responseBody && responseBody.dataUrl && <img draggable='false' src={responseBody.dataUrl} />}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} language={responseBody.language} readOnly lineNumbers={true}/>}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} mimeType={responseBody.mimeType} readOnly lineNumbers={true}/>}
</div>;
};
@ -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';
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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<SourceProps> = ({
text,
language,
mimeType,
linkify,
readOnly,
highlight,
revealLine,
@ -63,24 +67,13 @@ export const CodeMirrorWrapper: React.FC<SourceProps> = ({
(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<SourceProps> = ({
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<SourceProps> = ({
};
}, [codemirror, text, highlight, revealLine, focusOnChange, onChange]);
return <div className='cm-wrapper' ref={codemirrorElement}></div>;
return <div className='cm-wrapper' ref={codemirrorElement} onClick={onCodeMirrorClick}></div>;
};
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];
}

View File

@ -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);

View File

@ -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(' ');
}
}
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');

View File

@ -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<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];