chromium/chrome/browser/resources/omnibox/omnibox.ts

// Copyright 2012 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './strings.m.js';
import './omnibox_input.js';
import './omnibox_output.js';

import {assert} from 'chrome://resources/js/assert.js';
import {sendWithPromise} from 'chrome://resources/js/cr.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';

import type {OmniboxPageHandlerRemote, OmniboxResponse} from './omnibox.mojom-webui.js';
import {AutocompleteControllerType, OmniboxPageCallbackRouter, OmniboxPageHandler} from './omnibox.mojom-webui.js';
import type {DisplayInputs, OmniboxInput, QueryInputs} from './omnibox_input.js';
import type {OmniboxOutput} from './omnibox_output.js';

/**
 * Javascript for omnibox.html, served from chrome://omnibox/
 * This is used to debug omnibox ranking. The user enters some text into a box,
 * submits it, and then sees lots of debug information from the autocompleter
 * that shows what omnibox would do with that input.
 *
 * The simple object defined in this javascript file listens for contain events
 * on omnibox.html, sends (when appropriate) the input text to C++ code to start
 * the omnibox autcomplete controller working, and listens from callbacks from
 * the C++ code saying that results are available. When results (possibly
 * intermediate ones) are available, the Javascript formats them and displays
 * them.
 */

declare global {
  interface HTMLElementEventMap {
    'query-inputs-changed': CustomEvent<QueryInputs>;
    'display-inputs-changed': CustomEvent<DisplayInputs>;
    'filter-input-changed': CustomEvent<string>;
    'import': CustomEvent<OmniboxExport>;
    'process-batch': CustomEvent<BatchSpecifier>;
    'response-select': CustomEvent<number>;
    'responses-count-changed': CustomEvent<number>;
  }

  interface HTMLElementTagNameMap {
    'OmniboxInput': OmniboxInput;
    'OmniboxOutput': OmniboxOutput;
  }
}

interface OmniboxRequest {
  inputText: string;
  callback: (omniboxResponse: OmniboxResponse) => void;
  display: boolean;
}

interface BatchSpecifier {
  batchName: string;
  batchMode: string;
  batchQueryInputs: QueryInputs[];
}

interface OmniboxExport {
  versionDetails: Record<string, string>;
  queryInputs: QueryInputs;
  displayInputs: DisplayInputs;
  responsesHistory: OmniboxResponse[][];
}

let browserProxy: BrowserProxy;
let omniboxInput: OmniboxInput;
let omniboxOutput: OmniboxOutput;
let exportDelegate: ExportDelegate;

class BrowserProxy {
  private callbackRouter_: OmniboxPageCallbackRouter =
      new OmniboxPageCallbackRouter();
  private handler_: OmniboxPageHandlerRemote;
  private lastRequest: OmniboxRequest | null = null;

  constructor(omniboxOutput: OmniboxOutput) {
    this.callbackRouter_.handleNewAutocompleteResponse.addListener(
        this.handleNewAutocompleteResponse.bind(this));
    this.callbackRouter_.handleNewAutocompleteQuery.addListener(
        this.handleNewAutocompleteQuery.bind(this));
    this.callbackRouter_.handleAnswerImageData.addListener(
        omniboxOutput.updateAnswerImage.bind(omniboxOutput));

    this.handler_ = OmniboxPageHandler.getRemote();
    this.handler_.setClientPage(
        this.callbackRouter_.$.bindNewPipeAndPassRemote());
  }

