chrome-devtools-frontend
Version:
Chrome DevTools UI
390 lines (340 loc) • 11.3 kB
text/typescript
// Copyright 2024 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as fs from 'fs';
import * as path from 'path';
import type {Browser, Page} from 'puppeteer-core';
import puppeteer from 'puppeteer-core';
import {hideBin} from 'yargs/helpers';
import yargs from 'yargs/yargs';
import type {
ExampleMetadata, ExecutedExample, IndividualPromptRequestResponse, Logs, RunResult, TestTarget} from '../types';
import {createTargetExecutor} from './targets/factory.ts';
import type {TargetExecutor, TargetPreparationResult} from './targets/interface.ts';
import {TraceDownloader} from './trace-downloader.ts';
const startTime = performance.now();
const numberFormatter = new Intl.NumberFormat('en-EN', {
maximumSignificantDigits: 3,
});
const acquiredDevToolsTargets = new WeakMap();
function formatElapsedTime() {
return `${numberFormatter.format((performance.now() - startTime) / 1000)}s`;
}
const globalUserArgs =
yargs(hideBin(process.argv))
.option('example-urls', {
string: true,
type: 'array',
demandOption: true,
})
.option('parallel', {
boolean: true,
default: true,
})
.option('times', {
describe: 'How many times do you want to run an example?',
number: true,
default: 1,
})
.option('label', {string: true, default: 'run'})
.option('include-follow-up', {
boolean: true,
default: false,
})
.option('test-target', {
describe: 'Which panel do you want to run the examples against?',
choices: ['elements', 'performance-main-thread', 'performance-insights', 'elements-multimodal', 'patching'] as
const,
demandOption: true,
})
.parseSync();
const exampleUrls: string[] = [];
const OUTPUT_DIR = path.resolve(import.meta.dirname, 'data');
for (const exampleUrl of globalUserArgs.exampleUrls) {
for (let i = 0; i < globalUserArgs.times; i++) {
const url = new URL(exampleUrl);
if (i !== 0) {
url.searchParams.set('iteration', `${i + 1}`);
}
exampleUrls.push(url.toString());
}
}
class Logger {
#logs: Logs = {};
#updateElapsedTimeInterval: NodeJS.Timeout|null = null;
constructor() {
this.#updateElapsedTimeInterval = setInterval(() => {
this.#updateElapsedTime();
}, 1000);
}
#updateElapsedTime() {
this.#logs['elapsedTime'] = {
index: 999,
text: `\nElapsed time: ${formatElapsedTime()}`,
};
this.#flushLogs();
}
#flushLogs() {
process.stdout.write('\x1Bc');
const values = Object.values(this.#logs);
const sortedValues = values.sort((val1, val2) => val1.index - val2.index);
for (const {text} of sortedValues) {
process.stdout.write(`${text}\n`);
}
}
formatError(err: Error): string {
const stack = typeof err.cause === 'object' && err.cause && 'stack' in err.cause ? err.cause.stack : '';
return `${err.stack}${err.cause ? `\n${stack}` : ''}`;
}
/**
* Logs a header message to the console.
* @param {string} text The header text to log.
*/
head(text: string) {
this.log('head', -1, `${text}\n`);
}
/**
* @param {string} id
* @param {number} index
* @param {string} text
*/
log(id: string, index: number, text: string) {
this.#updateElapsedTime();
this.#logs[id] = {index, text};
this.#flushLogs();
}
error(id: string, index: number, text: string) {
this.log(id, index, text);
}
destroy() {
if (this.#updateElapsedTimeInterval) {
clearInterval(this.#updateElapsedTimeInterval);
}
}
}
export class Example {
#url: string;
#browser: Browser;
#ready = false;
#page: Page|null = null;
#devtoolsPage: Page|null = null;
#logger: Logger;
#userArgs: typeof globalUserArgs;
#executor: TargetExecutor;
#traceDownloader: TraceDownloader;
#preparationResult: TargetPreparationResult|null = null;
constructor(
url: string, browser: Browser, testTarget: TestTarget, userArgs: typeof globalUserArgs, logger: Logger,
traceDownloader: TraceDownloader) {
this.#url = url;
this.#browser = browser;
this.#logger = logger;
this.#userArgs = userArgs;
this.#traceDownloader = traceDownloader;
this.#executor = createTargetExecutor(testTarget, this.#traceDownloader);
}
url(): string {
return this.#url;
}
id(): string {
return this.#url.split('/').pop()?.replace('.html', '') ?? 'unknown-id';
}
isReady() {
return this.#ready;
}
async prepare() {
this.log('Creating a page');
try {
const page = await this.#browser.newPage();
this.#page = page;
await page.goto(this.#url);
this.log(`Navigated to ${this.#url}`);
const devtoolsTarget = await this.#browser.waitForTarget(target => {
const isAcquiredBefore = acquiredDevToolsTargets.has(target);
return (target.type() === 'other' && target.url().startsWith('devtools://') && !isAcquiredBefore);
});
acquiredDevToolsTargets.set(devtoolsTarget, true);
const devtoolsPage = await devtoolsTarget.asPage();
this.#devtoolsPage = devtoolsPage;
this.log('[Info]: Got devtools page');
// Delegate to executor's prepare
this.#preparationResult = await this.#executor.prepare(
this.#url, this.#page, this.#devtoolsPage, (text: string) => this.log(text), this.#userArgs);
this.#ready = true;
} catch (err) {
this.#ready = false;
const errorMsg = err instanceof Error ? this.#logger.formatError(err) : String(err);
this.error(`Preparation failed.\n${errorMsg}`);
}
}
async execute(): Promise<ExecutedExample> {
if (!this.#devtoolsPage) {
throw new Error('Cannot execute without DevTools page.');
}
if (!this.#page) {
throw new Error('Cannot execute without target page');
}
if (!this.#preparationResult) {
throw new Error('Cannot execute without preparation result. Call prepare() first.');
}
const executionStartTime = performance.now();
try {
// Delegate to executor's execute
const results: IndividualPromptRequestResponse[] = await this.#executor.execute(
this.#devtoolsPage,
this.#preparationResult,
this.id(),
(text: string) => this.log(text),
);
await this.#page.close();
return {
results,
metadata: {exampleId: this.id(), explanation: this.#preparationResult.explanation},
};
} finally {
const elapsedTime = numberFormatter.format(
(performance.now() - executionStartTime) / 1000,
);
this.log(`Finished (${elapsedTime}s)`);
}
}
log(text: string) {
const indexOfExample = exampleUrls.indexOf(this.#url);
this.#logger.log(
this.id(),
indexOfExample,
`\x1b[33m[${indexOfExample + 1}/${exampleUrls.length}] ${this.id()}:\x1b[0m ${text}`,
);
}
error(text: string) {
const indexOfExample = exampleUrls.indexOf(this.#url);
this.#logger.error(
this.id(),
indexOfExample,
`\x1b[33m[${indexOfExample + 1}/${exampleUrls.length}] ${this.id()}:[0m [31m${text}[0m`,
);
}
}
const logger = new Logger();
async function runInParallel(examples: Example[]): Promise<RunResult> {
logger.head('Preparing examples...');
for (const example of examples) {
await example.prepare();
}
logger.head('Running examples...');
const allExampleResults: IndividualPromptRequestResponse[] = [];
const metadata: ExampleMetadata[] = [];
await Promise.all(
examples.filter(example => example.isReady()).map(async example => {
try {
const executedExample = await example.execute();
allExampleResults.push(...executedExample.results);
metadata.push(executedExample.metadata);
} catch (err) {
const errorMsg = err instanceof Error ? logger.formatError(err) : String(err);
example.error(
`There is an error, skipping it.\n${errorMsg}`,
);
}
}),
);
return {allExampleResults, metadata};
}
async function runSequentially(examples: Example[]): Promise<RunResult> {
const allExampleResults: IndividualPromptRequestResponse[] = [];
const metadata: ExampleMetadata[] = [];
logger.head('Running examples sequentially...');
for (const example of examples) {
await example.prepare();
if (!example.isReady()) {
continue;
}
try {
const executedExample = await example.execute();
allExampleResults.push(...executedExample.results);
metadata.push(executedExample.metadata);
} catch (err) {
const errorMsg = err instanceof Error ? logger.formatError(err) : String(err);
example.error(
`There is an error, skipping it.\n${errorMsg}`,
);
}
}
return {allExampleResults, metadata};
}
async function main() {
logger.head('Connecting to the browser...');
const browser = await puppeteer.connect({
browserURL: 'http://127.0.0.1:9222',
defaultViewport: null,
targetFilter: target => {
if (target.url().startsWith('chrome-extension://')) {
return false;
}
return true;
},
});
logger.head('Browser connection is ready...');
logger.head(
'Getting browser pages... (If stuck in here, please close all the tabs in the connected Chrome manually.)',
);
try {
for (const page of await browser.pages()) {
if (page.url() === 'about:blank') {
continue;
}
await page.close();
}
} catch (err) {
const errorMsg = err instanceof Error ? logger.formatError(err) : String(err);
logger.head(
`There was an error closing pages\n${errorMsg}`,
);
}
logger.head('Preparing examples...');
const traceDownloader = new TraceDownloader();
const examples = exampleUrls.map(
exampleUrl =>
new Example(exampleUrl, browser, globalUserArgs.testTarget, globalUserArgs, logger, traceDownloader));
let allExampleResults: IndividualPromptRequestResponse[] = [];
let metadata: ExampleMetadata[] = [];
if (globalUserArgs.parallel) {
({allExampleResults, metadata} = await runInParallel(examples));
} else {
({allExampleResults, metadata} = await runSequentially(examples));
}
await browser.disconnect();
let score = 0;
{
let scoreSum = 0;
let count = 0;
for (const example of allExampleResults) {
if (example.score !== undefined) {
scoreSum += example.score;
count++;
}
}
if (count > 0) {
score = scoreSum / count;
}
}
const output = {
score,
metadata,
examples: allExampleResults,
};
const dateSuffix = new Date().toISOString().slice(0, 19);
const outputPath = path.resolve(
OUTPUT_DIR,
`${globalUserArgs.label}-${dateSuffix}.json`,
);
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR);
}
fs.writeFileSync(outputPath, JSON.stringify(output, null, 2));
console.info(
`\n[Info]: Finished exporting results to ${outputPath}, it took ${formatElapsedTime()}`,
);
logger.destroy();
}
void main();