| 1 | // transition_node — runs shell.js or transition.js, the file named on |
| 2 | // the command line, against a stub page in plain Node, and hands it view |
| 3 | // transitions whose promises reject. A transition the browser skips |
| 4 | // rejects ready, finished and updateCallbackDone with a DOMException |
| 5 | // named AbortError, InvalidStateError or TimeoutError even though the |
| 6 | // navigation succeeds, and nothing else handles it, so it reached the |
| 7 | // console as an uncaught error. The script must swallow exactly those |
| 8 | // three, at startup (document.activeViewTransition), on pagereveal and |
| 9 | // on pageswap, and let every other rejection through: an Error, a |
| 10 | // TypeError, a DOMException of another name, an Error merely named |
| 11 | // AbortError, and null. |
| 12 | // |
| 13 | // Ported from Rastrillo's ui/shell_transition_node.mjs (d9c9ed13, |
| 14 | // 023740c5, e5e35a3e, 6b1c5478). check/shell_test.go is the only |
| 15 | // caller. A failed assertion exits non-zero with its message; a pass |
| 16 | // prints one JSON object, the count of rejections checked, so the caller |
| 17 | // can tell a run that checked them all from one that checked none. |
| 18 | import assert from "node:assert/strict"; |
| 19 | import { readFileSync } from "node:fs"; |
| 20 | import vm from "node:vm"; |
| 21 | |
| 22 | const script = process.argv[2]; |
| 23 | if (script !== "shell.js" && script !== "transition.js") { |
| 24 | throw new Error(`name shell.js or transition.js, not ${script}`); |
| 25 | } |
| 26 | const source = readFileSync(new URL(`../../js/${script}`, import.meta.url), "utf8"); |
| 27 | const listeners = {}; |
| 28 | const document = { |
| 29 | querySelector: () => null, |
| 30 | addEventListener() {}, |
| 31 | }; |
| 32 | const window = { |
| 33 | onpagereveal: null, |
| 34 | onpageswap: null, |
| 35 | addEventListener: (event, listener) => { listeners[event] = listener; }, |
| 36 | }; |
| 37 | const start = () => vm.runInNewContext(source, { |
| 38 | window, document, URL, DOMException, |
| 39 | location: new URL("https://app.test/"), |
| 40 | }); |
| 41 | |
| 42 | start(); |
| 43 | assert.equal(typeof listeners.pageswap, "function", |
| 44 | `${script} observes outgoing transitions, for a page that opts in with its own CSS`); |
| 45 | |
| 46 | const unhandled = []; |
| 47 | process.on("unhandledRejection", (reason) => { unhandled.push(reason); }); |
| 48 | const turn = () => new Promise((resolve) => setImmediate(resolve)); |
| 49 | const skips = ["AbortError", "InvalidStateError", "TimeoutError"]; |
| 50 | let checked = 0; |
| 51 | for (const event of ["startup", "pagereveal", "pageswap"]) { |
| 52 | for (const promise of ["ready", "finished", "updateCallbackDone"]) { |
| 53 | const reasons = skips.map((name) => new DOMException("No transition", name)); |
| 54 | reasons.push(new Error("application failure"), |
| 55 | new TypeError("broken callback"), new DOMException("denied", "SecurityError"), |
| 56 | Object.assign(new Error("application abort"), { name: "AbortError" }), null); |
| 57 | for (const reason of reasons) { |
| 58 | unhandled.length = 0; |
| 59 | const transition = { |
| 60 | types: new Set(), |
| 61 | ready: Promise.resolve(), |
| 62 | finished: Promise.resolve(), |
| 63 | updateCallbackDone: Promise.resolve(), |
| 64 | }; |
| 65 | transition[promise] = Promise.reject(reason); |
| 66 | if (event === "startup") { |
| 67 | // The same transition at startup and again on pagereveal is |
| 68 | // observed once, and the types are left as the page set them. |
| 69 | document.activeViewTransition = transition; |
| 70 | start(); |
| 71 | assert.equal(transition.types.size, 0); |
| 72 | await turn(); |
| 73 | listeners.pagereveal({ viewTransition: transition }); |
| 74 | } else { |
| 75 | listeners[event]({ viewTransition: transition }); |
| 76 | } |
| 77 | await turn(); |
| 78 | await turn(); |
| 79 | const skipped = reason instanceof DOMException && skips.includes(reason.name); |
| 80 | assert.deepEqual(unhandled, skipped ? [] : [reason], `${script}, ${event}.${promise}: ${reason}`); |
| 81 | checked++; |
| 82 | } |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | // A filter on every rejection would hide this unrelated application |
| 87 | // failure, so the script must answer only the transitions it is given. |
| 88 | unhandled.length = 0; |
| 89 | const unrelated = new DOMException("application abort", "AbortError"); |
| 90 | Promise.reject(unrelated); |
| 91 | await turn(); |
| 92 | await turn(); |
| 93 | assert.deepEqual(unhandled, [unrelated], `${script} swallowed a rejection that was not a transition's`); |
| 94 | checked++; |
| 95 | |
| 96 | process.stdout.write(JSON.stringify({ checked }) + "\n"); |
| 97 | |