clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / scripts / shell-navigation.test.mjs
shell-navigation.test.mjs 5.8K
1// Navigation keeps in-place refreshes comfortable while making a new route2// 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";78const 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);1314function 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 shell53 // 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}5960test("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});7273test("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});8687test("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});9495test("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 and108 // 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});