Source code
Revision control
Copy as Markdown
Other Tools
/* 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
/* 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:
*
* @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.
* so it is licence MIT and:
* 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");
}
/** ------ Utility functions and definitions for raising POSIX signals ------ */
ChromeUtils.defineESModuleGetters(this, {
Downloads: "resource://gre/modules/Downloads.sys.mjs",
});
// Find the path for a profile written to disk because of signals
async function getFullProfilePath(pid) {
// Initially look for "MOZ_UPLOAD_DIR". If this exists, firefox will write the
// profile file here, so this is where we need to look.
let path = Services.env.get("MOZ_UPLOAD_DIR");
if (!path) {
path = await Downloads.getSystemDownloadsDirectory();
}
return PathUtils.join(path, `profile_0_${pid}.json`);
}
// Hardcode the constants SIGUSR1 and SIGUSR2.
// This is an absolutely terrible idea, as they are implementation defined!
// However, it turns out that for 99% of the platforms we care about, and for
// 99.999% of the platforms we test, these constants are, well, constant.
// Additionally, these constants are only for _testing_ the signal handling
// feature - the actual feature relies on platform specific definitions. This
// may cause a mismatch if we test on on, say, a gnu hurd kernel, or on a
// linux kernel running on sparc, but the feature will not break - only
// the testing.
const SIGUSR1 = Services.appinfo.OS === "Darwin" ? 30 : 10;
const SIGUSR2 = Services.appinfo.OS === "Darwin" ? 31 : 12;
// Derived heavily from equivalent sandbox testing code. For more details see:
function raiseSignal(pid, sig) {
const { ctypes } = ChromeUtils.importESModule(
"resource://gre/modules/ctypes.sys.mjs"
);
// Derived from functionality in js/src/devtools/rootAnalysis/utility.js
function openLibrary(names) {
for (const name of names) {
try {
return ctypes.open(name);
} catch (e) {}
}
return undefined;
}
try {
const libc = openLibrary([
"libc.so.6",
"libc.so",
"libc.dylib",
"libSystem.B.dylib",
]);
if (!libc) {
info("Failed to open any libc shared object");
return { ok: false };
}
// This choice of typing for `pid` is complex, and brittle, as it's platform
// dependent. Getting it wrong can result in incoreect generation/calling of
// the `kill` function. Unfortunately, as it's defined as `pid_t` in a
// header, we can't easily get access to it. For now, we just use an
// integer, and hope that the system int size aligns with the `pid_t` size.
const kill = libc.declare(
"kill",
ctypes.default_abi,
ctypes.int, // return value
ctypes.int32_t, // pid
ctypes.int // sig
);
let kres = kill(pid, sig);
if (kres != 0) {
info(`Kill returned a non-zero result ${kres}.`);
return { ok: false };
}
libc.close();
} catch (e) {
info(`Exception ${e} thrown while trying to call kill`);
return { ok: false };
}
return { ok: true };
}
/** ------ 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 ------ */