  private handleNewAutocompleteResponse(
      controllerType: AutocompleteControllerType, response: OmniboxResponse) {
    if (controllerType === AutocompleteControllerType.kMlDisabledDebug) {
      return;
    }
    const isDebugController =
        controllerType === AutocompleteControllerType.kDebug;

    const isForLastPageRequest =
        this.isForLastPageRequest(response.inputText, isDebugController);

    // When unfocusing the browser omnibox, the autocomplete controller
    // sends a response with no combined results. This response is ignored
    // in order to prevent the previous non-empty response from being
    // hidden and because these results wouldn't normally be displayed by
    // the browser window omnibox.
    if (isForLastPageRequest && this.lastRequest!.display ||
        omniboxInput.connectWindowOmnibox && !isDebugController &&
            response.combinedResults.length) {
      omniboxOutput.addAutocompleteResponse(response);
    }

    // TODO(orinj|manukh): If `response.done` but not `isForLastPageRequest`
    //  then callback is being dropped. We should guarantee that callback is
    //  always called because some callers await promises.
    if (isForLastPageRequest && response.done) {
      assert(this.lastRequest);
      this.lastRequest.callback(response);
      this.lastRequest = null;
    }
  }

  private handleNewAutocompleteQuery(
      controllerType: AutocompleteControllerType, inputText: string) {
    if (controllerType === AutocompleteControllerType.kMlDisabledDebug) {
      return;
    }
    const isDebugController =
        controllerType === AutocompleteControllerType.kDebug;
    // If the request originated from the debug page and is not for display,
    // then we don't want to clear the omniboxOutput.
    if (this.isForLastPageRequest(inputText, isDebugController) &&
            this.lastRequest!.display ||
        omniboxInput.connectWindowOmnibox && !isDebugController) {
      omniboxOutput.prepareNewQuery();
    }
  }

  makeRequest(
      inputText: string, resetAutocompleteController: boolean,
      cursorPosition: number, zeroSuggest: boolean,
      preventInlineAutocomplete: boolean, preferKeyword: boolean,
      currentUrl: string, pageClassification: number,
      display: boolean): Promise<OmniboxResponse> {
    return new Promise(resolve => {
      this.lastRequest = {inputText, callback: resolve, display};
      this.handler_.startOmniboxQuery(
          inputText, resetAutocompleteController, cursorPosition, zeroSuggest,
          preventInlineAutocomplete, preferKeyword, currentUrl,
          pageClassification);
    });
  }

  isForLastPageRequest(inputText: string, isDebugController: boolean): boolean {
    // Note: Using inputText is a sufficient fix for the way this is used today,
    // but in principle it would be better to associate requests with responses
    // using a unique session identifier, for example by rolling an integer each
    // time a request is made. Doing so would require extra bookkeeping on the
    // host side, so for now we keep it simple.
    return isDebugController && !!this.lastRequest &&
        this.lastRequest!.inputText.trimStart() === inputText;
  }
}

document.addEventListener('DOMContentLoaded', () => {
  omniboxInput = document.querySelector('omnibox-input')!;
  omniboxOutput = document.querySelector('omnibox-output')!;
  browserProxy = new BrowserProxy(omniboxOutput);
  exportDelegate = new ExportDelegate(omniboxOutput, omniboxInput);

  omniboxInput.addEventListener('query-inputs-changed', e => {
    browserProxy.makeRequest(
        e.detail.inputText, e.detail.resetAutocompleteController,
        e.detail.cursorPosition, e.detail.zeroSuggest,
        e.detail.preventInlineAutocomplete, e.detail.preferKeyword,
        e.detail.currentUrl, e.detail.pageClassification, true);
  });
  omniboxInput.addEventListener(
      'display-inputs-changed',
      e => omniboxOutput.updateDisplayInputs(e.detail));
  omniboxInput.addEventListener(
      'filter-input-changed', e => omniboxOutput.updateFilterText(e.detail));
  omniboxInput.addEventListener('import', e => exportDelegate.import(e.detail));
  omniboxInput.addEventListener(
      'process-batch', e => exportDelegate.processBatchData(e.detail));
  omniboxInput.addEventListener(
      'export-clipboard', () => exportDelegate.exportClipboard());
  omniboxInput.addEventListener(
      'export-file', () => exportDelegate.exportFile());
  omniboxInput.addEventListener(
      'response-select',
      e => omniboxOutput.updateSelectedResponseIndex(e.detail));

  omniboxOutput.addEventListener(
      'responses-count-changed', e => omniboxInput.responsesCount = e.detail);

  omniboxOutput.updateDisplayInputs(omniboxInput.displayInputs);
});

