// Browser-independent UI simulation of the actual visual Overview modal. // This does not replace an authenticated DOM/browser acceptance test. import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; const registry = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8')); const ids = registry.map(widget => widget.id); const initialHidden = registry.filter(widget => widget.default_hidden).map(widget => widget.id); const source = fs.readFileSync('src/admin/web/app/dashboard-layout-editor.js', 'utf8'); const html = fs.readFileSync('src/admin/web/index.html', 'utf8'); for (const id of ['overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel']) assert(html.includes(`id="${id}"`), id); class FakeNode { constructor(tag = 'div', content = '', classes = '') { this.tag = tag; this.textContent = String(content ?? ''); this.classes = new Set(classes.split(/\s+/).filter(Boolean)); this.classList = { add: (...args) => args.forEach(x => this.classes.add(x)), remove: (...args) => args.forEach(x => this.classes.delete(x)) }; this.children = []; this.attrs = {}; this.dataset = {}; this.listeners = new Map(); this.hidden = false; this.open = false; this.disabled = false; this.value = ''; this.draggable = false; } append(...nodes) { this.children.push(...nodes); } replaceChildren(...nodes) { this.children = nodes; } addEventListener(name, cb) { const list = this.listeners.get(name) || []; list.push(cb); this.listeners.set(name, list); } fire(name, event = {}) { for (const cb of this.listeners.get(name) || []) cb({ preventDefault() {}, target: this, ...event }); } setAttribute(k, v) { this.attrs[k] = String(v); } removeAttribute(k) { delete this.attrs[k]; } focus() { this.focused = true; } showModal() { this.open = true; } close() { this.open = false; this.fire('close'); } querySelectorAll(selector) { const nodes = []; function visit(node) { for (const child of node.children) { if (selector === '[id]' && child.attrs.id) nodes.push(child); else if (selector === '[aria-labelledby], [aria-describedby]' && (child.attrs['aria-labelledby'] || child.attrs['aria-describedby'])) nodes.push(child); else if (selector === '.overview-layout-tile' && child.classes.has('overview-layout-tile')) nodes.push(child); visit(child); } } visit(this); return nodes; } querySelector(selector) { return this.querySelectorAll('.overview-layout-tile').find(node => node.classes.has(selector.slice(1))) || (() => { const visited = [this]; while (visited.length) { const node = visited.shift(); if (node.classes.has(selector.slice(1))) return node; visited.push(...node.children); } return null; })(); } cloneNode(deep = false) { const copy = new FakeNode(this.tag, this.textContent, [...this.classes].join(' ')); copy.hidden = this.hidden; copy.attrs = { ...this.attrs }; copy.dataset = { ...this.dataset }; if (deep) copy.children = this.children.map(node => node.cloneNode(true)); return copy; } } const controls = Object.fromEntries([ 'overview-layout-dialog', 'overview-layout-visible', 'overview-layout-hidden', 'overview-layout-save', 'overview-layout-cancel', 'overview-layout-search', 'overview-layout-type-filter', 'overview-layout-message', 'overview-layout-open', 'overview-layout-open-settings', 'overview-layout-default', 'overview-layout-reset', 'overview-reset-confirm', 'overview-reset-apply', 'overview-reset-cancel', ].map(id => [id, new FakeNode()])); const originals = new Map(ids.map(id => { const node = new FakeNode('article', 'metric value', 'stat'); node.attrs.id = `real-${id}`; node.dataset.dashboardWidget = id; node.append(new FakeNode('strong', '42')); node.children[0].attrs.id = `metric-${id}`; node.hidden = initialHidden.includes(id); return [id, node]; })); let saved = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden], theme: 'light' }; const writes = [], notifications = []; let failSave = false; const window = { rustProxyPreferences: { current: () => saved, load: async () => saved, update: async patch => { if (failSave) throw Error('server refused'); writes.push(patch); saved = { ...saved, ...patch }; for (const id of ids) originals.get(id).hidden = patch.dashboard_hidden_widgets.includes(id); return saved; }, } }; const $ = id => controls[id]; const ctx = { window, $, t: text => `loc:${text}`, toast: text => notifications.push(text), el: (tag, content, cls) => new FakeNode(tag, content, cls), ADMIN_DASHBOARD_BY_ID: new Map(registry.map(widget => [widget.id, widget])), ADMIN_DASHBOARD_WIDGETS: [...ids], ADMIN_DEFAULT_HIDDEN_WIDGETS: [...initialHidden], ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...initialHidden] }, normalizeDashboardWidgets: value => [...new Set([...(Array.isArray(value) ? value.filter(id => ids.includes(id)) : []), ...ids])], normalizeHiddenDashboardWidgets: value => [...new Set((Array.isArray(value) ? value : []).filter(id => ids.includes(id)))], dashboardWidgetType: id => registry.find(widget => widget.id === id)?.display_type, dashboardWidgetTypeLabel: id => `loc:overviewBlockType${({ value: 'Value', value_chart: 'ValueChart', chart: 'Chart', panel: 'Panel', table: 'Table', })[registry.find(widget => widget.id === id)?.display_type]}`, dashboardWidgetTypeBadge: id => new FakeNode('span', `type:${registry.find(widget => widget.id === id)?.display_type}`, 'overview-widget-type'), document: { querySelector: selector => originals.get(selector.match(/data-dashboard-widget="([^"]+)"/)?.[1]) || null }, }; vm.createContext(ctx); vm.runInContext(source, ctx); const module = window.rustProxyDashboardLayout; const dialog = controls['overview-layout-dialog']; const visible = controls['overview-layout-visible']; const hidden = controls['overview-layout-hidden']; const refresh = () => new Promise(resolve => setImmediate(resolve)); const previewIds = () => visible.children.filter(node => node.dataset.previewWidget).map(node => node.dataset.previewWidget); // Cancel changes, no PUT or change of real dashboard. controls['overview-layout-open'].fire('click'); await refresh(); assert.equal(dialog.open, true); assert.equal(previewIds().length, ids.length - initialHidden.length); assert.equal(hidden.children.length, initialHidden.length); assert.equal(visible.children[0].children[0].children[1].textContent, 'type:value'); assert.equal(hidden.children[0].children[1].textContent, `type:${registry.find(w => w.id === initialHidden[0]).display_type}`); controls['overview-layout-type-filter'].value = 'chart'; controls['overview-layout-type-filter'].fire('change'); assert.equal(hidden.children.filter(item => item.classes.has('overview-layout-hidden-item')).length, registry.filter(w => w.default_hidden && w.display_type === 'chart').length, 'type filter shows only hidden charts'); controls['overview-layout-type-filter'].value = 'all'; controls['overview-layout-type-filter'].fire('change'); assert.equal(hidden.children.length, initialHidden.length); const previewCopy = visible.children[0].children[1]; assert.equal(previewCopy.attrs.id, undefined, 'preview clones must not duplicate real IDs'); assert.equal(previewCopy.children[0].attrs.id, undefined); assert.equal(previewCopy.attrs['aria-hidden'], 'true'); const first = previewIds()[0]; visible.children[0].children[0].children[2].children[2].fire('click'); assert(!previewIds().includes(first)); assert.equal(originals.get(first).hidden, false, 'draft hide must not touch live cards'); assert.equal(writes.length, 0); controls['overview-layout-cancel'].fire('click'); assert.equal(dialog.open, false); assert.equal(writes.length, 0); // Settings and Overview entry points are wired to the same editor. controls['overview-layout-open-settings'].fire('click'); await refresh(); assert.equal(dialog.open, true); const originalVisible = previewIds(); const from = visible.children[0], to = visible.children[2]; const dataTransfer = { setData(type, value) { this[type] = value; }, effectAllowed: '', dropEffect: '' }; from.fire('dragstart', { target: from, dataTransfer }); to.fire('dragover', { dataTransfer }); to.fire('drop', { dataTransfer }); assert.equal(previewIds()[2], originalVisible[0], 'dropping moves a tile after the drop target'); assert.equal(previewIds()[0], originalVisible[1]); // Keyboard buttons move cards back, without rebuilding saved prefs prematurely. visible.children[2].children[0].children[2].children[0].fire('click'); assert.equal(previewIds()[1], originalVisible[0]); const firstHiddenId = initialHidden[0]; const hiddenItem = hidden.children.find(node => node.children[0].textContent === `loc:${registry.find(x => x.id === firstHiddenId).label_key}`); hiddenItem.children[2].fire('click'); assert(previewIds().includes(firstHiddenId)); assert.equal(writes.length, 0); controls['overview-layout-save'].fire('click'); await refresh(); assert.equal(writes.length, 1, 'one atomic preferences update'); assert.equal(dialog.open, false); assert.equal(saved.theme, 'light', 'unrelated preferences are untouched'); assert.equal(new Set(saved.dashboard_widgets).size, ids.length); assert.equal(saved.dashboard_widgets.length, ids.length); assert(!saved.dashboard_hidden_widgets.includes(firstHiddenId)); assert.equal(notifications.length, 1); // Failed save keeps the dialog open and the unsaved draft available for retry. controls['overview-layout-open'].fire('click'); await refresh(); failSave = true; const writeCount = writes.length; controls['overview-layout-save'].fire('click'); await refresh(); assert.equal(dialog.open, true); assert.equal(controls['overview-layout-message'].textContent, 'server refused'); assert.equal(writes.length, writeCount); failSave = false; controls['overview-layout-cancel'].fire('click'); assert.equal(dialog.open, false); // Default view is only a draft until Save: no implicit writes. controls['overview-layout-open'].fire('click'); await refresh(); const writesBeforeDefault = writes.length; controls['overview-layout-default'].fire('click'); assert.deepEqual(previewIds(), ids.filter(id => !initialHidden.includes(id))); assert(controls['overview-layout-message'].textContent.includes('overviewLayoutDefaultLoaded')); assert.equal(writes.length, writesBeforeDefault); assert(previewIds().includes('backend_table'), 'all seven full-width Overview panels belong to the editor'); assert.equal(visible.children.find(node => node.dataset.previewWidget === 'backend_table').classes.has('is-panel'), true); // Reset first prompts for confirmation, cancellation must not change preferences. controls['overview-layout-reset'].fire('click'); assert.equal(controls['overview-reset-confirm'].open, true); assert.equal(writes.length, writesBeforeDefault); controls['overview-reset-cancel'].fire('click'); assert.equal(controls['overview-reset-confirm'].open, false); assert.equal(writes.length, writesBeforeDefault); // A failed reset keeps the confirmation open and never claims success. controls['overview-layout-reset'].fire('click'); failSave = true; controls['overview-reset-apply'].fire('click'); await refresh(); assert.equal(controls['overview-reset-confirm'].open, true); assert.equal(writes.length, writesBeforeDefault); failSave = false; controls['overview-reset-apply'].fire('click'); await refresh(); assert.equal(controls['overview-reset-confirm'].open, false); assert.equal(dialog.open, false); assert.equal(writes.length, writesBeforeDefault + 1); assert.deepEqual([...saved.dashboard_widgets], ids); assert.deepEqual([...saved.dashboard_hidden_widgets], initialHidden); assert.equal(saved.theme, 'light', 'reset must not affect unrelated account preferences'); console.log('PASS: full Overview editor, drag/keyboard, legacy settings, default preview, confirmed reset, cancel, atomic save and failures'); // Group boundaries are a UX constraint in the real browser: the modal must // not let a small tile replace the position of a tall diagnostic panel. window.rustProxyMasonry = { groupFor: id => id === 'traffic_chart' ? 1 : id.endsWith('_diagnostics') ? 3 : 0 }; controls['overview-layout-open'].fire('click'); await refresh(); const beforeGroupMove = previewIds(); const small = visible.children.find(node => node.dataset.previewWidget === 'requests'); const big = visible.children.find(node => node.dataset.previewWidget === 'traffic_chart'); small.fire('dragstart', { target: small, dataTransfer: {setData() {},effectAllowed:''} }); big.fire('drop'); assert.deepEqual(previewIds(), beforeGroupMove, 'the modal must not mix incompatible tile groups'); controls['overview-layout-cancel'].fire('click'); console.log('PASS: visual editor respects symmetric height groups');