1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418
|
// 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;
}
|