tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

standalone.ts (23069B)


      1 // Implements the standalone test runner (see also: /standalone/index.html).
      2 
      3 /* eslint no-console: "off" */
      4 
      5 import { dataCache } from '../framework/data_cache.js';
      6 import { getResourcePath, setBaseResourcePath } from '../framework/resources.js';
      7 import { globalTestConfig } from '../framework/test_config.js';
      8 import { DefaultTestFileLoader } from '../internal/file_loader.js';
      9 import { Logger } from '../internal/logging/logger.js';
     10 import { LiveTestCaseResult } from '../internal/logging/result.js';
     11 import { parseQuery } from '../internal/query/parseQuery.js';
     12 import { TestQueryLevel } from '../internal/query/query.js';
     13 import { TestTreeNode, TestSubtree, TestTreeLeaf, TestTree } from '../internal/tree.js';
     14 import {
     15  getDefaultRequestAdapterOptions,
     16  getGPU,
     17  setDefaultRequestAdapterOptions,
     18 } from '../util/navigator_gpu.js';
     19 import { ErrorWithExtra, unreachable } from '../util/util.js';
     20 
     21 import {
     22  kCTSOptionsInfo,
     23  parseSearchParamLikeWithOptions,
     24  CTSOptions,
     25  OptionInfo,
     26  OptionsInfos,
     27  camelCaseToSnakeCase,
     28 } from './helper/options.js';
     29 import { TestDedicatedWorker, TestSharedWorker, TestServiceWorker } from './helper/test_worker.js';
     30 
     31 const rootQuerySpec = 'webgpu:*';
     32 let isFullCTS = false;
     33 
     34 globalTestConfig.frameworkDebugLog = console.log;
     35 
     36 // Prompt before reloading to avoid losing test results.
     37 function enablePromptBeforeReload() {
     38  window.addEventListener('beforeunload', () => {
     39    return false;
     40  });
     41 }
     42 
     43 const kOpenTestLinkAltText = 'Open';
     44 
     45 type StandaloneOptions = CTSOptions & { runnow: OptionInfo };
     46 
     47 const kStandaloneOptionsInfos: OptionsInfos<StandaloneOptions> = {
     48  ...kCTSOptionsInfo,
     49  runnow: { description: 'run immediately on load' },
     50 };
     51 
     52 const { queries: qs, options } = parseSearchParamLikeWithOptions(
     53  kStandaloneOptionsInfos,
     54  window.location.search || rootQuerySpec
     55 );
     56 const { runnow, powerPreference, compatibility, forceFallbackAdapter } = options;
     57 globalTestConfig.enableDebugLogs = options.debug;
     58 globalTestConfig.unrollConstEvalLoops = options.unrollConstEvalLoops;
     59 globalTestConfig.compatibility = compatibility;
     60 globalTestConfig.enforceDefaultLimits = options.enforceDefaultLimits;
     61 globalTestConfig.blockAllFeatures = options.blockAllFeatures;
     62 if (options.subcasesBetweenAttemptingGC) {
     63  globalTestConfig.subcasesBetweenAttemptingGC = Number(options.subcasesBetweenAttemptingGC);
     64 }
     65 if (options.casesBetweenReplacingDevice) {
     66  globalTestConfig.casesBetweenReplacingDevice = Number(options.casesBetweenReplacingDevice);
     67 }
     68 globalTestConfig.logToWebSocket = options.logToWebSocket;
     69 
     70 const logger = new Logger();
     71 
     72 setBaseResourcePath('../out/resources');
     73 
     74 const testWorker =
     75  options.worker === null
     76    ? null
     77    : options.worker === 'dedicated'
     78    ? new TestDedicatedWorker(options)
     79    : options.worker === 'shared'
     80    ? new TestSharedWorker(options)
     81    : options.worker === 'service'
     82    ? new TestServiceWorker(options)
     83    : unreachable();
     84 
     85 const autoCloseOnPass = document.getElementById('autoCloseOnPass') as HTMLInputElement;
     86 const resultsVis = document.getElementById('resultsVis')!;
     87 const progressElem = document.getElementById('progress')!;
     88 const progressTestNameElem = progressElem.querySelector('.progress-test-name')!;
     89 const stopButtonElem = progressElem.querySelector('button')!;
     90 let runDepth = 0;
     91 let stopRequested = false;
     92 
     93 stopButtonElem.addEventListener('click', () => {
     94  stopRequested = true;
     95 });
     96 
     97 if (powerPreference || compatibility || forceFallbackAdapter) {
     98  setDefaultRequestAdapterOptions({
     99    ...(powerPreference && { powerPreference }),
    100    ...(compatibility && { featureLevel: 'compatibility' }),
    101    ...(forceFallbackAdapter && { forceFallbackAdapter: true }),
    102  });
    103 }
    104 
    105 dataCache.setStore({
    106  load: async (path: string) => {
    107    const response = await fetch(getResourcePath(`cache/${path}`));
    108    if (!response.ok) {
    109      return Promise.reject(response.statusText);
    110    }
    111    return new Uint8Array(await response.arrayBuffer());
    112  },
    113 });
    114 
    115 interface SubtreeResult {
    116  pass: number;
    117  fail: number;
    118  warn: number;
    119  skip: number;
    120  total: number;
    121  timems: number;
    122 }
    123 
    124 function emptySubtreeResult() {
    125  return { pass: 0, fail: 0, warn: 0, skip: 0, total: 0, timems: 0 };
    126 }
    127 
    128 function mergeSubtreeResults(...results: SubtreeResult[]) {
    129  const target = emptySubtreeResult();
    130  for (const result of results) {
    131    target.pass += result.pass;
    132    target.fail += result.fail;
    133    target.warn += result.warn;
    134    target.skip += result.skip;
    135    target.total += result.total;
    136    target.timems += result.timems;
    137  }
    138  return target;
    139 }
    140 
    141 type SetCheckedRecursively = () => void;
    142 type GenerateSubtreeHTML = (parent: HTMLElement) => SetCheckedRecursively;
    143 type RunSubtree = () => Promise<SubtreeResult>;
    144 
    145 interface VisualizedSubtree {
    146  generateSubtreeHTML: GenerateSubtreeHTML;
    147  runSubtree: RunSubtree;
    148 }
    149 
    150 // DOM generation
    151 
    152 function memoize<T>(fn: () => T): () => T {
    153  let value: T | undefined;
    154  return () => {
    155    if (value === undefined) {
    156      value = fn();
    157    }
    158    return value;
    159  };
    160 }
    161 
    162 function makeTreeNodeHTML(tree: TestTreeNode, parentLevel: TestQueryLevel): VisualizedSubtree {
    163  let subtree: VisualizedSubtree;
    164 
    165  if ('children' in tree) {
    166    subtree = makeSubtreeHTML(tree, parentLevel);
    167  } else {
    168    subtree = makeCaseHTML(tree);
    169  }
    170 
    171  const generateMyHTML = (parentElement: HTMLElement) => {
    172    const div = $('<div>').appendTo(parentElement)[0];
    173    return subtree.generateSubtreeHTML(div);
    174  };
    175  return { runSubtree: subtree.runSubtree, generateSubtreeHTML: generateMyHTML };
    176 }
    177 
    178 function makeCaseHTML(t: TestTreeLeaf): VisualizedSubtree {
    179  // Becomes set once the case has been run once.
    180  let caseResult: LiveTestCaseResult | undefined;
    181 
    182  // Becomes set once the DOM for this case exists.
    183  let clearRenderedResult: (() => void) | undefined;
    184  let updateRenderedResult: (() => void) | undefined;
    185 
    186  const name = t.query.toString();
    187  const runSubtree = async () => {
    188    if (clearRenderedResult) clearRenderedResult();
    189 
    190    const result: SubtreeResult = emptySubtreeResult();
    191    progressTestNameElem.textContent = name;
    192 
    193    const [rec, res] = logger.record(name);
    194    caseResult = res;
    195    if (testWorker) {
    196      await testWorker.run(rec, name);
    197    } else {
    198      await t.run(rec);
    199    }
    200 
    201    result.total++;
    202    result.timems += caseResult.timems;
    203    switch (caseResult.status) {
    204      case 'pass':
    205        result.pass++;
    206        break;
    207      case 'fail':
    208        result.fail++;
    209        break;
    210      case 'skip':
    211        result.skip++;
    212        break;
    213      case 'warn':
    214        result.warn++;
    215        break;
    216      default:
    217        unreachable();
    218    }
    219 
    220    if (updateRenderedResult) updateRenderedResult();
    221 
    222    return result;
    223  };
    224 
    225  const generateSubtreeHTML = (div: HTMLElement) => {
    226    div.classList.add('testcase');
    227 
    228    const caselogs = $('<div>').addClass('testcaselogs').hide();
    229    const [casehead, setChecked] = makeTreeNodeHeaderHTML(t, runSubtree, 2, checked => {
    230      checked ? caselogs.show() : caselogs.hide();
    231    });
    232    const casetime = $('<div>').addClass('testcasetime').html('ms').appendTo(casehead);
    233    div.appendChild(casehead);
    234    div.appendChild(caselogs[0]);
    235 
    236    clearRenderedResult = () => {
    237      div.removeAttribute('data-status');
    238      casetime.text('ms');
    239      caselogs.empty();
    240    };
    241 
    242    updateRenderedResult = () => {
    243      if (caseResult) {
    244        div.setAttribute('data-status', caseResult.status);
    245 
    246        casetime.text(caseResult.timems.toFixed(4) + ' ms');
    247 
    248        if (caseResult.logs) {
    249          caselogs.empty();
    250          // Show exceptions at the top since they are often unexpected can point out an error in the test itself vs the WebGPU implementation.
    251          caseResult.logs
    252            .filter(l => l.name === 'EXCEPTION')
    253            .forEach(l => {
    254              $('<pre>').addClass('testcaselogtext').text(l.toJSON()).appendTo(caselogs);
    255            });
    256          for (const l of caseResult.logs) {
    257            const caselog = $('<div>').addClass('testcaselog').appendTo(caselogs);
    258            $('<button>')
    259              .addClass('testcaselogbtn')
    260              .attr('alt', 'Log stack to console')
    261              .attr('title', 'Log stack to console')
    262              .appendTo(caselog)
    263              .on('click', () => {
    264                consoleLogError(l);
    265              });
    266            $('<pre>').addClass('testcaselogtext').appendTo(caselog).text(l.toJSON());
    267          }
    268        }
    269      }
    270    };
    271 
    272    updateRenderedResult();
    273 
    274    return setChecked;
    275  };
    276 
    277  return { runSubtree, generateSubtreeHTML };
    278 }
    279 
    280 function makeSubtreeHTML(n: TestSubtree, parentLevel: TestQueryLevel): VisualizedSubtree {
    281  let subtreeResult: SubtreeResult = emptySubtreeResult();
    282  // Becomes set once the DOM for this case exists.
    283  let clearRenderedResult: (() => void) | undefined;
    284  let updateRenderedResult: (() => void) | undefined;
    285 
    286  const { runSubtree, generateSubtreeHTML } = makeSubtreeChildrenHTML(
    287    n.children.values(),
    288    n.query.level
    289  );
    290 
    291  const runMySubtree = async () => {
    292    if (runDepth === 0) {
    293      stopRequested = false;
    294      progressElem.style.display = '';
    295      // only prompt if this is the full CTS and we started from the root.
    296      if (isFullCTS && n.query.filePathParts.length === 0) {
    297        enablePromptBeforeReload();
    298      }
    299    }
    300    if (stopRequested) {
    301      const result = emptySubtreeResult();
    302      result.skip = 1;
    303      result.total = 1;
    304      return result;
    305    }
    306 
    307    ++runDepth;
    308 
    309    if (clearRenderedResult) clearRenderedResult();
    310    subtreeResult = await runSubtree();
    311    if (updateRenderedResult) updateRenderedResult();
    312 
    313    --runDepth;
    314    if (runDepth === 0) {
    315      progressElem.style.display = 'none';
    316    }
    317 
    318    return subtreeResult;
    319  };
    320 
    321  const generateMyHTML = (div: HTMLElement) => {
    322    const subtreeHTML = $('<div>').addClass('subtreechildren');
    323    const generateSubtree = memoize(() => generateSubtreeHTML(subtreeHTML[0]));
    324 
    325    // Hide subtree - it's not generated yet.
    326    subtreeHTML.hide();
    327    const [header, setChecked] = makeTreeNodeHeaderHTML(n, runMySubtree, parentLevel, checked => {
    328      if (checked) {
    329        // Make sure the subtree is generated and then show it.
    330        generateSubtree();
    331        subtreeHTML.show();
    332      } else {
    333        subtreeHTML.hide();
    334      }
    335    });
    336 
    337    div.classList.add('subtree');
    338    div.classList.add(['', 'multifile', 'multitest', 'multicase'][n.query.level]);
    339    div.appendChild(header);
    340    div.appendChild(subtreeHTML[0]);
    341 
    342    clearRenderedResult = () => {
    343      div.removeAttribute('data-status');
    344    };
    345 
    346    updateRenderedResult = () => {
    347      let status = '';
    348      if (subtreeResult.pass > 0) {
    349        status += 'pass';
    350      }
    351      if (subtreeResult.fail > 0) {
    352        status += 'fail';
    353      }
    354      if (subtreeResult.skip === subtreeResult.total && subtreeResult.total > 0) {
    355        status += 'skip';
    356      }
    357      div.setAttribute('data-status', status);
    358      if (autoCloseOnPass.checked && status === 'pass') {
    359        div.firstElementChild!.removeAttribute('open');
    360      }
    361    };
    362 
    363    updateRenderedResult();
    364 
    365    return () => {
    366      setChecked();
    367      const setChildrenChecked = generateSubtree();
    368      setChildrenChecked();
    369    };
    370  };
    371 
    372  return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML };
    373 }
    374 
    375 function makeSubtreeChildrenHTML(
    376  children: Iterable<TestTreeNode>,
    377  parentLevel: TestQueryLevel
    378 ): VisualizedSubtree {
    379  const childFns = Array.from(children, subtree => makeTreeNodeHTML(subtree, parentLevel));
    380 
    381  const runMySubtree = async () => {
    382    const results: SubtreeResult[] = [];
    383    for (const { runSubtree } of childFns) {
    384      if (stopRequested) {
    385        break;
    386      }
    387      results.push(await runSubtree());
    388    }
    389    return mergeSubtreeResults(...results);
    390  };
    391  const generateMyHTML = (div: HTMLElement) => {
    392    const setChildrenChecked = Array.from(childFns, ({ generateSubtreeHTML }) =>
    393      generateSubtreeHTML(div)
    394    );
    395 
    396    return () => {
    397      for (const setChildChecked of setChildrenChecked) {
    398        setChildChecked();
    399      }
    400    };
    401  };
    402 
    403  return { runSubtree: runMySubtree, generateSubtreeHTML: generateMyHTML };
    404 }
    405 
    406 function consoleLogError(e: Error | ErrorWithExtra | undefined) {
    407  if (e === undefined) return;
    408  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
    409  (globalThis as any)._stack = e;
    410  console.log('_stack =', e);
    411  if ('extra' in e && e.extra !== undefined) {
    412    console.log('_stack.extra =', e.extra);
    413  }
    414 }
    415 
    416 function makeTreeNodeHeaderHTML(
    417  n: TestTreeNode,
    418  runSubtree: RunSubtree,
    419  parentLevel: TestQueryLevel,
    420  onChange: (checked: boolean) => void
    421 ): [HTMLElement, SetCheckedRecursively] {
    422  const isLeaf = 'run' in n;
    423  const div = $('<details>').addClass('nodeheader');
    424  const header = $('<summary>').appendTo(div);
    425 
    426  // prevent toggling if user is selecting text from an input element
    427  {
    428    let lastNodeName = '';
    429    div.on('pointerdown', event => {
    430      lastNodeName = event.target.nodeName;
    431    });
    432    div.on('click', event => {
    433      if (lastNodeName === 'INPUT') {
    434        event.preventDefault();
    435      }
    436    });
    437  }
    438 
    439  const setChecked = () => {
    440    div.prop('open', true); // (does not fire onChange)
    441    onChange(true);
    442  };
    443 
    444  const href = createSearchQuery([n.query.toString()]);
    445  if (onChange) {
    446    div.on('toggle', function (this) {
    447      onChange((this as HTMLDetailsElement).open);
    448    });
    449 
    450    // Expand the shallower parts of the tree at load.
    451    // Also expand completely within subtrees that are at the same query level
    452    // (e.g. s:f:t,* and s:f:t,t,*).
    453    if (n.query.level <= lastQueryLevelToExpand || n.query.level === parentLevel) {
    454      setChecked();
    455    }
    456  }
    457  const runtext = isLeaf ? 'Run case' : 'Run subtree';
    458  $('<button>')
    459    .addClass(isLeaf ? 'leafrun' : 'subtreerun')
    460    .attr('alt', runtext)
    461    .attr('title', runtext)
    462    .on('click', async () => {
    463      if (runDepth > 0) {
    464        showInfo('tests are already running');
    465        return;
    466      }
    467      showInfo('');
    468      console.log(`Starting run for ${n.query}`);
    469      // turn off all run buttons
    470      $('#resultsVis').addClass('disable-run');
    471      const startTime = performance.now();
    472      await runSubtree();
    473      const dt = performance.now() - startTime;
    474      const dtMinutes = dt / 1000 / 60;
    475      // turn on all run buttons
    476      $('#resultsVis').removeClass('disable-run');
    477      console.log(`Finished run: ${dt.toFixed(1)} ms = ${dtMinutes.toFixed(1)} min`);
    478    })
    479    .appendTo(header);
    480  $('<a>')
    481    .addClass('nodelink')
    482    .attr('href', href)
    483    .attr('alt', kOpenTestLinkAltText)
    484    .attr('title', kOpenTestLinkAltText)
    485    .appendTo(header);
    486  $('<button>')
    487    .addClass('copybtn')
    488    .attr('alt', 'copy query')
    489    .attr('title', 'copy query')
    490    .on('click', () => {
    491      void navigator.clipboard.writeText(n.query.toString());
    492    })
    493    .appendTo(header);
    494  if ('testCreationStack' in n && n.testCreationStack) {
    495    $('<button>')
    496      .addClass('testcaselogbtn')
    497      .attr('alt', 'Log test creation stack to console')
    498      .attr('title', 'Log test creation stack to console')
    499      .appendTo(header)
    500      .on('click', () => {
    501        consoleLogError(n.testCreationStack);
    502      });
    503  }
    504  const nodetitle = $('<div>').addClass('nodetitle').appendTo(header);
    505  const nodecolumns = $('<span>').addClass('nodecolumns').appendTo(nodetitle);
    506  {
    507    $('<input>')
    508      .attr('type', 'text')
    509      .attr('title', n.query.toString())
    510      .prop('readonly', true)
    511      .addClass('nodequery')
    512      .on('click', event => {
    513        (event.target as HTMLInputElement).select();
    514      })
    515      .val(n.query.toString())
    516      .appendTo(nodecolumns);
    517    if (n.subtreeCounts) {
    518      $('<span>')
    519        .attr('title', '(Nodes with TODOs) / (Total test count)')
    520        .text(TestTree.countsToString(n))
    521        .appendTo(nodecolumns);
    522    }
    523  }
    524  if ('description' in n && n.description) {
    525    nodetitle.append('&nbsp;');
    526    $('<pre>') //
    527      .addClass('nodedescription')
    528      .text(n.description)
    529      .appendTo(header);
    530  }
    531  return [div[0], setChecked];
    532 }
    533 
    534 // Collapse s:f:t:* or s:f:t:c by default.
    535 let lastQueryLevelToExpand: TestQueryLevel = 2;
    536 
    537 /**
    538 * Takes an array of string, ParamValue and returns an array of pairs
    539 * of [key, value] where value is a string. Converts boolean to '0' or '1'.
    540 */
    541 function keyValueToPairs([k, v]: [string, boolean | string | null]): [string, string][] {
    542  const key = camelCaseToSnakeCase(k);
    543  if (typeof v === 'boolean') {
    544    return [[key, v ? '1' : '0']];
    545  } else if (Array.isArray(v)) {
    546    return v.map(v => [key, v]);
    547  } else {
    548    return [[key, v!.toString()]];
    549  }
    550 }
    551 
    552 /**
    553 * Converts key value pairs to a search string.
    554 * Keys will appear in order in the search string.
    555 * Values can be undefined, null, boolean, string, or string[]
    556 * If the value is falsy the key will not appear in the search string.
    557 * If the value is an array the key will appear multiple times.
    558 *
    559 * @param params Some object with key value pairs.
    560 * @returns a search string.
    561 */
    562 function prepareParams(params: Record<string, boolean | string | null>): string {
    563  const pairsArrays = Object.entries(params)
    564    .filter(([, v]) => !(v === false || v === null || v === '0'))
    565    .map(keyValueToPairs);
    566  const pairs = pairsArrays.flat();
    567  return new URLSearchParams(pairs).toString();
    568 }
    569 
    570 // This is just a cast in one place.
    571 export function optionsToRecord(options: CTSOptions) {
    572  return options as unknown as Record<string, boolean | string | null>;
    573 }
    574 
    575 /**
    576 * Given a search query, generates a search parameter string
    577 * @param queries array of queries
    578 * @param params an optional existing search
    579 * @returns a search query string
    580 */
    581 function createSearchQuery(queries: string[], params?: string) {
    582  params = params === undefined ? prepareParams(optionsToRecord(options)) : params;
    583  // Add in q separately to avoid escaping punctuation marks.
    584  return `?${params}${params ? '&' : ''}${queries.map(q => 'q=' + q).join('&')}`;
    585 }
    586 
    587 /**
    588 * Show an info message on the page.
    589 * @param msg Message to show
    590 */
    591 function showInfo(msg: string) {
    592  $('#info')[0].textContent = msg;
    593 }
    594 
    595 void (async () => {
    596  const loader = new DefaultTestFileLoader();
    597 
    598  // MAINTENANCE_TODO: start populating page before waiting for everything to load?
    599  isFullCTS = qs.length === 1 && qs[0] === rootQuerySpec;
    600 
    601  // Update the URL bar to match the exact current options.
    602  const updateURLsWithCurrentOptions = () => {
    603    const params = prepareParams(optionsToRecord(options));
    604    let url = `${window.location.origin}${window.location.pathname}`;
    605    url += createSearchQuery(qs, params);
    606    window.history.replaceState(null, '', url.toString());
    607    document.querySelectorAll(`a[alt=${kOpenTestLinkAltText}]`).forEach(elem => {
    608      const a = elem as HTMLAnchorElement;
    609      const qs = new URLSearchParams(a.search).getAll('q');
    610      a.search = createSearchQuery(qs, params);
    611    });
    612  };
    613 
    614  const addOptionsToPage = (
    615    options: StandaloneOptions,
    616    optionsInfos: typeof kStandaloneOptionsInfos
    617  ) => {
    618    const optionsElem = $('table#options>tbody')[0];
    619    const optionValues = optionsToRecord(options);
    620 
    621    const createCheckbox = (optionName: string) => {
    622      return $(`<input>`)
    623        .attr('type', 'checkbox')
    624        .prop('checked', optionValues[optionName] as boolean)
    625        .on('change', function () {
    626          optionValues[optionName] = (this as HTMLInputElement).checked;
    627          updateURLsWithCurrentOptions();
    628        });
    629    };
    630 
    631    const createSelect = (optionName: string, info: OptionInfo) => {
    632      const select = $('<select>').on('change', function (this: HTMLSelectElement) {
    633        optionValues[optionName] = JSON.parse(this.value);
    634        updateURLsWithCurrentOptions();
    635      });
    636      const currentValue = optionValues[optionName];
    637      for (const { value, description } of info.selectValueDescriptions!) {
    638        $('<option>')
    639          .text(description)
    640          .val(JSON.stringify(value))
    641          .prop('selected', value === currentValue)
    642          .appendTo(select);
    643      }
    644      return select;
    645    };
    646 
    647    Object.entries(optionsInfos).forEach(([optionName, info], i) => {
    648      const id = `option${i}`;
    649      const input =
    650        typeof optionValues[optionName] === 'boolean'
    651          ? createCheckbox(optionName)
    652          : createSelect(optionName, info);
    653      input.attr('id', id);
    654      $('<tr>')
    655        .append($('<td>').append(input))
    656        .append(
    657          $('<td>').append($('<label>').attr('for', id).text(camelCaseToSnakeCase(optionName)))
    658        )
    659        .append($('<td>').text(info.description))
    660        .appendTo(optionsElem);
    661    });
    662  };
    663  addOptionsToPage(options, kStandaloneOptionsInfos);
    664 
    665  let deviceDescription = '<unable to get WebGPU adapter>';
    666  const adapter = await getGPU(null).requestAdapter(getDefaultRequestAdapterOptions());
    667  if (adapter) {
    668    deviceDescription = `${adapter.info.vendor} ${adapter.info.architecture} (${adapter.info.description})`;
    669  }
    670  $('#device')[0].textContent = 'Default WebGPU adapter: ' + deviceDescription;
    671  logger.defaultDeviceDescription = deviceDescription;
    672 
    673  if (qs.length !== 1) {
    674    showInfo('currently, there must be exactly one ?q=');
    675    return;
    676  }
    677 
    678  let rootQuery;
    679  try {
    680    rootQuery = parseQuery(qs[0]);
    681  } catch (e) {
    682    showInfo((e as Error).toString());
    683    return;
    684  }
    685 
    686  if (rootQuery.level > lastQueryLevelToExpand) {
    687    lastQueryLevelToExpand = rootQuery.level;
    688  }
    689  loader.addEventListener('import', ev => {
    690    showInfo(`loading: ${ev.data.url}`);
    691  });
    692  loader.addEventListener('imported', ev => {
    693    showInfo(`imported: ${ev.data.url}`);
    694  });
    695  loader.addEventListener('finish', () => {
    696    showInfo('');
    697  });
    698 
    699  let tree;
    700  try {
    701    tree = await loader.loadTree(rootQuery);
    702  } catch (err) {
    703    showInfo((err as Error).toString());
    704    return;
    705  }
    706 
    707  document.title = `${document.title} ${compatibility ? '(compat)' : ''} - ${rootQuery.toString()}`;
    708 
    709  tree.dissolveSingleChildTrees();
    710 
    711  const { runSubtree, generateSubtreeHTML } = makeSubtreeHTML(tree.root, 1);
    712  const setTreeCheckedRecursively = generateSubtreeHTML(resultsVis);
    713 
    714  document.getElementById('expandall')!.addEventListener('click', () => {
    715    setTreeCheckedRecursively();
    716  });
    717 
    718  function getResultsText() {
    719    const saveOptionElement = document.getElementById('saveOnlyFailures') as HTMLInputElement;
    720    const onlyFailures = saveOptionElement.checked;
    721    const predFunc = (key: string, value: LiveTestCaseResult) =>
    722      value.status === 'fail' || !onlyFailures;
    723    return logger.asJSON(2, predFunc);
    724  }
    725 
    726  document.getElementById('copyResultsJSON')!.addEventListener('click', () => {
    727    void navigator.clipboard.writeText(getResultsText());
    728  });
    729 
    730  document.getElementById('saveResultsJSON')!.addEventListener('click', () => {
    731    const text = getResultsText();
    732    const blob = new Blob([text], { type: 'text/plain' });
    733    const link = document.createElement('a');
    734    link.download = 'results-webgpu-cts.json';
    735    link.href = window.URL.createObjectURL(blob);
    736    link.click();
    737  });
    738 
    739  if (runnow) {
    740    void runSubtree();
    741  }
    742 })();