mirror of
https://github.com/microsoft/playwright.git
synced 2024-11-30 23:45:33 +03:00
- Pass `contentType` to the CodeMirror. - Support `text/markdown` mode. - Custom mode for non-supported types that linkifies urls.
This commit is contained in:
parent
2cfe733e30
commit
deba37b6b5
@ -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>}
|
||||
|
@ -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';
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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];
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -183,3 +183,6 @@ export class Settings {
|
||||
}
|
||||
|
||||
export const settings = new Settings();
|
||||
|
||||
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');
|
||||
|
@ -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[] = [];
|
||||
|
Loading…
Reference in New Issue
Block a user