From ba395b2f5a27ead31e425acf4bf346c587540b08 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:57:48 -0400 Subject: [PATCH] Fix: stop the importer losing sections and refusing valid configs The YAML subset refused a flow collection only after a key, so one written as a sequence element parsed into a mapping built from its brace and first key, and the section vanished from the import with no skip recorded. The anchor refusal tested the whole line, including values, so ordinary text containing * or & refused the file. A sequence element indented with more than one space after the dash threw on its continuation keys. The converters passed over a malformed top-level group and a Dashy section carrying no readable items, both without recording a skip, so a mangled file imported smaller than it was with nothing in the preview saying why. The dialog focus trap listened on the dialog element, which cannot hold focus, so a click on text inside it sent Escape and the Tab wrap to the document and neither worked. The backdrop dismissed on any click ending on it, including a text selection released past the edge of the dialog. --- CHANGELOG.md | 3 + ui/admin/index.html | 2 +- ui/index.html | 2 +- ui/js/admin.js | 6 +- ui/js/dashboard.js | 6 +- ui/js/dialog.js | 9 +- ui/js/import-foreign.js | 42 ++++-- ui/js/modal.js | 14 +- ui/js/spotlight.js | 2 +- ui/js/ui.js | 2 +- ui/js/yaml-lite.js | 53 +++++-- ui/test/dialog.test.mjs | 33 ++++- ui/test/import-foreign.test.mjs | 41 ++++++ ui/test/modal.test.mjs | 243 ++++++++++++++++++++++++++++++++ ui/test/yaml-lite.test.mjs | 50 +++++++ 15 files changed, 464 insertions(+), 44 deletions(-) create mode 100644 ui/test/modal.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a7d8f9..c3e00823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The password and Unsplash key status labels are translated instead of always reading in English. +- Escape and the Tab trap keep working in a folder or prompt overlay after + clicking text inside it. + ### Security - A stored credential is only reused for the request it was saved for. diff --git a/ui/admin/index.html b/ui/admin/index.html index ef5fcf49..35386184 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -285,6 +285,6 @@

General

- + diff --git a/ui/index.html b/ui/index.html index 7f01fb9b..a232c22b 100644 --- a/ui/index.html +++ b/ui/index.html @@ -54,5 +54,5 @@

Stackyard