class ExportDelegate {
  private omniboxInput_: OmniboxInput;
  private omniboxOutput_: OmniboxOutput;

  constructor(omniboxOutput: OmniboxOutput, omniboxInput: OmniboxInput) {
    this.omniboxInput_ = omniboxInput;
    this.omniboxOutput_ = omniboxOutput;
  }

  /**
   * Import a single data item previously exported. Returns true if a single
   * data item was imported for viewing; false if import failed.
   */
  import(importData: OmniboxExport): boolean {
    if (!validateImportData(importData)) {
      // TODO(manukh): Make use of this return value to fix the UI state bug in
      //  omnibox_input.js -- see the related TODO there.
      return false;
    }
    this.omniboxInput_.queryInputs = importData.queryInputs;
    this.omniboxInput_.displayInputs = importData.displayInputs;
    this.omniboxOutput_.updateDisplayInputs(importData.displayInputs);
    this.omniboxOutput_.setResponsesHistory(importData.responsesHistory);
    return true;
  }

  /**
   * This is the worker function that transforms query inputs to accumulate
   * batch exports, then finally initiates a download for the complete set.
   */
  private async processBatch(
      batchQueryInputs: QueryInputs[], batchName: string) {
    const batchExports = [];
    for (const queryInputs of batchQueryInputs) {
      const omniboxResponse = await browserProxy.makeRequest(
          queryInputs.inputText, queryInputs.resetAutocompleteController,
          queryInputs.cursorPosition, queryInputs.zeroSuggest,
          queryInputs.preventInlineAutocomplete, queryInputs.preferKeyword,
          queryInputs.currentUrl, queryInputs.pageClassification, false);
      const exportData = {
        queryInputs,
        // TODO(orinj|manukh): Make the schema consistent and remove the extra
        //  level of array nesting. [[This]] is done for now so that elements
        //  can be extracted in the form import expects.
        responsesHistory: [[omniboxResponse]],
        displayInputs: this.omniboxInput_.displayInputs,
      };
      batchExports.push(exportData);
    }
    const variationInfo =
        await sendWithPromise('requestVariationInfo', true);
    const pathInfo = await sendWithPromise('requestPathInfo');

    const now = new Date();
    const fileName = `omnibox_batch_${ExportDelegate.getTimeStamp(now)}.json`;
    // If this data format changes, please roll schemaVersion.
    const batchData = {
      schemaKind: 'Omnibox Batch Export',
      schemaVersion: 3,
      dateCreated: now.toISOString(),
      author: '',
      description: '',
      authorTool: 'chrome://omnibox',
      batchName,
      versionDetails: ExportDelegate.getVersionDetails(),
      variationInfo,
      pathInfo,
      appVersion: navigator.appVersion,
      batchExports,
    };
    ExportDelegate.download(batchData, fileName);
  }

  /**
   * Event handler for uploaded batch processing specifier data, kicks off
   * the processBatch asynchronous pipeline.
   */
  processBatchData(processBatchData: BatchSpecifier) {
    if (processBatchData.batchMode && processBatchData.batchQueryInputs &&
        processBatchData.batchName) {
      this.processBatch(
          processBatchData.batchQueryInputs, processBatchData.batchName);
    } else {
      const expected = {
        batchMode: 'combined',
        batchName: 'name for this batch of queries',
        batchQueryInputs: [{
          inputText: 'example input text',
          cursorPosition: 18,
          resetAutocompleteController: false,
          cursorLock: false,
          zeroSuggest: false,
          preventInlineAutocomplete: false,
          preferKeyword: false,
          currentUrl: '',
          pageClassification: '4',
        }],
      };
      console.error(`Invalid batch specifier data.  Expected format: \n${
          JSON.stringify(expected, null, 2)}`);
    }
  }

