chore: linkify urls in attachments body (#31673)

Reference: https://github.com/microsoft/playwright/issues/31284
This commit is contained in:
Yury Semikhatsky 2024-07-15 12:20:22 -07:00 committed by GitHub
parent d463d1f285
commit de39d227f7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 111 additions and 67 deletions

View File

@ -1,23 +0,0 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// hash string to integer in range [0, 6] for color index, to get same color for same tag
export function hashStringToInt(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++)
hash = str.charCodeAt(i) + ((hash << 8) - hash);
return Math.abs(hash % 6);
}

View File

@ -20,6 +20,7 @@ import * as icons from './icons';
import { TreeItem } from './treeItem';
import { CopyToClipboard } from './copyToClipboard';
import './links.css';
import { linkifyText } from './renderUtils';
export function navigate(href: string) {
window.history.pushState({}, '', href);
@ -77,9 +78,9 @@ export const AttachmentLink: React.FunctionComponent<{
return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
{attachment.body && <span>{attachment.name}</span>}
{attachment.body && <span>{linkifyText(attachment.name)}</span>}
</span>} loadChildren={attachment.body ? () => {
return [<div className='attachment-body'><CopyToClipboard value={attachment.body!}/>{attachment.body}</div>];
return [<div className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
};

View File

@ -0,0 +1,47 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* 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');
const result = [];
let currentIndex = 0;
let match;
while ((match = WEB_LINK_REGEX.exec(description)) !== null) {
const stringBeforeMatch = description.substring(currentIndex, match.index);
if (stringBeforeMatch)
result.push(stringBeforeMatch);
const value = match[0];
result.push(renderLink(value));
currentIndex = match.index + value.length;
}
const stringAfterMatches = description.substring(currentIndex);
if (stringAfterMatches)
result.push(stringAfterMatches);
return result;
}
function renderLink(text: string) {
let link = text;
if (link.startsWith('www.'))
link = 'https://' + link;
return <a href={link} target='_blank' rel='noopener noreferrer'>{text}</a>;
}

View File

@ -77,7 +77,7 @@ test('should render test case', async ({ mount }) => {
await expect(component.getByText('My test')).toBeVisible();
});
const linkRenderingTestCase: TestCase = {
const annotationLinkRenderingTestCase: TestCase = {
testId: 'testid',
title: 'My test',
path: [],
@ -96,8 +96,7 @@ const linkRenderingTestCase: TestCase = {
};
test('should correctly render links in annotations', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={linkRenderingTestCase} run={0} anchor=''></TestCaseView>);
// const container = await(component.getByText('Annotations'));
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} run={0} anchor=''></TestCaseView>);
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
await expect(firstLink).toBeVisible();
@ -114,4 +113,48 @@ test('should correctly render links in annotations', async ({ mount }) => {
const fourthLink = await component.getByText('https://github.com/microsoft/playwright/issues/23181').first();
await expect(fourthLink).toBeVisible();
await expect(fourthLink).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/23181');
});
const resultWithAttachment: TestResult = {
retry: 0,
startTime: new Date(0).toUTCString(),
duration: 100,
errors: [],
steps: [{
title: 'Outer step',
startTime: new Date(100).toUTCString(),
duration: 10,
location: { file: 'test.spec.ts', line: 62, column: 0 },
count: 1,
steps: [],
}],
attachments: [{
name: 'first attachment',
body: 'The body with https://playwright.dev/docs/intro link and https://github.com/microsoft/playwright/issues/31284.',
contentType: 'text/plain'
}],
status: 'passed',
};
const attachmentLinkRenderingTestCase: TestCase = {
testId: 'testid',
title: 'My test',
path: [],
projectName: 'chromium',
location: { file: 'test.spec.ts', line: 42, column: 0 },
tags: [],
outcome: 'expected',
duration: 10,
ok: true,
annotations: [],
results: [resultWithAttachment]
};
test('should correctly render links in attachments', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor=''></TestCaseView>);
await component.getByText('first attachment').click();
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
await expect(body).toBeVisible();
await expect(body.locator('a').filter({ hasText: 'playwright.dev' })).toHaveAttribute('href', 'https://playwright.dev/docs/intro');
await expect(body.locator('a').filter({ hasText: 'github.com' })).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
});

View File

@ -23,8 +23,8 @@ import { ProjectLink } from './links';
import { statusIcon } from './statusIcon';
import './testCaseView.css';
import { TestResultView } from './testResultView';
import { hashStringToInt } from './labelUtils';
import { msToString } from './uiUtils';
import { linkifyText } from './renderUtils';
import { hashStringToInt, msToString } from './utils';
export const TestCaseView: React.FC<{
projectNames: string[],
@ -68,43 +68,11 @@ export const TestCaseView: React.FC<{
</div>;
};
function renderAnnotationDescription(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');
const result = [];
let currentIndex = 0;
let match;
while ((match = WEB_LINK_REGEX.exec(description)) !== null) {
const stringBeforeMatch = description.substring(currentIndex, match.index);
if (stringBeforeMatch)
result.push(stringBeforeMatch);
const value = match[0];
result.push(renderLink(value));
currentIndex = match.index + value.length;
}
const stringAfterMatches = description.substring(currentIndex);
if (stringAfterMatches)
result.push(stringAfterMatches);
return result;
}
function renderLink(text: string) {
let link = text;
if (link.startsWith('www.'))
link = 'https://' + link;
return <a href={link} target='_blank' rel='noopener noreferrer'>{text}</a>;
}
function TestCaseAnnotationView({ annotation: { type, description } }: { annotation: TestCaseAnnotation }) {
return (
<div className='test-case-annotation'>
<span style={{ fontWeight: 'bold' }}>{type}</span>
{description && <span>: {renderAnnotationDescription(description)}</span>}
{description && <span>: {linkifyText(description)}</span>}
</div>
);
}

View File

@ -16,14 +16,13 @@
import type { HTMLReport, TestCaseSummary, TestFileSummary } from './types';
import * as React from 'react';
import { msToString } from './uiUtils';
import { hashStringToInt, msToString } from './utils';
import { Chip } from './chip';
import { filterWithToken, type Filter } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
import { statusIcon } from './statusIcon';
import './testFileView.css';
import { video, image, trace } from './icons';
import { hashStringToInt } from './labelUtils';
export const TestFileView: React.FC<React.PropsWithChildren<{
report: HTMLReport;

View File

@ -19,7 +19,7 @@ import * as React from 'react';
import type { Filter } from './filter';
import { TestFileView } from './testFileView';
import './testFileView.css';
import { msToString } from './uiUtils';
import { msToString } from './utils';
import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';

View File

@ -17,7 +17,7 @@
import type { TestAttachment, TestCase, TestResult, TestStep } from './types';
import * as React from 'react';
import { TreeItem } from './treeItem';
import { msToString } from './uiUtils';
import { msToString } from './utils';
import { AutoChip } from './chip';
import { traceImage } from './images';
import { AttachmentLink, generateTraceUrl } from './links';

View File

@ -39,3 +39,12 @@ export function msToString(ms: number): string {
const days = hours / 24;
return days.toFixed(1) + 'd';
}
// hash string to integer in range [0, 6] for color index, to get same color for same tag
export function hashStringToInt(str: string) {
let hash = 0;
for (let i = 0; i < str.length; i++)
hash = str.charCodeAt(i) + ((hash << 8) - hash);
return Math.abs(hash % 6);
}