helpers.mjs (6809B)
1 const variants = new Set((new URLSearchParams(location.search)).keys()); 2 3 export function hasVariant(name) { 4 return variants.has(name); 5 } 6 7 export class Recorder { 8 #events = []; 9 #errors = []; 10 #navigationAPI; 11 #domExceptionConstructor; 12 #location; 13 #skipCurrentChange; 14 #finalExpectedEvent; 15 #finalExpectedEventCount; 16 #currentFinalEventCount = 0; 17 18 #readyToAssertResolve; 19 #readyToAssertPromise = new Promise(resolve => { this.#readyToAssertResolve = resolve; }); 20 21 constructor({ window = self, skipCurrentChange = false, finalExpectedEvent, finalExpectedEventCount = 1 }) { 22 assert_equals(typeof finalExpectedEvent, "string", "Must pass a string for finalExpectedEvent"); 23 24 this.#navigationAPI = window.navigation; 25 this.#domExceptionConstructor = window.DOMException; 26 this.#location = window.location; 27 28 this.#skipCurrentChange = skipCurrentChange; 29 this.#finalExpectedEvent = finalExpectedEvent; 30 this.#finalExpectedEventCount = finalExpectedEventCount; 31 } 32 33 setUpNavigationAPIListeners() { 34 this.#navigationAPI.addEventListener("navigate", e => { 35 this.record("navigate"); 36 37 e.signal.addEventListener("abort", () => { 38 this.recordWithError("AbortSignal abort", e.signal.reason); 39 }); 40 }); 41 42 this.#navigationAPI.addEventListener("navigateerror", e => { 43 this.recordWithError("navigateerror", e.error); 44 45 this.#navigationAPI.transition?.finished.then( 46 () => this.record("transition.finished fulfilled"), 47 err => this.recordWithError("transition.finished rejected", err) 48 ); 49 }); 50 51 this.#navigationAPI.addEventListener("navigatesuccess", () => { 52 this.record("navigatesuccess"); 53 54 this.#navigationAPI.transition?.finished.then( 55 () => this.record("transition.finished fulfilled"), 56 err => this.recordWithError("transition.finished rejected", err) 57 ); 58 }); 59 60 if (!this.#skipCurrentChange) { 61 this.#navigationAPI.addEventListener("currententrychange", () => this.record("currententrychange")); 62 } 63 } 64 65 setUpResultListeners(result, suffix = "") { 66 67 result.committed.then( 68 () => this.record(`committed fulfilled${suffix}`), 69 err => this.recordWithError(`committed rejected${suffix}`, err) 70 ); 71 72 result.finished.then( 73 () => this.record(`finished fulfilled${suffix}`), 74 err => this.recordWithError(`finished rejected${suffix}`, err) 75 ); 76 77 this.#navigationAPI.transition?.committed?.then( 78 () => this.record(`transition.committed fulfilled${suffix}`), 79 err => this.recordWithError(`transition.committed rejected${suffix}`, err) 80 ); 81 } 82 83 record(name) { 84 const transitionProps = this.#navigationAPI.transition === null ? null : { 85 from: this.#navigationAPI.transition.from, 86 navigationType: this.#navigationAPI.transition.navigationType 87 }; 88 89 this.#events.push({ name, location: this.#location.hash, transitionProps }); 90 91 if (name === this.#finalExpectedEvent && ++this.#currentFinalEventCount === this.#finalExpectedEventCount) { 92 this.#readyToAssertResolve(); 93 } 94 } 95 96 recordWithError(name, errorObject) { 97 this.record(name); 98 this.#errors.push({ name, errorObject }); 99 } 100 101 get readyToAssert() { 102 return this.#readyToAssertPromise; 103 } 104 105 // Usage: 106 // recorder.assert([ 107 // /* event name, location.hash value, navigation.transition properties */ 108 // ["currententrychange", "", null], 109 // ["committed fulfilled", "#1", { from, navigationType }], 110 // ... 111 // ]); 112 // 113 // The array format is to avoid repitition at the call site, but I recommend 114 // you document it like above. 115 // 116 // This will automatically also assert that any error objects recorded are 117 // equal to each other. Use the other assert functions to check the actual 118 // contents of the error objects. 119 assert(expectedAsArray) { 120 if (this.#skipCurrentChange) { 121 expectedAsArray = expectedAsArray.filter(expected => expected[0] !== "currententrychange"); 122 } 123 // TODO: Remove once https://github.com/whatwg/html/pull/10919 is merged. 124 if (!('committed' in NavigationTransition.prototype)) { 125 expectedAsArray = expectedAsArray.filter(expected => { 126 return !expected[0].includes("transition.committed fulfilled") && 127 !expected[0].includes("transition.committed rejected") 128 }); 129 } 130 131 // Doing this up front gives nicer error messages because 132 // assert_array_equals is nice. 133 const recordedNames = this.#events.map(e => e.name); 134 const expectedNames = expectedAsArray.map(e => e[0]); 135 assert_array_equals(recordedNames, expectedNames); 136 137 for (let i = 0; i < expectedAsArray.length; ++i) { 138 const recorded = this.#events[i]; 139 const expected = expectedAsArray[i]; 140 141 assert_equals( 142 recorded.location, 143 expected[1], 144 `event ${i} (${recorded.name}): location.hash value` 145 ); 146 147 if (expected[2] === null) { 148 assert_equals( 149 recorded.transitionProps, 150 null, 151 `event ${i} (${recorded.name}): navigation.transition expected to be null` 152 ); 153 } else { 154 assert_not_equals( 155 recorded.transitionProps, 156 null, 157 `event ${i} (${recorded.name}): navigation.transition expected not to be null` 158 ); 159 assert_equals( 160 recorded.transitionProps.from, 161 expected[2].from, 162 `event ${i} (${recorded.name}): navigation.transition.from` 163 ); 164 assert_equals( 165 recorded.transitionProps.navigationType, 166 expected[2].navigationType, 167 `event ${i} (${recorded.name}): navigation.transition.navigationType` 168 ); 169 } 170 } 171 172 if (this.#errors.length > 1) { 173 for (let i = 1; i < this.#errors.length; ++i) { 174 assert_equals( 175 this.#errors[i].errorObject, 176 this.#errors[0].errorObject, 177 `error objects must match: error object for ${this.#errors[i].name} did not match the one for ${this.#errors[0].name}` 178 ); 179 } 180 } 181 } 182 183 assertErrorsAreAbortErrors() { 184 assert_greater_than( 185 this.#errors.length, 186 0, 187 "No errors were recorded but assertErrorsAreAbortErrors() was called" 188 ); 189 190 // Assume assert() has been called so all error objects are the same. 191 const { errorObject } = this.#errors[0]; 192 assert_throws_dom("AbortError", this.#domExceptionConstructor, () => { throw errorObject; }); 193 } 194 195 assertErrorsAre(expectedErrorObject) { 196 assert_greater_than( 197 this.#errors.length, 198 0, 199 "No errors were recorded but assertErrorsAre() was called" 200 ); 201 202 // Assume assert() has been called so all error objects are the same. 203 const { errorObject } = this.#errors[0]; 204 assert_equals(errorObject, expectedErrorObject); 205 } 206 }