blob: ca87f36f3df47745a2ddf484235e73ef71921a73 [file] [edit]
import { STEP_RUNNER_LOOKUP } from "./shared/step-runner.mjs";
import { MESSAGE_TYPE } from "./shared/benchmark.mjs";
import { WarmupSuite } from "./benchmark-runner.mjs";
function delay(ms) {
if (ms > 0)
return new Promise((resolve) => setTimeout(resolve, ms));
return undefined;
}
export class SuiteRunner {
#frame;
#page;
#params;
#suite;
#client;
#suiteResults;
#prepareTime = 0;
constructor(frame, page, params, suite, client, measuredValues) {
// FIXME: Create SuiteRunner-local measuredValues.
if (suite === WarmupSuite) {
// The warmup suite's results are intentionally not reported. Registering them in
// measuredValues would leave a 0-total entry, which zeroes the overall geomean.
this.#suiteResults = { tests: {}, prepare: 0, total: 0 };
} else {
this.#suiteResults = measuredValues.tests[suite.name];
if (!this.#suiteResults) {
this.#suiteResults = { tests: {}, prepare: 0, total: 0 };
measuredValues.tests[suite.name] = this.#suiteResults;
}
}
this.#frame = frame;
this.#page = page;
this.#client = client;
this.#suite = suite;
this.#params = params;
}
get frame() {
return this.#frame;
}
get page() {
return this.#page;
}
get params() {
return this.#params;
}
get suite() {
return this.#suite;
}
get client() {
return this.#client;
}
get suiteResults() {
return this.#suiteResults;
}
async run() {
await this._prepareSuite();
await this._runSuite();
}
async _prepareSuite() {
const suiteName = this.#suite.name;
const suitePrepareStartLabel = `suite-${suiteName}-prepare-start`;
const suitePrepareEndLabel = `suite-${suiteName}-prepare-end`;
performance.mark(suitePrepareStartLabel);
await this._loadFrame();
await this.#suite.prepare(this.#page);
performance.mark(suitePrepareEndLabel);
const entry = performance.measure(`suite-${suiteName}-prepare`, suitePrepareStartLabel, suitePrepareEndLabel);
this.#prepareTime = entry.duration;
}
async _runSuite() {
const suiteName = this.#suite.name;
const suiteStartLabel = `suite-${suiteName}-start`;
const suiteEndLabel = `suite-${suiteName}-end`;
await delay(this.#params.waitAfterSetup);
performance.mark(suiteStartLabel);
for (const step of this.#suite.tests) {
await this.#client?.willRunTest?.(this.#suite, step);
const stepRunnerType = this.#suite.type ?? this.params.useAsyncSteps ? "async" : "default";
const stepRunnerClass = STEP_RUNNER_LOOKUP[stepRunnerType];
const stepRunner = new stepRunnerClass(this.#frame, this.#page, this.#params, this.#suite, step, stepRunnerType);
let { syncTime, asyncTime } = await stepRunner.runStep();
this._recordTestResults(step, syncTime, asyncTime);
}
performance.mark(suiteEndLabel);
await delay(this.#params.waitAfterSuite);
performance.measure(`suite-${suiteName}`, suiteStartLabel, suiteEndLabel);
this._validateSuiteResults();
await this._updateClient();
}
_validateSuiteResults() {
// The warmup suite's results are intentionally not recorded, so it has no total.
if (this.#suite === WarmupSuite)
return;
// When the test is fast and the precision is low (for example with Firefox'
// privacy.resistFingerprinting preference), it's possible that the measured
// total duration for an entire is 0.
const suiteTotal = this.#suiteResults.total;
const suitePrepare = this.#suiteResults.prepare;
if (suiteTotal === 0)
throw new Error(`Got invalid 0-time total for suite ${this.#suite.name}: ${suiteTotal}`);
if (this.#params.measurePrepare && suitePrepare === 0)
throw new Error(`Got invalid 0-time prepare time for suite ${this.#suite.name}: ${suitePrepare}`);
}
async _loadFrame() {
return new Promise((resolve, reject) => {
const frame = this.#frame;
frame.onload = () => resolve();
frame.onerror = () => reject();
const url = new URL(this.#suite.url, document.baseURI);
for (const [key, value] of this.#params.toSearchParamsObject())
url.searchParams.append(key, value);
frame.src = url.href;
});
}
async _recordTestResults(step, syncTime, asyncTime) {
// Skip reporting updates for the warmup suite.
if (this.#suite === WarmupSuite)
return;
let total = syncTime + asyncTime;
this.#suiteResults.tests[step.name] = {
tests: { Sync: syncTime, Async: asyncTime },
total: total,
};
this.#suiteResults.prepare = this.#prepareTime;
this.#suiteResults.total += total;
}
async _updateClient(suite = this.#suite) {
await this.#client?.didFinishSuite?.(suite);
}
}
export class RemoteSuiteRunner extends SuiteRunner {
#appId;
#prepareTime;
get appId() {
return this.#appId;
}
set appId(id) {
this.#appId = id;
}
async run() {
this.postMessageCallbacks = new Map();
const handler = this._handlePostMessage.bind(this);
window.addEventListener("message", handler);
// FIXME: use this._suite in all SuiteRunner methods directly.
try {
await this._prepareSuite();
await this._runSuite();
} finally {
window.removeEventListener("message", handler);
}
}
async _prepareSuite() {
const suiteName = this.suite.name;
const suitePrepareStartLabel = `suite-${suiteName}-prepare-start`;
const suitePrepareEndLabel = `suite-${suiteName}-prepare-end`;
performance.mark(suitePrepareStartLabel);
// Wait for the app-ready message from the workload.
const appReadyPromise = this._subscribeOnce(MESSAGE_TYPE.appReady);
await this._loadFrame(this.suite);
// The suite will send out the app-ready message.
const response = await appReadyPromise;
this._initializeAppId(response.appId);
await this.suite.prepare?.(this.page);
performance.mark(suitePrepareEndLabel);
const entry = performance.measure(`suite-${suiteName}-prepare`, suitePrepareStartLabel, suitePrepareEndLabel);
this.#prepareTime = entry.duration;
}
async _runSuite() {
await delay(this.params.waitAfterSetup);
// Ask workload to run its own tests.
this._sendMessage(MESSAGE_TYPE.suiteStart, { name: this.suite.config?.name || "default" });
// Capture metrics from the completed tests.
const { result } = await this._subscribeOnce(MESSAGE_TYPE.suiteComplete);
await delay(this.params.waitAfterSuite);
this.suiteResults.tests = {
...this.suiteResults.tests,
...result.tests,
};
this.suiteResults.prepare = this.#prepareTime;
this.suiteResults.total = result.total;
this._validateSuiteResults();
await this._updateClient();
}
_sendMessage(type, payload) {
const message = {
appId: this.appId,
key: "benchmark-connector",
type: type,
payload: payload,
};
this.frame.contentWindow.postMessage(message, "*");
}
_handlePostMessage(event) {
const message = event.data;
const callback = this.postMessageCallbacks.get(message.type);
if (callback)
callback(event);
}
_startSubscription(type, callback) {
if (this.postMessageCallbacks.has(type))
throw new Error("Callback exists already");
this.postMessageCallbacks.set(type, callback);
}
_stopSubscription(type) {
if (!this.postMessageCallbacks.has(type))
throw new Error("Callback does not exist");
this.postMessageCallbacks.delete(type);
}
_subscribeOnce(type) {
return new Promise((resolve) => {
this._startSubscription(type, (e) => {
const message = e.data;
if (type !== MESSAGE_TYPE.appReady && message.appId !== this.appId)
throw new Error(`Got message for invalid app: ${message.appId} instead of ${this.appId}`);
this._stopSubscription(type);
resolve(message.payload);
});
});
}
_initializeAppId(appId) {
console.assert(!this.appId, `Cannot receive ${MESSAGE_TYPE.appReady} twice`);
console.assert(appId, `${MESSAGE_TYPE.appReady} sent no appId`);
this.appId = appId;
}
}
export const SUITE_RUNNER_LOOKUP = {
__proto__: null,
default: SuiteRunner,
async: SuiteRunner,
remote: RemoteSuiteRunner,
};