go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/queries/tr_search_query.ts (about)

     1  // Copyright 2021 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { html } from 'lit';
    16  
    17  import { Suggestion } from '@/common/components/auto_complete';
    18  import { TestVariant } from '@/common/services/resultdb';
    19  import { parseProtoDurationStr } from '@/common/tools/time_utils';
    20  import { highlight } from '@/generic_libs/tools/lit_utils';
    21  
    22  import { KV_SYNTAX_EXPLANATION, parseKeyValue } from './utils';
    23  
    24  const SPECIAL_QUERY_RE = /^(-?)([a-zA-Z]+):(.+)$/;
    25  
    26  export type TestVariantFilter = (v: TestVariant) => boolean;
    27  
    28  export function parseTestResultSearchQuery(
    29    searchQuery: string,
    30  ): TestVariantFilter {
    31    const filters = searchQuery.split(' ').map((query) => {
    32      const match = query.match(SPECIAL_QUERY_RE);
    33  
    34      const [, neg, type, value] = match || ['', '', '', query];
    35      const valueUpper = value.toUpperCase();
    36      const negate = neg === '-';
    37      switch (type.toUpperCase()) {
    38        // Whether the test ID or test name contains the query as a substring
    39        // (case insensitive).
    40        case '': {
    41          return (v: TestVariant) => {
    42            const matched =
    43              v.testId.toUpperCase().includes(valueUpper) ||
    44              v.testMetadata?.name?.toUpperCase().includes(valueUpper);
    45            return negate !== Boolean(matched);
    46          };
    47        }
    48        // Whether the test variant has the specified status.
    49        case 'STATUS': {
    50          const statuses = valueUpper.split(',');
    51          return (v: TestVariant) => negate !== statuses.includes(v.status);
    52        }
    53        // Whether there's at least one a test result of the specified status.
    54        case 'RSTATUS': {
    55          const statuses = valueUpper.split(',');
    56          return (v: TestVariant) =>
    57            negate !==
    58            (v.results || []).some((r) => statuses.includes(r.result.status));
    59        }
    60        // Whether the test ID contains the query as a substring (case
    61        // insensitive).
    62        case 'ID': {
    63          return (v: TestVariant) =>
    64            negate !== v.testId.toUpperCase().includes(valueUpper);
    65        }
    66        // Whether the test ID matches the specified ID (case sensitive).
    67        case 'EXACTID': {
    68          return (v: TestVariant) => negate !== (v.testId === value);
    69        }
    70        // Whether the test variant has a matching variant key-value pair.
    71        case 'V': {
    72          const [vKey, vValue] = parseKeyValue(value);
    73  
    74          // Otherwise, the value must match the specified value (case sensitive).
    75          return vValue === null
    76            ? (v: TestVariant) =>
    77                negate !== (v.variant?.def?.[vKey] !== undefined)
    78            : (v: TestVariant) => negate !== (v.variant?.def?.[vKey] === vValue);
    79        }
    80        // Whether the test variant has the specified variant hash.
    81        case 'VHASH': {
    82          return (v: TestVariant) =>
    83            negate !== (v.variantHash.toUpperCase() === valueUpper);
    84        }
    85        // Whether the test name contains the query as a substring (case
    86        // insensitive).
    87        case 'NAME': {
    88          return (v: TestVariant) =>
    89            negate !==
    90            (v.testMetadata?.name || '').toUpperCase().includes(valueUpper);
    91        }
    92        // Whether the test name matches the specified name (case sensitive).
    93        case 'EXACTNAME': {
    94          return (v: TestVariant) => negate !== (v.testMetadata?.name === value);
    95        }
    96        // Whether the test has a run with a matching tag (case sensitive).
    97        case 'TAG': {
    98          const [tKey, tValue] = parseKeyValue(value);
    99  
   100          if (tValue) {
   101            return (v: TestVariant) =>
   102              negate ===
   103              !v.results?.some(
   104                (r) =>
   105                  r.result.tags?.some(
   106                    (t) => t.key === tKey && t.value === tValue,
   107                  ),
   108              );
   109          } else {
   110            return (v: TestVariant) =>
   111              negate ===
   112              !v.results?.some((r) => r.result.tags?.some((t) => t.key === tKey));
   113          }
   114        }
   115        // Whether the test has at least one run with a duration in the specified
   116        // range.
   117        case 'DURATION': {
   118          const match = value.match(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)?$/);
   119          if (!match) {
   120            throw new Error(`invalid duration range: ${value}`);
   121          }
   122          const [, minDurationStr, maxDurationStr] = match;
   123          const minDuration = Number(minDurationStr) * 1000;
   124          const maxDuration = maxDurationStr
   125            ? Number(maxDurationStr || '0') * 1000
   126            : Infinity;
   127          return (v: TestVariant) =>
   128            negate ===
   129            !v.results?.some((r) => {
   130              if (!r.result.duration) {
   131                return false;
   132              }
   133              const duration = parseProtoDurationStr(r.result.duration);
   134              const durationMs = duration.toMillis();
   135              return durationMs >= minDuration && durationMs <= maxDuration;
   136            });
   137        }
   138        default: {
   139          throw new Error(`invalid query type: ${type}`);
   140        }
   141      }
   142    });
   143    return (v) => filters.every((f) => f(v));
   144  }
   145  
   146  // Queries with predefined value.
   147  const QUERY_SUGGESTIONS = [
   148    {
   149      value: 'Status:UNEXPECTED',
   150      explanation: 'Include only tests with unexpected status',
   151    },
   152    {
   153      value: '-Status:UNEXPECTED',
   154      explanation: 'Exclude tests with unexpected status',
   155    },
   156    {
   157      value: 'Status:UNEXPECTEDLY_SKIPPED',
   158      explanation: 'Include only tests with unexpectedly skipped status',
   159    },
   160    {
   161      value: '-Status:UNEXPECTEDLY_SKIPPED',
   162      explanation: 'Exclude tests with unexpectedly skipped status',
   163    },
   164    {
   165      value: 'Status:FLAKY',
   166      explanation: 'Include only tests with flaky status',
   167    },
   168    { value: '-Status:FLAKY', explanation: 'Exclude tests with flaky status' },
   169    {
   170      value: 'Status:EXONERATED',
   171      explanation: 'Include only tests with exonerated status',
   172    },
   173    {
   174      value: '-Status:EXONERATED',
   175      explanation: 'Exclude tests with exonerated status',
   176    },
   177    {
   178      value: 'Status:EXPECTED',
   179      explanation: 'Include only tests with expected status',
   180    },
   181    {
   182      value: '-Status:EXPECTED',
   183      explanation: 'Exclude tests with expected status',
   184    },
   185  
   186    {
   187      value: 'RStatus:Pass',
   188      explanation: 'Include only tests with at least one passed run',
   189    },
   190    {
   191      value: '-RStatus:Pass',
   192      explanation: 'Exclude tests with at least one passed run',
   193    },
   194    {
   195      value: 'RStatus:Fail',
   196      explanation: 'Include only tests with at least one failed run',
   197    },
   198    {
   199      value: '-RStatus:Fail',
   200      explanation: 'Exclude tests with at least one failed run',
   201    },
   202    {
   203      value: 'RStatus:Crash',
   204      explanation: 'Include only tests with at least one crashed run',
   205    },
   206    {
   207      value: '-RStatus:Crash',
   208      explanation: 'Exclude tests with at least one crashed run',
   209    },
   210    {
   211      value: 'RStatus:Abort',
   212      explanation: 'Include only tests with at least one aborted run',
   213    },
   214    {
   215      value: '-RStatus:Abort',
   216      explanation: 'Exclude tests with at least one aborted run',
   217    },
   218    {
   219      value: 'RStatus:Skip',
   220      explanation: 'Include only tests with at least one skipped run',
   221    },
   222    {
   223      value: '-RStatus:Skip',
   224      explanation: 'Exclude tests with at least one skipped run',
   225    },
   226  ];
   227  
   228  // Queries with arbitrary value.
   229  const QUERY_TYPE_SUGGESTIONS = [
   230    {
   231      type: 'V:',
   232      explanation: `Include only tests with a matching variant key-value pair (${KV_SYNTAX_EXPLANATION})`,
   233    },
   234    {
   235      type: '-V:',
   236      explanation: `Exclude tests with a matching variant key-value pair (${KV_SYNTAX_EXPLANATION})`,
   237    },
   238  
   239    {
   240      type: 'Tag:',
   241      explanation: `Include only tests with a run that has a matching tag key-value pair (${KV_SYNTAX_EXPLANATION})`,
   242    },
   243    {
   244      type: '-Tag:',
   245      explanation: `Exclude tests with a run that has a matching tag key-value pair (${KV_SYNTAX_EXPLANATION})`,
   246    },
   247  
   248    {
   249      type: 'ID:',
   250      explanation:
   251        'Include only tests with the specified substring in their ID (case insensitive)',
   252    },
   253    {
   254      type: '-ID:',
   255      explanation:
   256        'Exclude tests with the specified substring in their ID (case insensitive)',
   257    },
   258  
   259    {
   260      type: 'Name:',
   261      explanation:
   262        'Include only tests with the specified substring in their Name (case insensitive)',
   263    },
   264    {
   265      type: '-Name:',
   266      explanation:
   267        'Exclude tests with the specified substring in their Name (case insensitive)',
   268    },
   269  
   270    {
   271      type: 'ExactID:',
   272      explanation: 'Include only tests with the specified ID (case sensitive)',
   273    },
   274    {
   275      type: '-ExactID:',
   276      explanation: 'Exclude tests with the specified ID (case sensitive)',
   277    },
   278  
   279    {
   280      type: 'Duration:',
   281      explanation:
   282        'Include only tests with a run that has a duration in the specified range',
   283    },
   284    {
   285      type: '-Duration:',
   286      explanation:
   287        'Exclude tests with a run that has a duration in the specified range',
   288    },
   289  
   290    {
   291      type: 'ExactName:',
   292      explanation: 'Include only tests with the specified name (case sensitive)',
   293    },
   294    {
   295      type: '-ExactName:',
   296      explanation: 'Exclude tests with the specified name (case sensitive)',
   297    },
   298  
   299    {
   300      type: 'VHash:',
   301      explanation: 'Include only tests with the specified variant hash',
   302    },
   303    {
   304      type: '-VHash:',
   305      explanation: 'Exclude tests with the specified variant hash',
   306    },
   307  ];
   308  
   309  export function suggestTestResultSearchQuery(
   310    query: string,
   311  ): readonly Suggestion[] {
   312    if (query === '') {
   313      // Return some example queries when the query is empty.
   314      return [
   315        {
   316          isHeader: true,
   317          display: html`<strong>Advanced Syntax</strong>`,
   318        },
   319        {
   320          value: '-Status:EXPECTED',
   321          explanation: "Use '-' prefix to negate the filter",
   322        },
   323        {
   324          value: 'Status:UNEXPECTED -RStatus:Skipped',
   325          explanation:
   326            'Use space to separate filters. Filters are logically joined with AND',
   327        },
   328  
   329        // Put this section behind `Advanced Syntax` so `Advanced Syntax` won't
   330        // be hidden after the size of supported filter types grows.
   331        {
   332          isHeader: true,
   333          display: html`<strong>Supported Filter Types</strong>`,
   334        },
   335        {
   336          value: 'test-id-substr',
   337          explanation:
   338            'Include only tests with the specified substring in their ID or name (case insensitive)',
   339        },
   340        {
   341          value: 'V:query-encoded-variant-key=query-encoded-variant-value',
   342          explanation:
   343            'Include only tests with a matching test variant key-value pair (case sensitive)',
   344        },
   345        {
   346          value: 'V:query-encoded-variant-key',
   347          explanation:
   348            'Include only tests with the specified variant key (case sensitive)',
   349        },
   350        {
   351          value: 'Tag:query-encoded-tag-key=query-encoded-tag-value',
   352          explanation:
   353            'Include only tests with a run that has a matching tag key-value pair (case sensitive)',
   354        },
   355        {
   356          value: 'Tag:query-encoded-tag-key',
   357          explanation:
   358            'Include only tests with a run that has the specified tag key (case sensitive)',
   359        },
   360        {
   361          value: 'ID:test-id-substr',
   362          explanation:
   363            'Include only tests with the specified substring in their ID (case insensitive)',
   364        },
   365        {
   366          value:
   367            'Status:UNEXPECTED,UNEXPECTEDLY_SKIPPED,FLAKY,EXONERATED,EXPECTED',
   368          explanation: 'Include only tests with the specified status',
   369        },
   370        {
   371          value: 'RStatus:Pass,Fail,Crash,Abort,Skip',
   372          explanation:
   373            'Include only tests with at least one run of the specified status',
   374        },
   375        {
   376          value: 'Name:test-name-substr',
   377          explanation:
   378            'Include only tests with the specified substring in their name (case insensitive)',
   379        },
   380        {
   381          value: 'Duration:0.05-15',
   382          explanation:
   383            'Include only tests with a run that has a duration in the specified range (in seconds)',
   384        },
   385        {
   386          value: 'Duration:0.05-',
   387          explanation: 'Max duration can be omitted',
   388        },
   389        {
   390          value: 'ExactID:test-id',
   391          explanation:
   392            'Include only tests with the specified test ID (case sensitive)',
   393        },
   394        {
   395          value: 'ExactName:test-name',
   396          explanation:
   397            'Include only tests with the specified name (case sensitive)',
   398        },
   399        {
   400          value: 'VHash:2660cde9da304c42',
   401          explanation: 'Include only tests with the specified variant hash',
   402        },
   403      ];
   404    }
   405  
   406    const subQuery = query.split(' ').pop()!;
   407    if (subQuery === '') {
   408      return [];
   409    }
   410  
   411    const suggestions: Suggestion[] = [];
   412  
   413    // Suggest queries with predefined value.
   414    const subQueryUpper = subQuery.toUpperCase();
   415    suggestions.push(
   416      ...QUERY_SUGGESTIONS.filter(({ value }) =>
   417        value.toUpperCase().includes(subQueryUpper),
   418      ),
   419    );
   420  
   421    // Suggest queries with arbitrary value.
   422    const match = subQuery.match(/^([^:]*:?)(.*)$/);
   423    if (match) {
   424      const [, subQueryType, subQueryValue] = match as [string, string, string];
   425      const typeUpper = subQueryType.toUpperCase();
   426      suggestions.push(
   427        ...QUERY_TYPE_SUGGESTIONS.flatMap(({ type, explanation }) => {
   428          if (type.toUpperCase().includes(typeUpper)) {
   429            return [{ value: type + subQueryValue, explanation }];
   430          }
   431  
   432          if (subQueryValue === '') {
   433            return [{ value: type + subQueryType, explanation }];
   434          }
   435  
   436          return [];
   437        }),
   438      );
   439    }
   440  
   441    return suggestions.map((s) => ({
   442      ...s,
   443      display: s.display || highlight(s.value!, subQuery),
   444    }));
   445  }