feat(html): allow projects with spaces, lax filter matching (#9913)

This commit is contained in:
Pavel Feldman 2021-11-01 09:53:58 -08:00 committed by GitHub
parent d79aae633c
commit d234030b9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 111 additions and 43 deletions

View file

@ -21,7 +21,7 @@
rgb(0 0 0 / 15%) 0px 6.1px 6.3px,
rgb(0 0 0 / 10%) 0px -2px 4px,
rgb(0 0 0 / 15%) 0px -6.1px 12px,
rgb(0 0 0 / 25%) 0px 27px 28px;
rgb(0 0 0 / 25%) 0px 6px 12px;
}
html, body {
@ -51,6 +51,8 @@ body {
* {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
svg {
@ -153,17 +155,15 @@ svg {
box-shadow: inset 0 -1px 0 var(--color-border-muted) !important;
}
.columns > .tab-strip .tab-element.selected {
.test-case-column .tab-element.selected {
font-weight: 600;
border-bottom-color: var(--color-primer-border-active);
}
.test-case-column .tab-element {
border: none;
text-transform: uppercase;
font-weight: bold;
font-size: 11px;
color: var(--color-fg-default);
border-bottom: 2px solid transparent;
}
.test-case-column .tab-element:hover {
@ -514,16 +514,14 @@ article, aside, details, figcaption, figure, footer, header, main, menu, nav, se
background-color: var(--color-canvas-inset);
}
.width-full {
width: 100% !important;
}
.subnav-search {
position: relative;
flex: auto;
display: flex;
}
.subnav-search-input {
width: 320px;
flex: auto;
padding-left: 32px;
color: var(--color-fg-muted);
}
@ -543,9 +541,10 @@ article, aside, details, figcaption, figure, footer, header, main, menu, nav, se
}
.subnav-item {
flex: none;
position: relative;
float: left;
padding: 5px 16px;
padding: 5px 10px;
font-weight: 500;
line-height: 20px;
color: var(--color-fg-default);

View file

@ -94,10 +94,10 @@ const AllTestFilesSummaryView: React.FC<{
}, [report, filter]);
return <div className='file-summary-list'>
{report && <div className='d-flex'>
<form className='subnav-search width-full' onSubmit={
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText || ''}`);
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'>
@ -450,7 +450,9 @@ const ProjectLink: React.FunctionComponent<{
report: HTMLReport,
projectName: string,
}> = ({ report, projectName }) => {
return <Link href={`#?q=p:${projectName}`}>
const encoded = encodeURIComponent(projectName);
const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`;
return <Link href={`#?q=p:${value}`}>
<span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 6)}>
{projectName}
</span>
@ -484,53 +486,120 @@ const Route: React.FunctionComponent<{
};
class Filter {
project = new Set<string>();
outcome = new Set<string>();
project: string[] = [];
status: string[] = [];
text: string[] = [];
private static regex = /(".*?"|[\w]+:|[^"\s:]+)(?=\s*|\s*$)/g;
empty(): boolean {
return this.project.size + this.outcome.size + this.text.length === 0;
return this.project.length + this.status.length + this.text.length === 0;
}
static parse(expression: string): Filter {
const filter = new Filter();
const match = (expression.match(Filter.regex) || []).map(t => {
return t.startsWith('"') && t.endsWith('"') ? t.substring(1, t.length - 1) : t;
});
for (let i = 0; i < match.length; ++i) {
if (match[i] === 'p:' && match[i + 1]) {
filter.project.add(match[++i]);
const tokens = Filter.tokenize(expression);
const project = new Set<string>();
const status = new Set<string>();
const text: string[] = [];
for (const token of tokens) {
if (token.startsWith('p:')) {
project.add(token.slice(2));
continue;
}
if (match[i] === 's:' && match[i + 1]) {
const status = match[++i];
if (status === 'passed')
filter.outcome.add('expected');
else if (status === 'failed')
filter.outcome.add('unexpected');
else
filter.outcome.add(status);
if (token.startsWith('s:')) {
status.add(token.slice(2));
continue;
}
filter.text.push(match[i].toLowerCase());
text.push(token.toLowerCase());
}
const filter = new Filter();
filter.text = text;
filter.project = [...project];
filter.status = [...status];
return filter;
}
private static tokenize(expression: string): string[] {
const result: string[] = [];
let quote: '\'' | '"' | undefined;
let token: string[] = [];
for (let i = 0; i < expression.length; ++i) {
const c = expression[i];
if (quote && c === '\\' && expression[i + 1] === quote) {
token.push(quote);
++i;
continue;
}
if (c === '"' || c === '\'') {
if (quote === c) {
result.push(token.join('').toLowerCase());
token = [];
quote = undefined;
} else if (quote) {
token.push(c);
} else {
quote = c;
}
continue;
}
if (quote) {
token.push(c);
continue;
}
if (c === ' ') {
if (token.length) {
result.push(token.join('').toLowerCase());
token = [];
}
continue;
}
token.push(c);
}
if (token.length)
result.push(token.join('').toLowerCase());
return result;
}
matches(test: TestCaseSummary): boolean {
if (this.project.size && !this.project.has(test.projectName))
return false;
if (this.outcome.size && !this.outcome.has(test.outcome))
return false;
if (this.text.length) {
if (!(test as any).fullTitle)
(test as any).fullTitle = (test.path.join(' ') + test.title).toLowerCase();
const fullTitle = (test as any).fullTitle;
const matches = !!this.text.find(t => fullTitle.includes(t));
if (!(test as any).searchValues) {
let status = 'passed';
if (test.outcome === 'unexpected')
status = 'failed';
if (test.outcome === 'flaky')
status = 'flaky';
if (test.outcome === 'skipped')
status = 'skipped';
const searchValues: SearchValues = {
text: (status + ' ' + test.projectName + ' ' + test.path.join(' ') + test.title).toLowerCase(),
project: test.projectName.toLowerCase(),
status: status as any
};
(test as any).searchValues = searchValues;
}
const searchValues = (test as any).searchValues as SearchValues;
if (this.project.length) {
const matches = !!this.project.find(p => searchValues.project.includes(p));
if (!matches)
return false;
}
if (this.status.length) {
const matches = !!this.status.find(s => searchValues.status.includes(s));
if (!matches)
return false;
}
if (this.text.length) {
const matches = this.text.filter(t => searchValues.text.includes(t)).length === this.text.length;
if (!matches)
return false;
}
return true;
}
}
type SearchValues = {
text: string;
project: string;
status: 'passed' | 'failed' | 'flaky' | 'skipped';
};