feat(ui-mode): show native tags in test tree (#30092)

This brings up the question that we would show the tag name twice if its
a tag in a title. This would be aligned to how HTML report is doing it.

Fixes https://github.com/microsoft/playwright/issues/29927

---------

Signed-off-by: Max Schmitt <max@schmitt.mx>
Co-authored-by: Dmitry Gozman <dgozman@gmail.com>
This commit is contained in:
Max Schmitt 2024-03-26 01:06:22 +01:00 committed by GitHub
parent 051afb9ce0
commit 599185dd07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 170 additions and 4 deletions

View file

@ -0,0 +1,92 @@
/**
* 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.
*/
.tag {
display: inline-block;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
background-color: #8c959f;
color: white;
margin: 0 10px;
flex: none;
font-weight: 600;
}
.light-mode .tag-color-0 {
background-color: #ddf4ff;
color: #0550ae;
border: 1px solid #218bff;
}
.light-mode .tag-color-1 {
background-color: #fff8c5;
color: #7d4e00;
border: 1px solid #bf8700;
}
.light-mode .tag-color-2 {
background-color: #fbefff;
color: #6e40c9;
border: 1px solid #a475f9;
}
.light-mode .tag-color-3 {
background-color: #ffeff7;
color: #99286e;
border: 1px solid #e85aad;
}
.light-mode .tag-color-4 {
background-color: #FFF0EB;
color: #9E2F1C;
border: 1px solid #EA6045;
}
.light-mode .tag-color-5 {
background-color: #fff1e5;
color: #9b4215;
border: 1px solid #e16f24;
}
.dark-mode .tag-color-0 {
background-color: #051d4d;
color: #80ccff;
border: 1px solid #218bff;
}
.dark-mode .tag-color-1 {
background-color: #3b2300;
color: #eac54f;
border: 1px solid #bf8700;
}
.dark-mode .tag-color-2 {
background-color: #271052;
color: #d2a8ff;
border: 1px solid #a475f9;
}
.dark-mode .tag-color-3 {
background-color: #42062a;
color: #ff9bce;
border: 1px solid #e85aad;
}
.dark-mode .tag-color-4 {
background-color: #460701;
color: #FFA28B;
border: 1px solid #EC6547;
}
.dark-mode .tag-color-5 {
background-color: #471700;
color: #ffa657;
border: 1px solid #e16f24;
}

View file

@ -0,0 +1,36 @@
/**
* 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.
*/
import './tag.css';
export const TagView: React.FC<{ tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }> = ({ tag, style, onClick }) => {
return <span
className={`tag tag-color-${tagNameToColor(tag)}`}
onClick={onClick}
style={{ margin: '6px 0 0 6px', ...style }}
title={`Click to filter by tag: ${tag}`}
>
{tag}
</span>;
};
// hash string to integer in range [0, 6] for color index, to get same color for same tag
function tagNameToColor(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

@ -30,6 +30,7 @@ import { testStatusIcon } from './testUtils';
import type { TestModel } from './uiModeModel';
import './uiModeTestListView.css';
import type { TestServerConnection } from '@testIsomorphic/testServerConnection';
import { TagView } from './tag';
const TestTreeView = TreeView<TreeItem>;
@ -46,7 +47,8 @@ export const TestListView: React.FC<{
isLoading?: boolean,
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number,
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => {
setFilterText: (text: string) => void;
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
@ -132,6 +134,21 @@ export const TestListView: React.FC<{
runTests('bounce-if-busy', testTree.collectTestIds(treeItem));
};
const handleTagClick = (e: React.MouseEvent, tag: string) => {
e.preventDefault();
e.stopPropagation();
if (e.metaKey || e.ctrlKey) {
const parts = filterText.split(' ');
if (parts.includes(tag))
setFilterText(parts.filter(t => t !== tag).join(' ').trim());
else
setFilterText((filterText + ' ' + tag).trim());
} else {
// Replace all existing tags with this tag.
setFilterText((filterText.split(' ').filter(t => !t.startsWith('@')).join(' ') + ' ' + tag).trim());
}
};
return <TestTreeView
name='tests'
treeState={treeState}
@ -140,7 +157,10 @@ export const TestListView: React.FC<{
dataTestId='test-tree'
render={treeItem => {
return <div className='hbox ui-mode-list-item'>
<div className='ui-mode-list-item-title' title={treeItem.title}>{treeItem.title}</div>
<div className='ui-mode-list-item-title'>
<span title={treeItem.title}>{treeItem.title}</span>
{treeItem.kind === 'case' ? treeItem.tags.map(tag => <TagView key={tag} tag={tag.slice(1)} onClick={e => handleTagClick(e, tag)} />) : null}
</div>
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>

View file

@ -440,7 +440,9 @@ export const UIModeView: React.FC<{}> = ({
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount} />
requestedCollapseAllCount={collapseAllCount}
setFilterText={setFilterText}
/>
</div>
</SplitView>
</div>;

View file

@ -75,7 +75,7 @@ export function dumpTestTree(page: Page, options: { time?: boolean } = {}): () =
const indent = listItem.querySelectorAll('.list-view-indent').length;
const watch = listItem.querySelector('.toolbar-button.eye.toggled') ? ' 👁' : '';
const selected = listItem.classList.contains('selected') ? ' <=' : '';
const title = listItem.querySelector('.ui-mode-list-item-title').textContent;
const title = listItem.querySelector('.ui-mode-list-item-title').childNodes[0].textContent;
const timeElement = options.time ? listItem.querySelector('.ui-mode-list-item-time') : undefined;
const time = timeElement ? ' ' + timeElement.textContent.replace(/[.\d]+m?s/, 'XXms') : '';
result.push(' ' + ' '.repeat(indent) + treeIcon + ' ' + statusIcon + ' ' + title + time + watch + selected);

View file

@ -56,6 +56,22 @@ test('should filter by explicit tags', async ({ runUITest }) => {
`);
});
test('should display native tags and filter by them on click', async ({ runUITest }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('p', () => {});
test('pwt', { tag: '@smoke' }, () => {});
`,
});
await page.locator('.ui-mode-list-item-title').getByText('smoke').click();
await expect(page.getByPlaceholder('Filter')).toHaveValue('@smoke');
await expect.poll(dumpTestTree(page)).toBe(`
a.test.ts
pwt
`);
});
test('should filter by status', async ({ runUITest }) => {
const { page } = await runUITest(basicTestTree);