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 }