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 &lt;Enter&gt; 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 };