Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 1 commit into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/trace-viewer/src/ui/attachmentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand All @@ -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) {
Expand All @@ -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 <>
Expand All @@ -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>}
Expand Down
26 changes: 7 additions & 19 deletions packages/trace-viewer/src/ui/networkResourceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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);
Expand All @@ -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>;
};

Expand All @@ -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 () => {
Expand All @@ -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 });
}
}
};
Expand All @@ -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>;
};

Expand Down Expand Up @@ -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';
}
2 changes: 2 additions & 0 deletions packages/web/src/components/codeMirrorModule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions packages/web/src/components/codeMirrorWrapper.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
91 changes: 74 additions & 17 deletions packages/web/src/components/codeMirrorWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,21 @@ 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;
type: 'running' | 'paused' | 'error';
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[];
Expand All @@ -45,6 +47,8 @@ export interface SourceProps {
export const CodeMirrorWrapper: React.FC<SourceProps> = ({
text,
language,
mimeType,
linkify,
readOnly,
highlight,
revealLine,
Expand All @@ -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')
Expand All @@ -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)
Expand Down Expand Up @@ -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];
}
7 changes: 3 additions & 4 deletions packages/web/src/renderUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion packages/web/src/uiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
49 changes: 49 additions & 0 deletions tests/playwright-test/ui-mode-test-attachments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
Loading