- + diff --git a/ui/js/admin.js b/ui/js/admin.js index 0b01502e..0d075a29 100644 --- a/ui/js/admin.js +++ b/ui/js/admin.js @@ -13,9 +13,9 @@ import { loadSettings, showBgFields } from '/js/admin-settings.js?v=f189373f'; import { canJoinFolder, applyDrop } from '/js/admin-drag-logic.js?v=53aeaa55'; /* openModal is the item editor in this file, so the shared dialog comes in under a different name. */ -import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=10d34989'; -import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=a3f8f22e'; -import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=e0db2fb3'; +import { openModal as openDialog, confirmModal, promptModal } from '/js/modal.js?v=684b74e0'; +import { parseYaml, YamlLiteError } from '/js/yaml-lite.js?v=14aa75ec'; +import { detectSource, convert, SKIP, NOTE } from '/js/import-foreign.js?v=cf38ec0b'; /* Admin UI: Stackyard Dashboard */ diff --git a/ui/js/dashboard.js b/ui/js/dashboard.js index a1b31bc2..df1819d8 100644 --- a/ui/js/dashboard.js +++ b/ui/js/dashboard.js @@ -22,15 +22,15 @@ import { qa, setUserText, } from '/js/utils.js?v=84d58686'; -import { initSpotlight } from '/js/spotlight.js?v=a5ba7f75'; +import { initSpotlight } from '/js/spotlight.js?v=7aa670dc'; import { html, setHtml, raw } from '/js/html.js?v=ccec347c'; import { initI18n, t, currentLang } from '/js/i18n.js?v=133a7aac'; import { pwStrength, passwordMismatch } from '/js/password-strength.js?v=dab9978e'; import { sanitizeItemLinks } from '/js/link-url.js?v=19038560'; -import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=3d60a9bc'; +import { initUI, mkFolder, openFolderDesktop, openFolderMobile, buildMobile } from '/js/ui.js?v=1515288b'; import { badgeSignature, computeBadgeVisual } from '/js/badge-logic.js?v=d278c683'; import { configChanged, landingAfterSetup } from '/js/dashboard-logic.js?v=640430ba'; -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; const MOB = innerWidth <= 768 || /iPhone|iPod|Android/i.test(navigator.userAgent); diff --git a/ui/js/dialog.js b/ui/js/dialog.js index e30f8c16..e2e37bbd 100644 --- a/ui/js/dialog.js +++ b/ui/js/dialog.js @@ -89,7 +89,7 @@ export function trapFocus(root, opts = {}) { function release() { if (released) return; released = true; - root.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown)); + document.removeEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); /* Without this, closing leaves focus on nothing and the next Tab starts from the top of the page. Skipped if the element has left the document. */ if (restoreTo && restoreTo.focus && restoreTo.isConnected) { @@ -101,7 +101,12 @@ export function trapFocus(root, opts = {}) { } } - root.addEventListener('keydown', /** @type {EventListener} */ (onKeydown)); + /* On the document rather than on root, because a dialog holds text that is not + focusable: clicking a line of it moves focus to the body, and a listener + inside root would then never see the key. Escape and the Tab wrap have to + work from wherever the click left focus, which is the case wrapTab's + out-of-root branch already covers. */ + document.addEventListener('keydown', /** @type {EventListener} */ (onKeydown), true); const target = initialFocus || focusableWithin(root)[0]; if (target && target.focus) { diff --git a/ui/js/import-foreign.js b/ui/js/import-foreign.js index b4b371c0..2584c0b1 100644 --- a/ui/js/import-foreign.js +++ b/ui/js/import-foreign.js @@ -131,7 +131,7 @@ export function convertIcon(raw) { } return host === 'cdn.jsdelivr.net' ? { iconUrl: s, dropped: false } : { iconUrl: '', dropped: true }; } - if (s.startsWith('/') || s.includes('/')) return { iconUrl: '', dropped: true }; + if (s.includes('/')) return { iconUrl: '', dropped: true }; /* selfh.st and homelab-svg-assets prefixes. Both name the same service the dashboard-icons catalogue does, so the remainder is a usable slug. */ const body = /^(sh|hl)-/i.test(s) ? s.slice(3) : s; @@ -148,9 +148,11 @@ export function convertIcon(raw) { /** Collector for one conversion run. Keeps ids unique across every file in the batch, including against the items already on the dashboard. + A Set given by the caller is used as it is rather than copied: the ids of one + file have to be visible while the next one in the batch is converted. @param {Iterable} takenIds */ function collector(takenIds) { - const taken = new Set(takenIds || []); + const taken = takenIds instanceof Set ? takenIds : new Set(takenIds || []); return { taken, /** @type {any[]} */ items: [], @@ -242,7 +244,7 @@ export function convertHomepageServices(doc, takenIds = []) { const col = collector(takenIds); if (!Array.isArray(doc)) return result(col); - const walk = (entries, groupLabel, depth) => { + const walk = (entries, groupLabel) => { const at = col.items.length; const children = []; for (const entry of Array.isArray(entries) ? entries : []) { @@ -262,7 +264,7 @@ export function convertHomepageServices(doc, takenIds = []) { /* The child's own name, not the compound one: the preview prints "group / name", so passing the compound name repeats the parent. */ col.note(NOTE.GROUP_FLATTENED, name, groupLabel, nested); - walk(value, nested, depth + 1); + walk(value, nested); continue; } if (!isMap(value)) { @@ -295,8 +297,14 @@ export function convertHomepageServices(doc, takenIds = []) { for (const group of doc) { const g = soleEntry(group); - if (!g || !Array.isArray(g[1])) continue; - walk(g[1], g[0], 0); + if (!g || !Array.isArray(g[1])) { + /* A top-level group the file shapes differently from the rest. detectSource + settles the format on the first entry that matches, so one of these does + not stop the import, and passing over it loses the whole group. */ + col.skip(SKIP.UNREADABLE, g ? g[0] : '', ''); + continue; + } + walk(g[1], g[0]); } return result(col); } @@ -308,7 +316,10 @@ export function convertHomepageBookmarks(doc, takenIds = []) { for (const group of doc) { const g = soleEntry(group); - if (!g || !Array.isArray(g[1])) continue; + if (!g || !Array.isArray(g[1])) { + col.skip(SKIP.UNREADABLE, g ? g[0] : '', ''); + continue; + } const [groupLabel, entries] = g; const at = col.items.length; const children = []; @@ -371,8 +382,6 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { } const title = text(raw.title); const icon = convertIcon(raw.icon); - /* localUrl is the address on the other network. Importing it would give - half the tiles a link that only works from somewhere else. */ const href = str(raw.url); const check = raw.statusCheck === true; /* Dashy's per-item switch for a health check against a self-signed @@ -390,15 +399,22 @@ export function convertDashy(doc, takenIds = [], untitledFolder = 'Imported') { children.push(app.id); if (icon.dropped) col.note(NOTE.ICON_DROPPED, title, groupLabel, str(raw.icon)); if (viaSubItem) col.note(NOTE.SUBITEMS_FLATTENED, title, groupLabel); - /* A second address for the same service, reachable only from the other - network. Reported rather than dropped in silence, because the tile - keeps whichever of the two the source called the primary one. */ + /* localUrl is a second address for the same service, reachable only from + the other network, so importing it would give half the tiles a link that + works from somewhere else. Reported rather than dropped in silence, + because the tile keeps whichever of the two the source called primary. */ if (str(raw.localUrl)) col.note(NOTE.LOCAL_URL_DROPPED, title, groupLabel, str(raw.localUrl)); noteIgnored(col, raw, title, groupLabel); for (const sub of Array.isArray(raw.subItems) ? raw.subItems : []) addDashyItem(sub, true); }; - for (const item of Array.isArray(section.items) ? section.items : []) addDashyItem(item, false); + if (Array.isArray(section.items)) for (const item of section.items) addDashyItem(item, false); + else if (!Array.isArray(section.widgets)) { + /* A section shaped like one but carrying nothing readable. Without this it + contributes no items, no skips and no notes, and the preview shows a + file smaller than the one that was chosen with nothing saying why. */ + col.skip(SKIP.UNREADABLE, groupLabel, ''); + } /* A section widget has no link of its own to keep, unlike a Homepage service widget, so there is nothing to import in its place. */ if (Array.isArray(section.widgets) && section.widgets.length) diff --git a/ui/js/modal.js b/ui/js/modal.js index a8d13f83..540d806a 100644 --- a/ui/js/modal.js +++ b/ui/js/modal.js @@ -9,7 +9,7 @@ Structure only. Callers fill the body with their own nodes. */ -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; /** Open a modal and return its parts. @@ -57,8 +57,15 @@ export function openModal({ title, className, onClose }) { if (onClose) onClose(); }; + /* Both ends of the click, or a text selection that starts inside the dialog + and is released past its edge dismisses it, discarding whatever the person + was reading closely enough to select. */ + let downOnBackdrop = false; + ov.onmousedown = e => { + downOnBackdrop = e.target === ov; + }; ov.onclick = e => { - if (e.target === ov) close(); + if (e.target === ov && downOnBackdrop) close(); }; const addAction = (label, cls, onAct) => { @@ -79,6 +86,9 @@ export function openModal({ title, className, onClose }) { document.body.appendChild(ov); const focus = initial => { + /* Arming twice is a caller re-rendering the body. The previous trap has to + go, or its listener outlives the dialog and its restore target is lost. */ + release(); release = trapFocus(box, { onClose: close, initialFocus: initial }); }; diff --git a/ui/js/spotlight.js b/ui/js/spotlight.js index 0dbb8cdc..5335a4a0 100644 --- a/ui/js/spotlight.js +++ b/ui/js/spotlight.js @@ -1,5 +1,5 @@ import { mk, clr, el, inp as inpById, q, qa, setUserText } from '/js/utils.js?v=84d58686'; -import { wrapTab } from '/js/dialog.js?v=4ff94595'; +import { wrapTab } from '/js/dialog.js?v=b3841546'; import { t } from '/js/i18n.js?v=133a7aac'; /* Attached to the window so a re-open can undo the previous one. Not a diff --git a/ui/js/ui.js b/ui/js/ui.js index ee2d68bd..dc9dacea 100644 --- a/ui/js/ui.js +++ b/ui/js/ui.js @@ -12,7 +12,7 @@ import { setUserText, } from '/js/utils.js?v=84d58686'; import { t, currentLang } from '/js/i18n.js?v=133a7aac'; -import { trapFocus } from '/js/dialog.js?v=4ff94595'; +import { trapFocus } from '/js/dialog.js?v=b3841546'; import { mobileMetrics } from '/js/mobile-metrics.js?v=b32c23b3'; let _state = null; diff --git a/ui/js/yaml-lite.js b/ui/js/yaml-lite.js index f2891956..7ea6ebcb 100644 --- a/ui/js/yaml-lite.js +++ b/ui/js/yaml-lite.js @@ -34,6 +34,11 @@ export class YamlLiteError extends Error { "url: http://host:8080" splits once and keeps the port. */ const KEY_RE = /^(?:(?:"((?:[^"\\]|\\.)*)")|(?:'((?:[^']|'')*)')|([^:#]+?))\s*:(?:\s+(.*))?$/; +/** The escapes a double-quoted form decodes. Shared so a key and a value spell + the same text the same way. + @param {string} s */ +const unescapeDouble = s => s.replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); + /** @param {string} raw @param {number} line */ function scalar(raw, line) { const s = raw.trim(); @@ -41,7 +46,7 @@ function scalar(raw, line) { if (s[0] === '"') { const m = /^"((?:[^"\\]|\\.)*)"\s*$/.exec(s); if (!m) throw new YamlLiteError('unterminated double-quoted value', line); - return m[1].replace(/\\(["\\/nrt])/g, (_, c) => ({ n: '\n', r: '\r', t: '\t' })[c] || c); + return unescapeDouble(m[1]); } if (s[0] === "'") { const m = /^'((?:[^']|'')*)'\s*$/.exec(s); @@ -81,6 +86,7 @@ const BLOCK_RE = /^(.*?):\s*([|>])([-+]?)(\d*)\s*$/; @param {string[]} raw the block's lines, already stripped of its indentation @param {string} style either "|" or ">" @param {string} chomp */ function foldBlock(raw, style, chomp) { + if (!raw.length) return ''; let body; if (style === '|') body = raw.join('\n'); else { @@ -93,6 +99,8 @@ function foldBlock(raw, style, chomp) { } } if (chomp === '-') return body.replace(/\n+$/, ''); + /* "keep" means every trailing newline the block ended with, so the blank lines + have to still be here rather than trimmed off before the fold. */ if (chomp === '+') return body + '\n'; return body.replace(/\n+$/, '') + (body.length ? '\n' : ''); } @@ -106,13 +114,16 @@ function scan(text) { becomes part of the first key and the document reads as malformed. */ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); const rows = text.split(/\r\n|\r|\n/); + /* The empty string after a final newline is not a line of the file. It only + shows where a block scalar counts the blank lines it ends with. */ + if (rows.length > 1 && rows[rows.length - 1] === '') rows.pop(); for (let i = 0; i < rows.length; i++) { const raw = rows[i]; const line = i + 1; if (/^\s*$/.test(raw)) continue; if (/^\s*#/.test(raw)) continue; - if (/^\t/.test(raw) || /^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line); + if (/^ *\t/.test(raw)) throw new YamlLiteError('tab indentation is not supported', line); const trimmed = raw.trim(); if (trimmed === '---') { /* One leading marker is ordinary. A second means a multi-document file, @@ -121,10 +132,11 @@ function scan(text) { continue; } if (trimmed === '...') throw new YamlLiteError('multi-document files are not supported', line); - /* Before the anchor check: a merge key is written with an alias, and naming - the alias would send someone looking for the wrong thing. */ + /* Named before the general anchor refusal, which a merge key would also + trip, because naming the alias sends someone looking for the wrong thing. + Anchors themselves are refused where a node begins rather than here: a + value is free text, and `description: The *arr stack` is not an alias. */ if (/^<<\s*:/.test(trimmed)) throw new YamlLiteError('merge keys are not supported', line); - if (/(^|\s)[&*][^\s]/.test(trimmed)) throw new YamlLiteError('anchors and aliases are not supported', line); const indent = raw.length - raw.trimStart().length; const bm = BLOCK_RE.exec(trimmed); @@ -148,7 +160,6 @@ function scan(text) { if (ri < base) break; body.push(r.slice(base)); } - while (body.length && body[body.length - 1] === '') body.pop(); out.push({ indent, text: bm[1].trim() + ':', line, block: foldBlock(body, bm[2], bm[3]) }); i = j - 1; continue; @@ -165,6 +176,13 @@ function block(lines, cur, indent) { return first.text.startsWith('- ') || first.text === '-' ? sequence(lines, cur, indent) : mapping(lines, cur, indent); } +/** Refuse an anchor or alias where a node begins. Only the start of a node is + checked, so the same characters inside a value stay ordinary text. + @param {string} text @param {number} line */ +function refuseAnchor(text, line) { + if (/^[&*]\S/.test(text)) throw new YamlLiteError('anchors and aliases are not supported', line); +} + /** @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent */ function sequence(lines, cur, indent) { const out = []; @@ -173,7 +191,8 @@ function sequence(lines, cur, indent) { if (l.indent < indent) break; if (l.indent > indent) throw new YamlLiteError('unexpected indentation', l.line); if (!(l.text.startsWith('- ') || l.text === '-')) break; - const rest = l.text === '-' ? '' : l.text.slice(2).trim(); + const after = l.text.slice(1); + const rest = after.trim(); cur.pos++; if (rest === '') { /* "-" alone: the element is the block indented under it. */ @@ -181,12 +200,23 @@ function sequence(lines, cur, indent) { else out.push(null); continue; } + refuseAnchor(rest, l.line); + if (rest.startsWith('- ') || rest === '-') + throw new YamlLiteError('a sequence inside a sequence line is not supported', l.line); + /* Ahead of the key match, or a flow mapping's own brace and first key read + as a key line and the element parses into something that was never in the + file. scalar refuses it, and allows the empty pair. */ + if (rest[0] === '{' || rest[0] === '[') { + out.push(scalar(rest, l.line)); + continue; + } const m = KEY_RE.exec(rest); if (m) { /* The compound "- key: value" form. The element is a mapping whose first - key sits on the dash line, so its own indentation is the column the - key text starts at, not the dash. */ - const inner = indent + 2; + key sits on the dash line, so its own indentation is the column the key + text starts at, not a fixed offset from the dash: "- key" is as valid + as "- key" and its continuation lines line up with the key. */ + const inner = indent + 1 + (after.length - after.trimStart().length); const map = Object.create(null); assign(map, m, l.line, lines, cur, inner, l); while (cur.pos < lines.length && lines[cur.pos].indent === inner) { @@ -211,7 +241,7 @@ function sequence(lines, cur, indent) { @param {Line[]} lines @param {{ pos: number }} cur @param {number} indent @param {Line} [own] the key's own line, when it carried a block scalar */ function assign(map, m, line, lines, cur, indent, own) { - const key = m[1] !== undefined ? m[1] : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim(); + const key = m[1] !== undefined ? unescapeDouble(m[1]) : m[2] !== undefined ? m[2].replace(/''/g, "'") : m[3].trim(); if (own && own.block !== undefined) { map[key] = own.block; return; @@ -253,6 +283,7 @@ function mapping(lines, cur, indent) { Only valid as the whole value of the key above, which assign handles. */ break; } + refuseAnchor(l.text, l.line); const m = KEY_RE.exec(l.text); if (!m) throw new YamlLiteError('expected "key: value"', l.line); cur.pos++; diff --git a/ui/test/dialog.test.mjs b/ui/test/dialog.test.mjs index b1e4cd9d..4e7557e1 100644 --- a/ui/test/dialog.test.mjs +++ b/ui/test/dialog.test.mjs @@ -111,16 +111,37 @@ function makeDom() { return node; }; - globalThis.document = { + /* The trap listens on the document, not on the dialog, because a click on + text inside the dialog leaves focus on the body and a listener under the + root would never see the key. So the document needs the same machinery a + node has. */ + const doc = { get activeElement() { return active; }, createElement: t => el(t), + addEventListener(type, fn) { + if (!listeners.has(this)) listeners.set(this, []); + listeners.get(this).push({ type, fn }); + }, + removeEventListener(type, fn) { + const l = listeners.get(this) || []; + const at = l.findIndex(x => x.type === type && x.fn === fn); + if (at !== -1) l.splice(at, 1); + }, + _fire(type, event) { + for (const l of (listeners.get(this) || []).filter(x => x.type === type)) l.fn(event); + }, + _listenerCount() { + return (listeners.get(this) || []).length; + }, }; + globalThis.document = doc; globalThis.getComputedStyle = () => ({ visibility: 'visible', display: 'block', position: 'static' }); return { el, + doc, setActive: n => { active = n; }, @@ -308,7 +329,7 @@ test('Escape closes by default', async () => { closed++; }, }); - ov._fire('keydown', keyEvent('Escape')); + dom.doc._fire('keydown', keyEvent('Escape')); assert.equal(closed, 1); }); }); @@ -325,7 +346,7 @@ test('Escape can be turned off', async () => { closed++; }, }); - ov._fire('keydown', keyEvent('Escape')); + dom.doc._fire('keydown', keyEvent('Escape')); assert.equal(closed, 0); }); }); @@ -333,11 +354,11 @@ test('Escape can be turned off', async () => { test('releasing removes the listener', async () => { await withDom(({ trapFocus }, dom) => { const { ov } = dialogOf(dom); - const before = ov._listenerCount(); + const before = dom.doc._listenerCount(); const release = trapFocus(ov); - assert.equal(ov._listenerCount(), before + 1); + assert.equal(dom.doc._listenerCount(), before + 1); release(); - assert.equal(ov._listenerCount(), before, 'a trap that outlives its dialog would capture later keys'); + assert.equal(dom.doc._listenerCount(), before, 'a trap that outlives its dialog would capture later keys'); }); }); diff --git a/ui/test/import-foreign.test.mjs b/ui/test/import-foreign.test.mjs index e9899d4f..6c6dbc15 100644 --- a/ui/test/import-foreign.test.mjs +++ b/ui/test/import-foreign.test.mjs @@ -360,3 +360,44 @@ test('conversion adds items only, so nothing existing is touched', () => { assert.equal(item.type === 'folder' || item.type === 'app', true); } }); + +/* detectSource settles the format on the first entry that matches, so a group + the rest of the file does not agree with reaches the converter. Passing over + it imported a smaller dashboard with nothing in the preview saying so. */ +test('a group that is not a list is counted, not passed over', () => { + const doc = parseYaml('- Dev:\n - Git:\n href: http://git\n- Broken: notalist\n'); + const out = convertHomepageServices(doc, []); + assert.equal(apps(out).length, 1); + assert.equal(byName(out.skipped, 'Broken').length, 1); + assert.equal(out.skipped[0].reason, SKIP.UNREADABLE); +}); + +test('a bookmarks group that is not a list is counted too', () => { + const doc = [{ Dev: [{ Git: [{ href: 'http://git' }] }] }, { Broken: 'notalist' }]; + const out = convertHomepageBookmarks(doc, []); + assert.equal(apps(out).length, 1); + assert.equal(byName(out.skipped, 'Broken').length, 1); +}); + +test('a Dashy section with no readable items is counted, not dropped', () => { + const out = convertDashy({ sections: [{ name: 'Apps' }] }, []); + assert.equal(out.items.length, 0); + assert.equal(byName(out.skipped, 'Apps').length, 1); + assert.equal(out.skipped[0].reason, SKIP.UNREADABLE); +}); + +/* A section that only holds widgets already reports them as dropped, so a + second line about the same section would say nothing new. */ +test('a Dashy section holding only widgets reports the widgets alone', () => { + const out = convertDashy({ sections: [{ name: 'Stats', widgets: [{ type: 'cpu' }] }] }, []); + assert.deepEqual(out.skipped, []); + assert.equal(out.notes[0].code, NOTE.WIDGETS_DROPPED); +}); + +/* The caller builds one set and passes it to every file in the batch. Copying + it inside meant the second file could not see what the first allocated. */ +test('the caller sees the ids a conversion allocated', () => { + const taken = new Set(['Plex_abc']); + const out = convertHomepageServices(parseYaml(SERVICES), taken); + for (const item of out.items) assert.ok(taken.has(item.id), 'the id is in the caller’s set'); +}); diff --git a/ui/test/modal.test.mjs b/ui/test/modal.test.mjs new file mode 100644 index 00000000..c9a1bb57 --- /dev/null +++ b/ui/test/modal.test.mjs @@ -0,0 +1,243 @@ +/* The dialog the admin UI opens for anything the browser's own confirm() and + prompt() cannot show. It replaced them, so it owes what they gave for free: + a backdrop that dismisses, Escape, and an answer that comes back once. + + The dismissal routes are what these cover. A dialog that closes when it + should not is worse than the browser's version, because the import preview + behind it is a list someone reads before deciding, and a dropped answer + reads as Cancel and discards the whole import. */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { register } from 'node:module'; + +register('./js-root-hooks.mjs', import.meta.url); + +/* Enough DOM for appending, dismissing and focus. Layout decides focusability + in a browser and there is none here, so everything visible counts. */ +function makeDom() { + const listeners = new Map(); + let active = null; + + const el = tag => ({ + tagName: tag.toUpperCase(), + children: [], + parent: null, + isConnected: true, + disabled: false, + className: '', + textContent: '', + offsetParent: {}, + attrs: new Map(), + focus() { + active = this; + }, + getAttribute(k) { + return this.attrs.has(k) ? this.attrs.get(k) : null; + }, + setAttribute(k, v) { + this.attrs.set(k, v); + }, + append(...kids) { + for (const k of kids) { + k.parent = this; + this.children.push(k); + } + }, + appendChild(k) { + this.append(k); + return k; + }, + contains(other) { + return other === this || this.children.some(c => c.contains && c.contains(other)); + }, + remove() { + if (this.parent) this.parent.children = this.parent.children.filter(c => c !== this); + this.isConnected = false; + }, + querySelectorAll(sel) { + const wanted = sel.split(',').map(s => s.trim()); + const out = []; + const walk = n => { + for (const c of n.children) { + const hit = wanted.some(w => w.startsWith(c.tagName.toLowerCase())); + if (hit && !c.disabled) out.push(c); + walk(c); + } + }; + walk(this); + return out; + }, + }); + + const body = el('div'); + const doc = { + get activeElement() { + return active; + }, + body, + createElement: el, + addEventListener(type, fn) { + if (!listeners.has(type)) listeners.set(type, []); + listeners.get(type).push(fn); + }, + removeEventListener(type, fn) { + const l = listeners.get(type) || []; + const at = l.indexOf(fn); + if (at !== -1) l.splice(at, 1); + }, + _fire(type, event) { + for (const fn of [...(listeners.get(type) || [])]) fn(event); + }, + }; + + globalThis.document = doc; + globalThis.getComputedStyle = () => ({ visibility: 'visible', display: 'block', position: 'static' }); + return { el, doc, body, setActive: n => (active = n), getActive: () => active }; +} + +async function withDom(fn) { + const dom = makeDom(); + const mod = await import('../js/modal.js'); + try { + return await fn(mod, dom); + } finally { + delete globalThis.document; + delete globalThis.getComputedStyle; + } +} + +const overlay = dom => dom.body.children[dom.body.children.length - 1]; +const buttons = box => box.children[2].children; + +/* A click reports the nearest ancestor of where it went down and where it came + up, so a selection dragged out of the dialog arrives on the overlay. */ +function clickFrom(ov, downTarget, upTarget) { + ov.onmousedown({ target: downTarget }); + ov.onclick({ target: upTarget }); +} + +test('the backdrop dismisses when the click both starts and ends on it', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + openModal({ title: 'T', onClose: () => closed++ }); + const ov = overlay(dom); + clickFrom(ov, ov, ov); + assert.equal(closed, 1); + assert.equal(dom.body.children.includes(ov), false, 'the overlay is gone'); + }); +}); + +/* Selecting a line of the preview and releasing past the edge of the dialog. + Dismissing there discards an import the person was still reading. */ +test('a selection released on the backdrop does not dismiss', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + const ov = overlay(dom); + clickFrom(ov, m.body, ov); + assert.equal(closed, 0); + assert.equal(dom.body.children.includes(ov), true, 'the dialog is still open'); + }); +}); + +test('a click inside the dialog does not dismiss', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + clickFrom(overlay(dom), m.body, m.body); + assert.equal(closed, 0); + }); +}); + +/* The dialog body holds text that nothing can focus, so a click on it leaves + focus on the page. Escape has to work from there. */ +test('Escape closes after a click on text inside the dialog', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + m.addAction('OK', 'bp sm'); + m.focus(); + dom.setActive(dom.body); + dom.doc._fire('keydown', { key: 'Escape', preventDefault() {} }); + assert.equal(closed, 1); + }); +}); + +test('close is safe to call twice, so two dismissal routes answer once', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + m.close(); + m.close(); + assert.equal(closed, 1); + }); +}); + +test('arming the trap again releases the one before it', async () => { + await withDom(({ openModal }, dom) => { + let closed = 0; + const m = openModal({ title: 'T', onClose: () => closed++ }); + const a = m.addAction('OK', 'bp sm'); + m.focus(a); + m.focus(a); + dom.doc._fire('keydown', { key: 'Escape', preventDefault() {} }); + assert.equal(closed, 1, 'an orphaned trap would close it a second time'); + }); +}); + +test('confirmModal resolves true on the confirming action and false otherwise', async () => { + await withDom(async ({ confirmModal }, dom) => { + const yes = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + const box = overlay(dom).children[0]; + buttons(box)[1].onclick(); + assert.equal(await yes, true); + + const no = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + buttons(overlay(dom).children[0])[0].onclick(); + assert.equal(await no, false); + }); +}); + +/* Dismissing is not an answer. The caller treats false as Cancel, which is the + safe reading for a destructive action. */ +test('confirmModal resolves false when the backdrop dismisses it', async () => { + await withDom(async ({ confirmModal }, dom) => { + const answer = confirmModal({ title: 'T', body: dom.el('p'), confirmLabel: 'Go', cancelLabel: 'No' }); + const ov = overlay(dom); + clickFrom(ov, ov, ov); + assert.equal(await answer, false); + }); +}); + +test('promptModal returns the trimmed value and refuses an empty one', async () => { + await withDom(async ({ promptModal }, dom) => { + const answer = promptModal({ title: 'T', label: 'Name', confirmLabel: 'Save', cancelLabel: 'No' }); + const box = overlay(dom).children[0]; + const input = box.children[1].children[0].children[1]; + + input.value = ' '; + buttons(box)[1].onclick(); + assert.equal(dom.body.children.includes(overlay(dom)), true, 'an empty value keeps the dialog open'); + + input.value = ' Media '; + buttons(box)[1].onclick(); + assert.equal(await answer, 'Media'); + }); +}); + +test('promptModal resolves null when it is cancelled', async () => { + await withDom(async ({ promptModal }, dom) => { + const answer = promptModal({ title: 'T', label: 'Name', confirmLabel: 'Save', cancelLabel: 'No' }); + buttons(overlay(dom).children[0])[0].onclick(); + assert.equal(await answer, null); + }); +}); + +test('two open dialogs label their headings apart', async () => { + await withDom(({ openModal }, dom) => { + const a = openModal({ title: 'One' }); + const b = openModal({ title: 'Two' }); + assert.notEqual(a.box.getAttribute('aria-labelledby'), b.box.getAttribute('aria-labelledby')); + }); +}); diff --git a/ui/test/yaml-lite.test.mjs b/ui/test/yaml-lite.test.mjs index af31ed4c..66fb63b4 100644 --- a/ui/test/yaml-lite.test.mjs +++ b/ui/test/yaml-lite.test.mjs @@ -199,3 +199,53 @@ test('brackets with contents still refuse, since that is where guessing starts', assert.throws(() => parseYaml('tags: [a, b]\n'), /flow collections/); assert.throws(() => parseYaml('opts: { a: 1 }\n'), /flow collections/); }); + +/* A flow collection written as a list element used to reach the key matcher + first, which found the brace and the first key and built a mapping out of + them. The whole element parsed into something that was never in the file. */ +test('a flow collection refuses in a list too, not only after a key', () => { + assert.throws(() => parseYaml('sections:\n - {name: Apps, items: []}\n'), /flow collections/); + assert.throws(() => parseYaml('sections:\n - [a, b]\n'), /flow collections/); + assert.deepEqual(plain(parseYaml('sections:\n - {}\n - []\n')), { sections: [{}, []] }); +}); + +test('a list inside a list line refuses rather than reading as text', () => { + assert.throws(() => parseYaml('a:\n - - x\n'), /sequence inside a sequence/); +}); + +/* "*arr" is what a homelab calls the Sonarr and Radarr stack, and description + is an ordinary Homepage field. Refusing these refused valid files. */ +test('& and * inside a value are text, not an anchor', () => { + assert.equal(parseYaml('description: The *arr stack\n').description, 'The *arr stack'); + assert.equal(parseYaml('description: Movies &TV\n').description, 'Movies &TV'); + assert.equal(parseYaml('cron: "*/5 * * * *"\n').cron, '*/5 * * * *'); + assert.equal(parseYaml('- name: Plex **HD**\n')[0].name, 'Plex **HD**'); +}); + +test('an anchor where a node begins still refuses', () => { + assert.throws(() => parseYaml('base: &defaults\n a: 1\n'), /anchors and aliases/); + assert.throws(() => parseYaml('a: *ref\n'), /anchors and aliases/); + assert.throws(() => parseYaml('&defaults key: 1\n'), /anchors and aliases/); + assert.throws(() => parseYaml('a:\n - &ref x\n'), /anchors and aliases/); +}); + +/* Hand-aligned configs pad the dash. The continuation keys then sit one column + further in, and assuming a fixed offset threw on the whole file. */ +test('a list element keeps its own key column, however the dash is padded', () => { + const one = plain(parseYaml('sections:\n - name: Apps\n icon: box\n')); + assert.deepEqual(plain(parseYaml('sections:\n - name: Apps\n icon: box\n')), one); + assert.deepEqual(plain(parseYaml('sections:\n - name: Apps\n icon: box\n')), one); +}); + +test('a double-quoted key decodes its escapes the way a value does', () => { + assert.deepEqual(Object.keys(parseYaml('"a\\nb": 1\n')), ['a\nb']); +}); + +/* Every trailing newline, which is the whole difference between "keep" and the + default. Stripping the blank lines before the fold left nothing to keep. */ +test('the keep indicator keeps more than one trailing newline', () => { + assert.equal(parseYaml('a: |+\n x\n\n\nb: 1\n').a, 'x\n\n\n'); + assert.equal(parseYaml('a: |+\n').a, ''); + assert.equal(parseYaml('a: |\n x\n\n\nb: 1\n').a, 'x\n'); + assert.equal(parseYaml('a: |-\n x\n\n\nb: 1\n').a, 'x'); +});