  exportClipboard() {
    navigator.clipboard.writeText(ExportDelegate.jsonStringify(this.exportData))
        .catch(error => console.error('unable to export to clipboard:', error));
  }

  exportFile() {
    const exportData = this.exportData;
    const timeStamp = ExportDelegate.getTimeStamp();
    const fileName =
        `omnibox_debug_export_${exportData.queryInputs.inputText}_${timeStamp}.json`;
    ExportDelegate.download(exportData, fileName);
  }

  private get exportData(): OmniboxExport {
    return {
      versionDetails: ExportDelegate.getVersionDetails(),
      queryInputs: this.omniboxInput_.queryInputs,
      displayInputs: this.omniboxInput_.displayInputs,
      // 20 entries will be about 7mb and 180k lines. That's small enough to
      // attach to bugs.chromium.org which has a 10mb limit.
      responsesHistory: this.omniboxOutput_.responsesHistory.slice(-20),
    };
  }

  private static download(object: Object, fileName: string) {
    const content = ExportDelegate.jsonStringify(object);
    const blob = new Blob([content], {type: 'application/json'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = fileName;
    a.click();
  }

  private static jsonStringify(data: Object): string {
    return JSON.stringify(data, (_, value) =>
        typeof value === 'bigint' ? value.toString() : value, 2);
  }

  /**
   * Returns a sortable timestamp string for use in filenames.
   */
  private static getTimeStamp(date: Date = new Date()): string {
    const iso = date.toISOString();
    return iso.replace(/:/g, '').split('.')[0]!;
  }

  private static getVersionDetails(): Record<string, string> {
    const loadTimeDataKeys = ['cl', 'command_line', 'executable_path',
      'language', 'official', 'os_type', 'profile_path', 'useragent',
      'version', 'version_processor_variation', 'version_modifier'];
    return Object.fromEntries(
        loadTimeDataKeys.map(key => {
          let valueOrError;
          try {
            valueOrError = loadTimeData.getValue(key);
          } catch (e) {
            valueOrError = (e as Error).toString();
          }
          return [key, valueOrError];
        }));
  }
}

/**
 * This is the minimum validation required to ensure no console errors.
 * Invalid importData that passes validation will be processed with a
 * best-attempt; e.g. if responses are missing 'relevance' values, then those
 * cells will be left blank.
 */
function validateImportData(importData: OmniboxExport): boolean {
  const EXPECTED_FORMAT = {
    queryInputs: {},
    displayInputs: {},
    responsesHistory: [[{combinedResults: [], resultsByProvider: []}]],
  };
  const INVALID_MESSAGE = `Invalid import format; expected \n${
      JSON.stringify(EXPECTED_FORMAT, null, 2)};\n`;

  if (!importData) {
    console.error(INVALID_MESSAGE + 'received non object.');
    return false;
  }

  if (!importData.queryInputs || !importData.displayInputs) {
    console.error(
        INVALID_MESSAGE +
        'import missing objects queryInputs and displayInputs.');
    return false;
  }

  if (!Array.isArray(importData.responsesHistory)) {
    console.error(INVALID_MESSAGE + 'import missing array responsesHistory.');
    return false;
  }

  if (!importData.responsesHistory.every(Array.isArray)) {
    console.error(INVALID_MESSAGE + 'responsesHistory contains non arrays.');
    return false;
  }

  if (!importData.responsesHistory.every(
      responses => responses.every(
          ({combinedResults, resultsByProvider}) =>
              Array.isArray(combinedResults) &&
              Array.isArray(resultsByProvider)))) {
    console.error(
        INVALID_MESSAGE +
        'responsesHistory items\' items missing combinedResults and ' +
        'resultsByProvider arrays.');
    return false;
  }

  return true;
}