tinyrelayfile
tinyrelay/scripts/shell-navigation.test.mjs/

scripts/shell-navigation.test.mjs raw
1// Navigation keeps in-place refreshes comfortable while making a new route
2// start at a predictable position on small screens.
3import test from "node:test";
4import assert from "node:assert/strict";
5import fs from "node:fs";
6import vm from "node:vm";
7
8const source = fs.readFileSync("tinyclient/components.js", "utf8");
9const start = source.indexOf(" const swapShell = (text, url, push) => {");
10const end = source.indexOf(" const load = async (url, push) => {", start);
11const swapSource = source.slice(start, end);
12const releaseSource = source.slice(source.indexOf(" const releaseNavigation = () => {"), start);
13
14function setup({url = "https://relay.test/rooms/general", active = "content", contentScroll = 0, pageScroll = 0} = {}) {
15 const calls = {scroll: [], history: [], focus: []};
16 const links = [];
17 const elements = {
18 "#topbar": {outerHTML: "<header id=topbar></header>"},
19 "#railbox": {outerHTML: "<aside id=railbox></aside>"},
20 "#content": {outerHTML: "<main id=content></main>", scrollTop: contentScroll, focus(options) { calls.focus.push({id: "content", options}); }},
21 "#panel": {outerHTML: "<aside id=panel></aside>"},
22 "#footer": {outerHTML: "<footer id=footer></footer>"}
23 };
24 const nav = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }};
25 const context = {open: false, removeAttribute(name) { if (name === "open") this.open = false; }};
26 const draft = {id: "draft", focus(options) { calls.focus.push({id: this.id, options}); }};
27 const document = {
28 activeElement: active === "draft" ? draft : elements["#content"],
29 title: "old",
30 querySelector(selector) { return elements[selector] || null; },
31 querySelectorAll(selector) { return selector === "[data-fixi-nav]" ? links : []; },
32 getElementById(id) { return id === "nav-menu" ? nav : id === "context-menu" ? context : id === "content" ? elements["#content"] : id === "draft" ? draft : null; },
33 dispatchEvent() {}
34 };
35 const parsed = {
36 title: "new",
37 querySelector(selector) { return elements[selector] || null; }
38 };
39 const sandbox = {
40 document,
41 DOMParser: class { parseFromString() { return parsed; } },
42 CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } },
43 history: {pushState(_state, _title, href) { calls.history.push(href); }},
44 window: {scrollY: pageScroll, scrollTo(...args) { calls.scroll.push(args); }},
45 location: {href: url},
46 morph() {},
47 navTargets: ["#topbar", "#railbox", "#content", "#panel", "#footer"],
48 navigationSerial: 0,
49 closeStreams() {},
50 repairComponents() {}
51 };
52 // Keep the production closure intact, while supplying only its DOM shell
53 // dependencies. The returned function is the implementation under test.
54 const swap = vm.runInNewContext(`(() => { let renderedURL = ${JSON.stringify(url)}; const navTargets = ${JSON.stringify(sandbox.navTargets)}; let navigationSerial = 0; const closeStreams = () => {}; const repairComponents = () => {}; const ensureModules = () => {}; const decorate = () => {}; const decorateForms = () => {}; ${releaseSource} ${swapSource.replace(" const swapShell", "const swapShell")} return swapShell; })()`, sandbox);
55 nav.open = true;
56 context.open = true;
57 return {swap, nav, context, content: elements["#content"], document, calls, links};
58}
59
60test("a changed route resets both menu drawers and scroll", () => {
61 const s = setup({contentScroll: 420, pageScroll: 880});
62 s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"), true);
63 assert.equal(s.nav.open, false);
64 assert.equal(s.context.open, false);
65 assert.equal(s.content.scrollTop, 0);
66 assert.deepEqual(s.calls.scroll, [[0, 0]]);
67 assert.equal(s.calls.focus.length, 1);
68 assert.equal(s.calls.focus[0].id, "content");
69 assert.equal(s.calls.focus[0].options.preventScroll, true);
70 assert.equal(s.calls.history.length, 1);
71});
72
73test("an in-place refresh preserves scroll and the active draft", () => {
74 const s = setup({active: "draft", contentScroll: 315, pageScroll: 640});
75 s.nav.open = false;
76 s.context.open = false;
77 s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false);
78 assert.equal(s.content.scrollTop, 315);
79 assert.deepEqual(s.calls.scroll, []);
80 assert.equal(s.calls.focus.length, 1);
81 assert.equal(s.calls.focus[0].id, "draft");
82 assert.equal(s.calls.focus[0].options.preventScroll, true);
83 assert.equal(s.nav.open, false);
84 assert.equal(s.context.open, false);
85});
86
87test("a browser-back route change resets position without pushing history", () => {
88 const s = setup({url: "https://relay.test/rooms/general/thread/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", contentScroll: 190, pageScroll: 230});
89 s.swap("<html></html>", new URL("https://relay.test/rooms/general"), false);
90 assert.equal(s.content.scrollTop, 0);
91 assert.deepEqual(s.calls.scroll, [[0, 0]]);
92 assert.deepEqual(s.calls.history, []);
93});
94
95test("a retained back link does not keep its old Fixi handler after a shell swap", () => {
96 const s = setup();
97 const link = new EventTarget();
98 let intercepted = 0, normalClicks = 0;
99 link.__fixi = () => { intercepted++; };
100 link.__fixi.evt = "click";
101 link.addEventListener("click", link.__fixi);
102 link.addEventListener("click", () => { normalClicks++; });
103 s.links.push(link);
104 link.dispatchEvent(new Event("click"));
105 assert.equal(intercepted, 1);
106 s.swap("<html></html>", new URL("https://relay.test/rooms/general/thread/" + "a".repeat(64)), true);
107 // The node survives morphing, but its next href may contain a fragment and
108 // no longer opt in to Fixi. Only the browser's normal link action should run.
109 link.dispatchEvent(new Event("click"));
110 assert.equal(intercepted, 1);
111 assert.equal(normalClicks, 2);
112 assert.equal(link.__fixi, undefined);
113});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs