591 lines
18 KiB
JavaScript
591 lines
18 KiB
JavaScript
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||
|
||
/* globals Assert */
|
||
/* globals info */
|
||
|
||
/**
|
||
* This file contains utilities that can be shared between xpcshell tests and mochitests.
|
||
*/
|
||
|
||
// The marker phases.
|
||
const INSTANT = 0;
|
||
const INTERVAL = 1;
|
||
const INTERVAL_START = 2;
|
||
const INTERVAL_END = 3;
|
||
|
||
// This Services declaration may shadow another from head.js, so define it as
|
||
// a var rather than a const.
|
||
|
||
const defaultSettings = {
|
||
entries: 8 * 1024 * 1024, // 8M entries = 64MB
|
||
interval: 1, // ms
|
||
features: [],
|
||
threads: ["GeckoMain"],
|
||
};
|
||
|
||
// Effectively `async`: Start the profiler and return the `startProfiler`
|
||
// promise that will get resolved when all child process have started their own
|
||
// profiler.
|
||
async function startProfiler(callersSettings) {
|
||
if (Services.profiler.IsActive()) {
|
||
Assert.ok(
|
||
Services.env.exists("MOZ_PROFILER_STARTUP"),
|
||
"The profiler is active at the begining of the test, " +
|
||
"the MOZ_PROFILER_STARTUP environment variable should be set."
|
||
);
|
||
if (Services.env.exists("MOZ_PROFILER_STARTUP")) {
|
||
// If the startup profiling environment variable exists, it is likely
|
||
// that tests are being profiled.
|
||
// Stop the profiler before starting profiler tests.
|
||
info(
|
||
"This test starts and stops the profiler and is not compatible " +
|
||
"with the use of MOZ_PROFILER_STARTUP. " +
|
||
"Stopping the profiler before starting the test."
|
||
);
|
||
await Services.profiler.StopProfiler();
|
||
} else {
|
||
throw new Error(
|
||
"The profiler must not be active before starting it in a test."
|
||
);
|
||
}
|
||
}
|
||
const settings = Object.assign({}, defaultSettings, callersSettings);
|
||
return Services.profiler.StartProfiler(
|
||
settings.entries,
|
||
settings.interval,
|
||
settings.features,
|
||
settings.threads,
|
||
0,
|
||
settings.duration
|
||
);
|
||
}
|
||
|
||
function startProfilerForMarkerTests() {
|
||
return startProfiler({
|
||
features: ["nostacksampling", "js"],
|
||
threads: ["GeckoMain", "DOM Worker"],
|
||
});
|
||
}
|
||
|
||
/**
|
||
* This is a helper function be able to run `await wait(500)`. Unfortunately
|
||
* this is needed as the act of collecting functions relies on the periodic
|
||
* sampling of the threads. See:
|
||
* https://bugzilla.mozilla.org/show_bug.cgi?id=1529053
|
||
*
|
||
* @param {number} time
|
||
* @returns {Promise}
|
||
*/
|
||
function wait(time) {
|
||
return new Promise(resolve => {
|
||
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
|
||
setTimeout(resolve, time);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Get the payloads of a type recursively, including from all subprocesses.
|
||
*
|
||
* @param {Object} profile The gecko profile.
|
||
* @param {string} type The marker payload type, e.g. "DiskIO".
|
||
* @param {Array} payloadTarget The recursive list of payloads.
|
||
* @return {Array} The final payloads.
|
||
*/
|
||
function getPayloadsOfTypeFromAllThreads(profile, type, payloadTarget = []) {
|
||
for (const { markers } of profile.threads) {
|
||
for (const markerTuple of markers.data) {
|
||
const payload = markerTuple[markers.schema.data];
|
||
if (payload && payload.type === type) {
|
||
payloadTarget.push(payload);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const subProcess of profile.processes) {
|
||
getPayloadsOfTypeFromAllThreads(subProcess, type, payloadTarget);
|
||
}
|
||
|
||
return payloadTarget;
|
||
}
|
||
|
||
/**
|
||
* Get the payloads of a type from a single thread.
|
||
*
|
||
* @param {Object} thread The thread from a profile.
|
||
* @param {string} type The marker payload type, e.g. "DiskIO".
|
||
* @return {Array} The payloads.
|
||
*/
|
||
function getPayloadsOfType(thread, type) {
|
||
const { markers } = thread;
|
||
const results = [];
|
||
for (const markerTuple of markers.data) {
|
||
const payload = markerTuple[markers.schema.data];
|
||
if (payload && payload.type === type) {
|
||
results.push(payload);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Applies the marker schema to create individual objects for each marker
|
||
*
|
||
* @param {Object} thread The thread from a profile.
|
||
* @return {InflatedMarker[]} The markers.
|
||
*/
|
||
function getInflatedMarkerData(thread) {
|
||
const { markers, stringTable } = thread;
|
||
return markers.data.map(markerTuple => {
|
||
const marker = {};
|
||
for (const [key, tupleIndex] of Object.entries(markers.schema)) {
|
||
marker[key] = markerTuple[tupleIndex];
|
||
if (key === "name") {
|
||
// Use the string from the string table.
|
||
marker[key] = stringTable[marker[key]];
|
||
}
|
||
}
|
||
return marker;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Applies the marker schema to create individual objects for each marker, then
|
||
* keeps only the network markers that match the profiler tests.
|
||
*
|
||
* @param {Object} thread The thread from a profile.
|
||
* @return {InflatedMarker[]} The filtered network markers.
|
||
*/
|
||
function getInflatedNetworkMarkers(thread) {
|
||
const markers = getInflatedMarkerData(thread);
|
||
return markers.filter(
|
||
m =>
|
||
m.data &&
|
||
m.data.type === "Network" &&
|
||
// We filter out network markers that aren't related to the test, to
|
||
// avoid intermittents.
|
||
m.data.URI.includes("/tools/profiler/")
|
||
);
|
||
}
|
||
|
||
/**
|
||
* From a list of network markers, this returns pairs of start/stop markers.
|
||
* If a stop marker can't be found for a start marker, this will return an array
|
||
* of only 1 element.
|
||
*
|
||
* @param {InflatedMarker[]} networkMarkers Network markers
|
||
* @return {InflatedMarker[][]} Pairs of network markers
|
||
*/
|
||
function getPairsOfNetworkMarkers(allNetworkMarkers) {
|
||
// For each 'start' marker we want to find the next 'stop' or 'redirect'
|
||
// marker with the same id.
|
||
const result = [];
|
||
const mapOfStartMarkers = new Map(); // marker id -> id in result array
|
||
for (const marker of allNetworkMarkers) {
|
||
const { data } = marker;
|
||
if (data.status === "STATUS_START") {
|
||
if (mapOfStartMarkers.has(data.id)) {
|
||
const previousMarker = result[mapOfStartMarkers.get(data.id)][0];
|
||
Assert.ok(
|
||
false,
|
||
`We found 2 start markers with the same id ${data.id}, without end marker in-between.` +
|
||
`The first marker has URI ${previousMarker.data.URI}, the second marker has URI ${data.URI}.` +
|
||
` This should not happen.`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
mapOfStartMarkers.set(data.id, result.length);
|
||
result.push([marker]);
|
||
} else {
|
||
// STOP or REDIRECT
|
||
if (!mapOfStartMarkers.has(data.id)) {
|
||
Assert.ok(
|
||
false,
|
||
`We found an end marker without a start marker (id: ${data.id}, URI: ${data.URI}). This should not happen.`
|
||
);
|
||
continue;
|
||
}
|
||
result[mapOfStartMarkers.get(data.id)].push(marker);
|
||
mapOfStartMarkers.delete(data.id);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* It can be helpful to force the profiler to collect a JavaScript sample. This
|
||
* function spins on a while loop until at least one more sample is collected.
|
||
*
|
||
* @return {number} The index of the collected sample.
|
||
*/
|
||
function captureAtLeastOneJsSample() {
|
||
function getProfileSampleCount() {
|
||
const profile = Services.profiler.getProfileData();
|
||
return profile.threads[0].samples.data.length;
|
||
}
|
||
|
||
const sampleCount = getProfileSampleCount();
|
||
// Create an infinite loop until a sample has been collected.
|
||
while (true) {
|
||
if (sampleCount < getProfileSampleCount()) {
|
||
return sampleCount;
|
||
}
|
||
}
|
||
}
|
||
|
||
function isJSONWhitespace(c) {
|
||
return ["\n", "\r", " ", "\t"].includes(c);
|
||
}
|
||
|
||
function verifyJSONStringIsCompact(s) {
|
||
const stateData = 0;
|
||
const stateString = 1;
|
||
const stateEscapedChar = 2;
|
||
let state = stateData;
|
||
for (let i = 0; i < s.length; ++i) {
|
||
let c = s[i];
|
||
switch (state) {
|
||
case stateData:
|
||
if (isJSONWhitespace(c)) {
|
||
Assert.ok(
|
||
false,
|
||
`"Unexpected JSON whitespace at index ${i} in profile: <<<${s}>>>"`
|
||
);
|
||
return;
|
||
}
|
||
if (c == '"') {
|
||
state = stateString;
|
||
}
|
||
break;
|
||
case stateString:
|
||
if (c == '"') {
|
||
state = stateData;
|
||
} else if (c == "\\") {
|
||
state = stateEscapedChar;
|
||
}
|
||
break;
|
||
case stateEscapedChar:
|
||
state = stateString;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* This function pauses the profiler before getting the profile. Then after
|
||
* getting the data, the profiler is stopped, and all profiler data is removed.
|
||
* @returns {Promise<Profile>}
|
||
*/
|
||
async function stopNowAndGetProfile() {
|
||
// Don't await the pause, because each process will handle it before it
|
||
// receives the following `getProfileDataAsArrayBuffer()`.
|
||
Services.profiler.Pause();
|
||
|
||
const profileArrayBuffer =
|
||
await Services.profiler.getProfileDataAsArrayBuffer();
|
||
await Services.profiler.StopProfiler();
|
||
|
||
const profileUint8Array = new Uint8Array(profileArrayBuffer);
|
||
const textDecoder = new TextDecoder("utf-8", { fatal: true });
|
||
const profileString = textDecoder.decode(profileUint8Array);
|
||
verifyJSONStringIsCompact(profileString);
|
||
|
||
return JSON.parse(profileString);
|
||
}
|
||
|
||
/**
|
||
* This function ensures there's at least one sample, then pauses the profiler
|
||
* before getting the profile. Then after getting the data, the profiler is
|
||
* stopped, and all profiler data is removed.
|
||
* @returns {Promise<Profile>}
|
||
*/
|
||
async function waitSamplingAndStopAndGetProfile() {
|
||
await Services.profiler.waitOnePeriodicSampling();
|
||
return stopNowAndGetProfile();
|
||
}
|
||
|
||
/**
|
||
* Verifies that a marker is an interval marker.
|
||
*
|
||
* @param {InflatedMarker} marker
|
||
* @returns {boolean}
|
||
*/
|
||
function isIntervalMarker(inflatedMarker) {
|
||
return (
|
||
inflatedMarker.phase === 1 &&
|
||
typeof inflatedMarker.startTime === "number" &&
|
||
typeof inflatedMarker.endTime === "number"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param {Profile} profile
|
||
* @returns {Thread[]}
|
||
*/
|
||
function getThreads(profile) {
|
||
const threads = [];
|
||
|
||
function getThreadsRecursive(process) {
|
||
for (const thread of process.threads) {
|
||
threads.push(thread);
|
||
}
|
||
for (const subprocess of process.processes) {
|
||
getThreadsRecursive(subprocess);
|
||
}
|
||
}
|
||
|
||
getThreadsRecursive(profile);
|
||
return threads;
|
||
}
|
||
|
||
/**
|
||
* Find a specific marker schema from any process of a profile.
|
||
*
|
||
* @param {Profile} profile
|
||
* @param {string} name
|
||
* @returns {MarkerSchema}
|
||
*/
|
||
function getSchema(profile, name) {
|
||
{
|
||
const schema = profile.meta.markerSchema.find(s => s.name === name);
|
||
if (schema) {
|
||
return schema;
|
||
}
|
||
}
|
||
for (const subprocess of profile.processes) {
|
||
const schema = subprocess.meta.markerSchema.find(s => s.name === name);
|
||
if (schema) {
|
||
return schema;
|
||
}
|
||
}
|
||
console.error("Parent process schema", profile.meta.markerSchema);
|
||
for (const subprocess of profile.processes) {
|
||
console.error("Child process schema", subprocess.meta.markerSchema);
|
||
}
|
||
throw new Error(`Could not find a schema for "${name}".`);
|
||
}
|
||
|
||
/**
|
||
* This escapes all characters that have a special meaning in RegExps.
|
||
* This was stolen from https://github.com/sindresorhus/escape-string-regexp and
|
||
* so it is licence MIT and:
|
||
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com).
|
||
* See the full license in https://raw.githubusercontent.com/sindresorhus/escape-string-regexp/main/license.
|
||
* @param {string} string The string to be escaped
|
||
* @returns {string} The result
|
||
*/
|
||
function escapeStringRegexp(string) {
|
||
if (typeof string !== "string") {
|
||
throw new TypeError("Expected a string");
|
||
}
|
||
|
||
// Escape characters with special meaning either inside or outside character
|
||
// sets. Use a simple backslash escape when it’s always valid, and a `\xnn`
|
||
// escape when the simpler form would be disallowed by Unicode patterns’
|
||
// stricter grammar.
|
||
return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
|
||
}
|
||
|
||
/** ------ Assertions helper ------ */
|
||
/**
|
||
* This assert helper function makes it easy to check a lot of properties in an
|
||
* object. We augment Assert.sys.mjs to make it easier to use.
|
||
*/
|
||
Object.assign(Assert, {
|
||
/*
|
||
* It checks if the properties on the right are all present in the object on
|
||
* the left. Note that the object might still have other properties (see
|
||
* objectContainsOnly below if you want the stricter form).
|
||
*
|
||
* The basic form does basic equality on each expected property:
|
||
*
|
||
* Assert.objectContains(fixture, {
|
||
* foo: "foo",
|
||
* bar: 1,
|
||
* baz: true,
|
||
* });
|
||
*
|
||
* But it also has a more powerful form with expectations. The available
|
||
* expectations are:
|
||
* - any(): this only checks for the existence of the property, not its value
|
||
* - number(), string(), boolean(), bigint(), function(), symbol(), object():
|
||
* this checks if the value is of this type
|
||
* - objectContains(expected): this applies Assert.objectContains()
|
||
* recursively on this property.
|
||
* - stringContains(needle): this checks if the expected value is included in
|
||
* the property value.
|
||
* - stringMatches(regexp): this checks if the property value matches this
|
||
* regexp. The regexp can be passed as a string, to be dynamically built.
|
||
*
|
||
* example:
|
||
*
|
||
* Assert.objectContains(fixture, {
|
||
* name: Expect.stringMatches(`Load \\d+:.*${url}`),
|
||
* data: Expect.objectContains({
|
||
* status: "STATUS_STOP",
|
||
* URI: Expect.stringContains("https://"),
|
||
* requestMethod: "GET",
|
||
* contentType: Expect.string(),
|
||
* startTime: Expect.number(),
|
||
* cached: Expect.boolean(),
|
||
* }),
|
||
* });
|
||
*
|
||
* Each expectation will translate into one or more Assert call. Therefore if
|
||
* one expectation fails, this will be clearly visible in the test output.
|
||
*
|
||
* Expectations can also be normal functions, for example:
|
||
*
|
||
* Assert.objectContains(fixture, {
|
||
* number: value => Assert.greater(value, 5)
|
||
* });
|
||
*
|
||
* Note that you'll need to use Assert inside this function.
|
||
*/
|
||
objectContains(object, expectedProperties) {
|
||
// Basic tests: we don't want to run other assertions if these tests fail.
|
||
if (typeof object !== "object") {
|
||
this.ok(
|
||
false,
|
||
`The first parameter should be an object, but found: ${object}.`
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (typeof expectedProperties !== "object") {
|
||
this.ok(
|
||
false,
|
||
`The second parameter should be an object, but found: ${expectedProperties}.`
|
||
);
|
||
return;
|
||
}
|
||
|
||
for (const key of Object.keys(expectedProperties)) {
|
||
const expected = expectedProperties[key];
|
||
if (!(key in object)) {
|
||
this.report(
|
||
true,
|
||
object,
|
||
expectedProperties,
|
||
`The object should contain the property "${key}", but it's missing.`
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (typeof expected === "function") {
|
||
// This is a function, so let's call it.
|
||
expected(
|
||
object[key],
|
||
`The object should contain the property "${key}" with an expected value and type.`
|
||
);
|
||
} else {
|
||
// Otherwise, we check for equality.
|
||
this.equal(
|
||
object[key],
|
||
expectedProperties[key],
|
||
`The object should contain the property "${key}" with an expected value.`
|
||
);
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* This is very similar to the previous `objectContains`, but this also looks
|
||
* at the number of the objects' properties. Thus this will fail if the
|
||
* objects don't have the same properties exactly.
|
||
*/
|
||
objectContainsOnly(object, expectedProperties) {
|
||
// Basic tests: we don't want to run other assertions if these tests fail.
|
||
if (typeof object !== "object") {
|
||
this.ok(
|
||
false,
|
||
`The first parameter should be an object but found: ${object}.`
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (typeof expectedProperties !== "object") {
|
||
this.ok(
|
||
false,
|
||
`The second parameter should be an object but found: ${expectedProperties}.`
|
||
);
|
||
return;
|
||
}
|
||
|
||
// In objectContainsOnly, we specifically want to check if all properties
|
||
// from the fixture object are expected.
|
||
// We'll be failing a test only for the specific properties that weren't
|
||
// expected, and only fail with one message, so that the test outputs aren't
|
||
// spammed.
|
||
const extraProperties = [];
|
||
for (const fixtureKey of Object.keys(object)) {
|
||
if (!(fixtureKey in expectedProperties)) {
|
||
extraProperties.push(fixtureKey);
|
||
}
|
||
}
|
||
|
||
if (extraProperties.length) {
|
||
// Some extra properties have been found.
|
||
this.report(
|
||
true,
|
||
object,
|
||
expectedProperties,
|
||
`These properties are present, but shouldn't: "${extraProperties.join(
|
||
'", "'
|
||
)}".`
|
||
);
|
||
}
|
||
|
||
// Now, let's carry on the rest of our work.
|
||
this.objectContains(object, expectedProperties);
|
||
},
|
||
});
|
||
|
||
const Expect = {
|
||
any:
|
||
() =>
|
||
() => {} /* We don't check anything more than the presence of this property. */,
|
||
};
|
||
|
||
/* These functions are part of the Assert object, and we want to reuse them. */
|
||
[
|
||
"stringContains",
|
||
"stringMatches",
|
||
"objectContains",
|
||
"objectContainsOnly",
|
||
].forEach(
|
||
assertChecker =>
|
||
(Expect[assertChecker] =
|
||
expected =>
|
||
(actual, ...moreArgs) =>
|
||
Assert[assertChecker](actual, expected, ...moreArgs))
|
||
);
|
||
|
||
/* These functions will only check for the type. */
|
||
[
|
||
"number",
|
||
"string",
|
||
"boolean",
|
||
"bigint",
|
||
"symbol",
|
||
"object",
|
||
"function",
|
||
].forEach(type => (Expect[type] = makeTypeChecker(type)));
|
||
|
||
function makeTypeChecker(type) {
|
||
return (...unexpectedArgs) => {
|
||
if (unexpectedArgs.length) {
|
||
throw new Error(
|
||
"Type checkers expectations aren't expecting any argument."
|
||
);
|
||
}
|
||
return (actual, message) => {
|
||
const isCorrect = typeof actual === type;
|
||
Assert.report(!isCorrect, actual, type, message, "has type");
|
||
};
|
||
};
|
||
}
|
||
/* ------ End of assertion helper ------ */
|