Source code

Revision control

Copy as Markdown

Other Tools

const variants = new Set((new URLSearchParams(location.search)).keys());
export function hasVariant(name) {
return variants.has(name);
}
export class Recorder {
#events = [];
#errors = [];
#navigationAPI;
#domExceptionConstructor;
#location;
#skipCurrentChange;
#finalExpectedEvent;
#finalExpectedEventCount;
#currentFinalEventCount = 0;
#readyToAssertResolve;
#readyToAssertPromise = new Promise(resolve => { this.#readyToAssertResolve = resolve; });
constructor({ window = self, skipCurrentChange = false, finalExpectedEvent, finalExpectedEventCount = 1 }) {
assert_equals(typeof finalExpectedEvent, "string", "Must pass a string for finalExpectedEvent");
this.#navigationAPI = window.navigation;
this.#domExceptionConstructor = window.DOMException;
this.#location = window.location;
this.#skipCurrentChange = skipCurrentChange;
this.#finalExpectedEvent = finalExpectedEvent;
this.#finalExpectedEventCount = finalExpectedEventCount;
}
setUpNavigationAPIListeners() {
this.#navigationAPI.addEventListener("navigate", e => {
this.record("navigate");
e.signal.addEventListener("abort", () => {
this.recordWithError("AbortSignal abort", e.signal.reason);
});
});
this.#navigationAPI.addEventListener("navigateerror", e => {
this.recordWithError("navigateerror", e.error);
this.#navigationAPI.transition?.finished.then(
() => this.record("transition.finished fulfilled"),
err => this.recordWithError("transition.finished rejected", err)
);
});
this.#navigationAPI.addEventListener("navigatesuccess", () => {
this.record("navigatesuccess");
this.#navigationAPI.transition?.finished.then(
() => this.record("transition.finished fulfilled"),
err => this.recordWithError("transition.finished rejected", err)
);
});
if (!this.#skipCurrentChange) {
this.#navigationAPI.addEventListener("currententrychange", () => this.record("currententrychange"));
}
}
setUpResultListeners(result, suffix = "") {
result.committed.then(
() => this.record(`committed fulfilled${suffix}`),
err => this.recordWithError(`committed rejected${suffix}`, err)
);
result.finished.then(
() => this.record(`finished fulfilled${suffix}`),
err => this.recordWithError(`finished rejected${suffix}`, err)
);
}
record(name) {
const transitionProps = this.#navigationAPI.transition === null ? null : {
from: this.#navigationAPI.transition.from,
navigationType: this.#navigationAPI.transition.navigationType
};
this.#events.push({ name, location: this.#location.hash, transitionProps });
if (name === this.#finalExpectedEvent && ++this.#currentFinalEventCount === this.#finalExpectedEventCount) {
this.#readyToAssertResolve();
}
}
recordWithError(name, errorObject) {
this.record(name);
this.#errors.push({ name, errorObject });
}
get readyToAssert() {
return this.#readyToAssertPromise;
}
// Usage:
// recorder.assert([
// /* event name, location.hash value, navigation.transition properties */
// ["currententrychange", "", null],
// ["committed fulfilled", "#1", { from, navigationType }],
// ...
// ]);
//
// The array format is to avoid repitition at the call site, but I recommend
// you document it like above.
//
// This will automatically also assert that any error objects recorded are
// equal to each other. Use the other assert functions to check the actual
// contents of the error objects.
assert(expectedAsArray) {
if (this.#skipCurrentChange) {
expectedAsArray = expectedAsArray.filter(expected => expected[0] !== "currententrychange");
}
// Doing this up front gives nicer error messages because
// assert_array_equals is nice.
const recordedNames = this.#events.map(e => e.name);
const expectedNames = expectedAsArray.map(e => e[0]);
assert_array_equals(recordedNames, expectedNames);
for (let i = 0; i < expectedAsArray.length; ++i) {
const recorded = this.#events[i];
const expected = expectedAsArray[i];
assert_equals(
recorded.location,
expected[1],
`event ${i} (${recorded.name}): location.hash value`
);
if (expected[2] === null) {
assert_equals(
recorded.transitionProps,
null,
`event ${i} (${recorded.name}): navigation.transition expected to be null`
);
} else {
assert_not_equals(
recorded.transitionProps,
null,
`event ${i} (${recorded.name}): navigation.transition expected not to be null`
);
assert_equals(
recorded.transitionProps.from,
expected[2].from,
`event ${i} (${recorded.name}): navigation.transition.from`
);
assert_equals(
recorded.transitionProps.navigationType,
expected[2].navigationType,
`event ${i} (${recorded.name}): navigation.transition.navigationType`
);
}
}
if (this.#errors.length > 1) {
for (let i = 1; i < this.#errors.length; ++i) {
assert_equals(
this.#errors[i].errorObject,
this.#errors[0].errorObject,
`error objects must match: error object for ${this.#errors[i].name} did not match the one for ${this.#errors[0].name}`
);
}
}
}
assertErrorsAreAbortErrors() {
assert_greater_than(
this.#errors.length,
0,
"No errors were recorded but assertErrorsAreAbortErrors() was called"
);
// Assume assert() has been called so all error objects are the same.
const { errorObject } = this.#errors[0];
assert_throws_dom("AbortError", this.#domExceptionConstructor, () => { throw errorObject; });
}
assertErrorsAre(expectedErrorObject) {
assert_greater_than(
this.#errors.length,
0,
"No errors were recorded but assertErrorsAre() was called"
);
// Assume assert() has been called so all error objects are the same.
const { errorObject } = this.#errors[0];
assert_equals(errorObject, expectedErrorObject);
}
}