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); } }