// transition_node — runs shell.js or transition.js, the file named on // the command line, against a stub page in plain Node, and hands it view // transitions whose promises reject. A transition the browser skips // rejects ready, finished and updateCallbackDone with a DOMException // named AbortError, InvalidStateError or TimeoutError even though the // navigation succeeds, and nothing else handles it, so it reached the // console as an uncaught error. The script must swallow exactly those // three, at startup (document.activeViewTransition), on pagereveal and // on pageswap, and let every other rejection through: an Error, a // TypeError, a DOMException of another name, an Error merely named // AbortError, and null. // // Ported from Rastrillo's ui/shell_transition_node.mjs (d9c9ed13, // 023740c5, e5e35a3e, 6b1c5478). check/shell_test.go is the only // caller. A failed assertion exits non-zero with its message; a pass // prints one JSON object, the count of rejections checked, so the caller // can tell a run that checked them all from one that checked none. import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import vm from "node:vm"; const script = process.argv[2]; if (script !== "shell.js" && script !== "transition.js") { throw new Error(`name shell.js or transition.js, not ${script}`); } const source = readFileSync(new URL(`../../js/${script}`, import.meta.url), "utf8"); const listeners = {}; const document = { querySelector: () => null, addEventListener() {}, }; const window = { onpagereveal: null, onpageswap: null, addEventListener: (event, listener) => { listeners[event] = listener; }, }; const start = () => vm.runInNewContext(source, { window, document, URL, DOMException, location: new URL("https://app.test/"), }); start(); assert.equal(typeof listeners.pageswap, "function", `${script} observes outgoing transitions, for a page that opts in with its own CSS`); const unhandled = []; process.on("unhandledRejection", (reason) => { unhandled.push(reason); }); const turn = () => new Promise((resolve) => setImmediate(resolve)); const skips = ["AbortError", "InvalidStateError", "TimeoutError"]; let checked = 0; for (const event of ["startup", "pagereveal", "pageswap"]) { for (const promise of ["ready", "finished", "updateCallbackDone"]) { const reasons = skips.map((name) => new DOMException("No transition", name)); reasons.push(new Error("application failure"), new TypeError("broken callback"), new DOMException("denied", "SecurityError"), Object.assign(new Error("application abort"), { name: "AbortError" }), null); for (const reason of reasons) { unhandled.length = 0; const transition = { types: new Set(), ready: Promise.resolve(), finished: Promise.resolve(), updateCallbackDone: Promise.resolve(), }; transition[promise] = Promise.reject(reason); if (event === "startup") { // The same transition at startup and again on pagereveal is // observed once, and the types are left as the page set them. document.activeViewTransition = transition; start(); assert.equal(transition.types.size, 0); await turn(); listeners.pagereveal({ viewTransition: transition }); } else { listeners[event]({ viewTransition: transition }); } await turn(); await turn(); const skipped = reason instanceof DOMException && skips.includes(reason.name); assert.deepEqual(unhandled, skipped ? [] : [reason], `${script}, ${event}.${promise}: ${reason}`); checked++; } } } // A filter on every rejection would hide this unrelated application // failure, so the script must answer only the transitions it is given. unhandled.length = 0; const unrelated = new DOMException("application abort", "AbortError"); Promise.reject(unrelated); await turn(); await turn(); assert.deepEqual(unhandled, [unrelated], `${script} swallowed a rejection that was not a transition's`); checked++; process.stdout.write(JSON.stringify({ checked }) + "\n");