| // 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 {DisplayInputs, OmniboxInput, QueryInputs} from './omnibox_input.js'; |
| import type {OmniboxPageHandlerRemote, OmniboxResponse} from './omnibox_internals.mojom-webui.js'; |
| import {AutocompleteControllerType, OmniboxPageCallbackRouter, OmniboxPageHandler} from './omnibox_internals.mojom-webui.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_.handleAnswerIconImageData.addListener( |
| omniboxOutput.updateAnswerIconImage.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; |
| } |