github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/components/test-search.js (about) 1 /** 2 * Copyright 2018 The WPT Dashboard Project. All rights reserved. 3 * Use of this source code is governed by a BSD-style license that can be 4 * found in the LICENSE file. 5 */ 6 7 import '../node_modules/@polymer/paper-tooltip/paper-tooltip.js'; 8 import { html } from '../node_modules/@polymer/polymer/polymer-element.js'; 9 import { PolymerElement } from '../node_modules/@polymer/polymer/polymer-element.js'; 10 import { WPTFlags } from './wpt-flags.js'; 11 import './ohm.js'; 12 import { AllBrowserNames } from './product-info.js'; 13 14 /* eslint-enable */ 15 const statuses = [ 16 'pass', 17 'ok', 18 'error', 19 'timeout', 20 'notrun', 21 'fail', 22 'crash', 23 'skip', 24 'assert', 25 'unknown', 26 'missing', // UI calls unknown missing. 27 ]; 28 29 const atoms = { 30 status: statuses, 31 }; 32 33 for (const b of AllBrowserNames) { 34 atoms[b] = statuses; 35 } 36 37 /* global ohm */ 38 const QUERY_GRAMMAR = ohm.grammar(` 39 Query { 40 Root = ListOf<OrQ, space*> 41 42 OrQ = NonemptyListOf<AndQ, or> 43 44 AndQ = NonemptyListOf<Q, and> 45 46 Q = All 47 | None 48 | Count 49 | Sequential 50 | Exists 51 52 All = "all(" ListOf<Exp, space*> ")" 53 54 None = "none(" ListOf<Exp, space*> ")" 55 56 Sequential = "seq(" ListOf<Exp, space*> ")" 57 58 Count = CountSpecifier "(" Exp ")" 59 60 CountSpecifier 61 = "count" ":"? inequality number -- countInequality 62 | "count:" number -- countN 63 | "three" -- count3 64 | "two" -- count2 65 | "one" -- count1 66 67 Exists 68 = "exists(" ListOf<Exp, space*> ")" -- explicit 69 | AndPart -- implicit 70 71 72 Exp = NonemptyListOf<OrPart, or> 73 74 OrPart = NonemptyListOf<AndPart, and> 75 76 AndPart 77 = NestedExp 78 | Fragment -- fragment 79 80 NestedExp 81 = "(" Exp ")" -- paren 82 | not NestedExp -- not 83 84 or 85 = "|" 86 | caseInsensitive<"or"> 87 88 and 89 = "&" 90 | caseInsensitive<"and"> 91 92 not 93 = "!" 94 | "not" 95 96 inequality 97 = ">=" 98 | "<=" 99 | ">" 100 | "<" 101 | "=" 102 103 Fragment 104 = not Fragment -- not 105 | linkExp 106 | isExp 107 | triagedExp 108 | labelExp 109 | webFeatureExp 110 | statusExp 111 | subtestExp 112 | pathExp 113 | patternExp 114 115 statusExp 116 = caseInsensitive<"status"> ":" statusLiteral -- eq 117 | caseInsensitive<"status"> ":!" statusLiteral -- neq 118 | productSpec ":" statusLiteral -- product_eq 119 | productSpec ":!" statusLiteral -- product_neq 120 121 subtestExp 122 = caseInsensitive<"subtest"> ":" nameFragment 123 124 pathExp 125 = caseInsensitive<"path"> ":" nameFragment 126 127 linkExp 128 = caseInsensitive<"link"> ":" nameFragment 129 130 triagedExp 131 = caseInsensitive<"triaged"> ":" browserName 132 | caseInsensitive<"triaged"> ":" "test-issue" 133 134 labelExp 135 = caseInsensitive<"label"> ":" nameFragment 136 137 webFeatureExp 138 = caseInsensitive<"feature"> ":" nameFragment 139 140 isExp 141 = caseInsensitive<"is"> ":" metadataQualityLiteral 142 143 patternExp = nameFragment 144 145 productSpec = browserName ("-" browserVersion)? 146 147 browserName 148 = ${AllBrowserNames.map(b => 'caseInsensitive<"' + b + '">').join('\n |')} 149 150 browserVersion = number ("." number)* 151 152 statusLiteral 153 = ${statuses.map(s => 'caseInsensitive<"' + s + '">').join('\n |')} 154 155 metadataQualityLiteral 156 = caseInsensitive<"different"> 157 | caseInsensitive<"tentative"> 158 | caseInsensitive<"optional"> 159 160 nameFragment 161 = basicNameFragment -- basic 162 | quotemark complexNameFragment quotemark -- quoted 163 164 basicNameFragment = basicNameFragmentChar+ 165 166 complexNameFragment = nameFragmentChar+ (space+ nameFragmentChar+)* 167 168 basicNameFragmentChar 169 = letter 170 | digit 171 | "/" 172 | "." 173 | "-" 174 | "_" 175 | "?" 176 177 nameFragmentChar 178 = "\\x00".."\\x08" 179 | "\\x0E".."\\x1F" 180 | "\\x21" 181 | "\\x23".."\\uFFFF" 182 183 number = digit+ 184 quotemark = "\\"" 185 backslash = "\\\\" 186 } 187 `); 188 /* eslint-disable */ 189 const evalNot = (n, p) => { 190 return {not: p.eval()}; 191 }; 192 const evalSelf = p => p.eval(); 193 const emptyQuery = Object.freeze({exists: [{pattern: ''}]}); 194 const andConjunction = l => { 195 const ps = l.eval(); 196 return ps.length === 1 ? ps[0] : {and: ps}; 197 }; 198 const orConjunction = l => { 199 const ps = l.eval(); 200 return ps.length === 1 ? ps[0] : {or: ps}; 201 }; 202 const QUERY_SEMANTICS = QUERY_GRAMMAR.createSemantics().addOperation('eval', { 203 _terminal: function() { 204 return this.sourceString; 205 }, 206 Root: (r) => { 207 const ps = r.eval(); 208 if (ps.length === 0) { 209 return emptyQuery; 210 } 211 // If there's only separate implicit exists at the root, collapse them. 212 const isImplicitExists = p => 'exists' in p && p.exists.length === 1 213 || 'and' in p && p.and.every(isImplicitExists) 214 || 'or' in p && p.or.every(isImplicitExists); 215 if (ps.every(isImplicitExists)) { 216 const unwrap = p => 'exists' in p && p.exists[0] 217 || 'or' in p && { or: p.or.map(unwrap) } 218 || 'and' in p && { and: p.and.map(unwrap) } 219 || p; 220 return { exists: ps.map(unwrap) }; 221 } 222 if (ps.length === 1) { 223 return ps[0]; 224 } 225 return { and: ps }; 226 }, 227 OrQ: orConjunction, 228 AndQ: andConjunction, 229 EmptyListOf: function() { 230 return []; 231 }, 232 NonemptyListOf: function(fst, seps, rest) { 233 return [fst.eval()].concat(rest.eval()); 234 }, 235 Exists_explicit: (l, e, r) => { 236 return { exists: e.eval() }; 237 }, 238 Exists_implicit: e => { 239 return { exists: [e.eval()] }; 240 }, 241 All: (_, l, __) => { 242 const ps = l.eval(); 243 return ps.length === 0 ? emptyQuery : { all: ps }; 244 }, 245 None: (_, l, __) => { 246 const ps = l.eval(); 247 return ps.length === 0 ? emptyQuery : { none: ps }; 248 }, 249 Sequential: (_, l, __) => { 250 const ps = l.eval(); 251 return ps.length === 0 ? emptyQuery : { sequential: ps }; 252 }, 253 Count: (cs, _, exp, __) => { 254 let count = cs.eval(); 255 count.where = exp.eval(); 256 return count; 257 }, 258 CountSpecifier_countInequality: (_, __, c, n) => { 259 let inequality = c.eval(); 260 switch (inequality) { 261 case ">=": 262 return { moreThan: parseInt(n.eval()) - 1 }; 263 case ">": 264 return { moreThan: n.eval() }; 265 case "<=": 266 return { lessThan: parseInt(n.eval()) + 1 }; 267 case "<": 268 return { lessThan: n.eval() }; 269 case ":": 270 case "=": 271 return { count: n.eval() }; 272 } 273 throw new Error('Unexpected inequality ' + inequality); 274 }, 275 CountSpecifier_countN: (_, n) => { return { count: n.eval() }; }, 276 CountSpecifier_count3: (_) => {return {count: 3}; }, 277 CountSpecifier_count2: (_) => {return {count: 2}; }, 278 CountSpecifier_count1: (_) => {return {count: 1}; }, 279 linkExp: (l, colon, r) => { 280 const ps = r.eval(); 281 return ps.length === 0 ? emptyQuery : {link: ps }; 282 }, 283 Exp: orConjunction, 284 NestedExp: evalSelf, 285 NestedExp_paren: (_, p, __) => p.eval(), 286 NestedExp_not: evalNot, 287 OrPart: andConjunction, 288 AndPart_fragment: evalSelf, 289 Fragment: evalSelf, 290 Fragment_not: evalNot, 291 browserName: (browser) => { 292 return browser.sourceString.toUpperCase(); 293 }, 294 statusLiteral: (status) => { 295 return status.sourceString.toUpperCase() === 'MISSING' 296 ? 'UNKNOWN' 297 : status.sourceString.toUpperCase(); 298 }, 299 statusExp_eq: (l, colon, r) => { 300 return { status: r.eval() }; 301 }, 302 statusExp_product_eq: (l, colon, r) => { 303 return { 304 product: l.sourceString.toLowerCase(), 305 status: r.eval(), 306 }; 307 }, 308 statusExp_neq: (l, colonBang, r) => { 309 return { status: {not: r.eval() } }; 310 }, 311 statusExp_product_neq: (l, colonBang, r) => { 312 return { 313 product: l.sourceString.toLowerCase(), 314 status: {not: r.eval()}, 315 }; 316 }, 317 isExp: (l, colon, r) => { 318 return { is: r.eval() }; 319 }, 320 triagedExp: (l, colon, r) => { 321 const ps = r.eval(); 322 if (ps.length === 0) { 323 return emptyQuery; 324 } 325 // Test-level issues are represented on the backend as an empty product. 326 return { triaged: ps.toLowerCase().replace('test-issue', '') }; 327 }, 328 labelExp: (l, colon, r) => { 329 const ps = r.eval(); 330 return ps.length === 0 ? emptyQuery : {label: ps }; 331 }, 332 webFeatureExp: (l, colon, r) => { 333 const ps = r.eval(); 334 return ps.length === 0 ? emptyQuery : {feature: ps }; 335 }, 336 subtestExp: (l, colon, r) => { 337 return { subtest: r.eval() }; 338 }, 339 pathExp: (l, colon, r) => { 340 return { path: r.eval() }; 341 }, 342 patternExp: (p) => { 343 return { pattern: p.eval() }; 344 }, 345 nameFragment_basic: (p) => { 346 return p.sourceString; 347 }, 348 nameFragment_quoted: (_, chars, __) => { 349 return chars.sourceString; 350 }, 351 backslash: (v) => '\\', 352 quotemark: (v) => '"', 353 number: (v) => parseInt(v.sourceString), 354 }); 355 /* eslint-enable */ 356 357 const QUERY_DEBOUNCE_ID = Symbol('query_debounce_timeout'); 358 359 class TestSearch extends WPTFlags(PolymerElement) { 360 static get template() { 361 return html` 362 <style> 363 input.query { 364 font-size: 16px; 365 display: block; 366 padding: 0.5em 0; 367 width: 100%; 368 } 369 .help { 370 float: right; 371 } 372 </style> 373 374 <div> 375 <input class="query" list="query-list" aria-label="Search test files" 376 value="{{ queryInput::input }}" placeholder="[[placeholder]]" 377 onchange="[[onChange]]" onkeyup="[[onKeyUp]]" onkeydown="[[onKeyDown]]" onfocus="[[onFocus]]" onblur="[[onBlur]]"> 378 <span class="help"> 379 For information on the search syntax, <a href="https://github.com/web-platform-tests/wpt.fyi/blob/main/api/query/README.md">view the search documentation</a> 380 </span> 381 382 <!-- TODO(markdittmer): Static id will break multiple search components. --> 383 <datalist id="query-list"></datalist> 384 <paper-tooltip position="top" manual-mode="true"> 385 Press <Enter> to commit query 386 </paper-tooltip> 387 </div> 388 `; 389 } 390 391 static get QUERY_GRAMMAR() { 392 return QUERY_GRAMMAR; 393 } 394 static get QUERY_SEMANTICS() { 395 return QUERY_SEMANTICS; 396 } 397 static get is() { 398 return 'test-search'; 399 } 400 static get properties() { 401 return { 402 placeholder: { 403 type: String, 404 value: 'Search test files, like \'cors/allow-headers.htm\', then press <Enter>', 405 }, 406 // Query input string 407 queryInput: { 408 type: String, 409 notify: true, 410 observer: 'queryInputChanged' 411 }, 412 // Debounced + normalized query string. 413 query: { 414 type: String, 415 notify: true, 416 observer: 'queryUpdated', 417 }, 418 structuredQuery: { 419 type: Object, 420 notify: true, 421 }, 422 results: { 423 type: Array, 424 notify: true, 425 }, 426 testPaths: Set, 427 onKeyUp: Function, 428 onChange: Function, 429 onFocus: Function, 430 onBlur: Function, 431 }; 432 } 433 434 constructor() { 435 super(); 436 437 this.onChange = this.handleChange.bind(this); 438 this.onFocus = this.handleFocus.bind(this); 439 this.onBlur = this.handleBlur.bind(this); 440 this.onKeyUp = this.handleKeyUp.bind(this); 441 this.onKeyDown = this.handleKeyDown.bind(this); 442 } 443 444 ready() { 445 super.ready(); 446 this._createMethodObserver('updateDatalist(query, testPaths)'); 447 this.queryInput = this.query; 448 } 449 450 queryUpdated(query) { 451 this.queryInput = query; 452 if (this.structuredQueries) { 453 if (!query) { 454 this.structuredQuery = null; 455 } else { 456 try { 457 this.structuredQuery = Object.freeze(this.parseAndInterpretQuery(query)); 458 } catch (err) { 459 // TODO: Handle query parse/interpret error. 460 } 461 } 462 } 463 } 464 465 parseAndInterpretQuery(query) { 466 const p = QUERY_GRAMMAR.match(query); 467 if (!p.succeeded()) { 468 throw new Error(`Failed to parse query: ${query}`); 469 } 470 471 return QUERY_SEMANTICS(p).eval(); 472 } 473 474 updateDatalist(query, paths) { 475 const datalist = this.shadowRoot.querySelector('datalist'); 476 datalist.innerHTML = ''; 477 for (const atomPrefix of Object.keys(atoms)) { 478 if (!query || atomPrefix.startsWith(query)) { 479 const option = document.createElement('option'); 480 option.setAttribute('value', atomPrefix + ':'); 481 option.setAttribute('atom', atomPrefix); 482 datalist.appendChild(option); 483 } else if (query) { 484 for (const value of atoms[atomPrefix].map(v => `${atomPrefix}:${v}`)) { 485 if (value.startsWith(query)) { 486 const option = document.createElement('option'); 487 option.setAttribute('value', value); 488 option.setAttribute('atom', value); 489 datalist.appendChild(option); 490 } 491 } 492 } 493 } 494 if (paths) { 495 let matches = Array.from(paths); 496 if (query) { 497 matches = matches 498 .filter(p => p.toLowerCase().includes(query)) 499 .sort((p1, p2) => p1.indexOf(query) - p2.indexOf(query)); 500 } 501 for (const match of matches.slice(0, 10 - datalist.children.length)) { 502 const option = document.createElement('option'); 503 option.setAttribute('value', match); 504 datalist.appendChild(option); 505 } 506 } 507 } 508 509 queryInputChanged(_, oldQuery) { 510 // Debounce first initialization. 511 if (typeof(oldQuery) === 'undefined') { 512 return; 513 } 514 if (this[QUERY_DEBOUNCE_ID]) { 515 window.clearTimeout(this[QUERY_DEBOUNCE_ID]); 516 } 517 this[QUERY_DEBOUNCE_ID] = window.setTimeout(this.latchQuery.bind(this), 500); 518 } 519 520 latchQuery() { 521 this.query = (this.queryInput || '').toLowerCase(); 522 } 523 524 commitQuery() { 525 this.query = this.queryInput; 526 this.dispatchEvent(new CustomEvent('commit', { 527 detail: { 528 query: this.query, 529 structuredQuery: this.structuredQuery, 530 }, 531 })); 532 this.shadowRoot.querySelector('.query').blur(); 533 } 534 535 handleKeyDown(e) { 536 // Prevent tab key navigation on search bar. 537 if (e.keyCode === 9) { 538 e.preventDefault(); 539 return false; 540 } 541 } 542 543 handleKeyUp(e) { 544 // Commit when enter key was pressed 545 if (e.keyCode === 13) { 546 this.commitQuery(); 547 } 548 } 549 550 handleChange(e) { 551 const opts = Array.from(this.shadowRoot.querySelectorAll('option')); 552 if (opts.length === 0) { 553 return; 554 } 555 556 const path = e.target.value; 557 const autocompleteSelection = 558 opts.find(o => o.getAttribute('value').toLowerCase().includes(path.toLowerCase())); 559 if (autocompleteSelection) { 560 if (autocompleteSelection.getAttribute('atom')) { 561 return; 562 } 563 if (autocompleteSelection.value.toLowerCase() === path.toLowerCase()) { 564 this.dispatchEvent(new CustomEvent('autocomplete', { 565 detail: {path: autocompleteSelection.value}, 566 })); 567 this.shadowRoot.querySelector('.query').blur(); 568 } 569 } 570 } 571 572 handleFocus() { 573 this.shadowRoot.querySelector('paper-tooltip').show(); 574 } 575 576 handleBlur() { 577 this.shadowRoot.querySelector('paper-tooltip').hide(); 578 } 579 580 clear() { 581 this.query = ''; 582 this.queryInput = ''; 583 } 584 } 585 window.customElements.define(TestSearch.is, TestSearch); 586 587 export { TestSearch };