// Offline simulation of direct Overview arrangement. No authenticated browser // or Rust networking is exercised by this test. import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; const widgets = JSON.parse(fs.readFileSync('src/admin/web/dashboard-widgets.json', 'utf8')); const ids = widgets.map(w => w.id); const hiddenDefault = widgets.filter(w => w.default_hidden).map(w => w.id); const source = fs.readFileSync('src/admin/web/app/dashboard-inline-layout.js', 'utf8'); const html = fs.readFileSync('src/admin/web/index.html', 'utf8'); for (const id of ['overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel', 'overview-inline-hint', 'dashboard-overview-grid']) assert(html.includes(`id="${id}"`), `missing ${id}`); class Node { constructor(tag = 'div', value = '', cls = '') { this.tag = tag; this.textContent = value ?? ''; this.classes = new Set(cls.split(' ').filter(Boolean)); this.classList = { add: (...names) => names.forEach(name => this.classes.add(name)), remove: (...names) => names.forEach(name => this.classes.delete(name)) }; this.children = []; this.listeners = new Map(); this.dataset = {}; this.attributes = {}; this.hidden = false; this.disabled = false; this.parent = null; } append(...children) { for (const child of children) { child.remove(); this.children.push(child); child.parent = this; } } replaceChildren(...children) { for (const child of this.children) child.parent = null; this.children = []; this.append(...children); } remove() { if (this.parent) { const at = this.parent.children.indexOf(this); if (at >= 0) this.parent.children.splice(at, 1); } this.parent = null; } closest(selector) { for (let cur = this; cur; cur = cur.parent) { if (selector === '[data-dashboard-widget]' && cur.dataset.dashboardWidget) return cur; if (selector === '[data-overview-drag-handle]' && cur.dataset.overviewDragHandle) return cur; } return null; } contains(other) { return !!other && (other === this || this.children.some(child => child.contains(other))); } querySelector(selector) { for (const child of this.children) { if (child.classes.has(selector.slice(1)) || child.tag === selector) return child; const nested = child.querySelector(selector); if (nested) return nested; } return null; } setAttribute(name, value) { this.attributes[name] = String(value); } getBoundingClientRect() { return { left: 0, width: 100 }; } addEventListener(name, cb) { this.listeners.set(name, [...(this.listeners.get(name) || []), cb]); } fire(name, event = {}) { const e = { target: this, clientX: 0, prevented: false, preventDefault() { this.prevented = true; }, ...event }; for (const cb of this.listeners.get(name) || []) cb(e); return e; } focus() { this.focused = true; } } const controls = new Map(['dashboard-overview-grid', 'overview-layout-unlock', 'overview-inline-actions', 'overview-inline-save', 'overview-inline-cancel', 'overview-inline-hint', 'overview-inline-hidden', 'overview-inline-hidden-list', 'overview-inline-hidden-count', 'overview-layout-open', 'overview-layout-dialog'].map(id => [id, new Node()])); const grid = controls.get('dashboard-overview-grid'); controls.get('overview-inline-hidden').append(new Node('summary', 'Hidden blocks')); const cards = new Map(ids.map(id => { const node = new Node('article'); node.dataset.dashboardWidget = id; node.hidden = hiddenDefault.includes(id); node.setAttribute('id', `real-${id}`); node.append(new Node('strong', id)); grid.append(node); return [id, node]; })); let current = { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault], theme: 'light' }; const writes = [], notifications = []; let reject = false; const window = { rustProxyPreferences: { load: async () => current, current: () => current, update: async patch => { if (reject) throw Error('server refused'); writes.push(patch); current = { ...current, ...patch }; applyDashboardLayout(current.dashboard_widgets, current.dashboard_hidden_widgets); return current; }, }, }; function applyDashboardLayout(order, hidden) { for (const id of order) { cards.get(id).hidden = hidden.includes(id); grid.append(cards.get(id)); } } const $ = id => controls.get(id); const ctx = { window, $, el: (tag, value, cls) => new Node(tag, value, cls), t: key => `loc:${key}`, toast: msg => notifications.push(msg), dashboardWidgetLabel: id => `label:${id}`, dashboardWidgetTypeBadge: id => new Node('span', `kind:${widgets.find(w => w.id === id).display_type}`, 'overview-widget-type'), applyDashboardLayout, ADMIN_APPEARANCE_DEFAULTS: { dashboard_widgets: [...ids], dashboard_hidden_widgets: [...hiddenDefault] }, normalizeDashboardWidgets: order => [...new Set([...(Array.isArray(order) ? order.filter(id => ids.includes(id)) : []), ...ids])], normalizeHiddenDashboardWidgets: hidden => [...new Set((hidden || []).filter(id => ids.includes(id)))], }; vm.createContext(ctx); vm.runInContext(source, ctx); const editor = window.rustProxyInlineLayout; const refresh = () => new Promise(resolve => setImmediate(resolve)); const visible = () => grid.children.filter(node => !node.hidden).map(node => node.dataset.dashboardWidget); const toolbarCount = () => grid.children.filter(node => node.querySelector('.overview-inline-toolbar')).length; const button = (id, cls) => cards.get(id).querySelector(cls); const grip = id => button(id, '.overview-inline-grip'); const dataTransfer = () => ({ setData(k, value) { this[k] = value; }, effectAllowed: '', dropEffect: '' }); // Opening does not clone cards or trigger network writes. Only the handle drags. controls.get('overview-layout-unlock').fire('click'); await refresh(); assert(editor.isEditing()); assert.equal(controls.get('overview-inline-actions').hidden, false); assert.equal(toolbarCount(), 62); assert.equal(cards.get('requests').attributes.id, 'real-requests'); assert.equal(grip('requests').dataset.overviewDragHandle, 'requests'); const accidental = grid.fire('dragstart', { target: cards.get('requests'), dataTransfer: dataTransfer() }); assert(accidental.prevented); assert.equal(writes.length, 0); // Hide removes the real block from the current view, not the registry; restore // works immediately without an API call, and Cancel reverts an uncommitted hide. button('requests', '.overview-inline-hide').fire('click'); assert(cards.get('requests').hidden); assert(controls.get('overview-inline-hidden').open); assert.equal(controls.get('overview-inline-hidden').hidden, false); assert(controls.get('overview-inline-hidden-list').children.length > hiddenDefault.length); assert.equal(writes.length, 0); controls.get('overview-inline-hidden-list').children .find(item => item.children.some(child => child.textContent === 'label:requests')) .querySelector('.overview-inline-restore').fire('click'); assert(!cards.get('requests').hidden, 'restore makes the block visible in the same draft'); button('requests', '.overview-inline-hide').fire('click'); assert(cards.get('requests').hidden); controls.get('overview-inline-cancel').fire('click'); assert(!cards.get('requests').hidden, 'cancel restores the original visibility'); assert.equal(controls.get('overview-inline-hidden').hidden, true); assert.equal(writes.length, 0); controls.get('overview-layout-unlock').fire('click'); await refresh(); const transfer = dataTransfer(); grid.fire('dragstart', { target: grip('requests'), dataTransfer: transfer }); assert.equal(transfer['text/plain'], 'requests'); grid.fire('dragover', { target: cards.get('active'), clientX: 70, dataTransfer: transfer }); grid.fire('drop', { target: cards.get('active'), clientX: 70, dataTransfer: transfer }); assert.deepEqual(visible().slice(0, 3), ['active','requests','bandwidth']); assert.equal(writes.length, 0); button('requests', '.overview-inline-up').fire('click'); assert.deepEqual(visible().slice(0, 3), ['requests','active','bandwidth']); button('traffic_chart', '.overview-inline-up').fire('click'); assert.equal(writes.length, 0); controls.get('overview-inline-cancel').fire('click'); assert(!editor.isEditing()); assert.deepEqual(visible(), ids.filter(id => !hiddenDefault.includes(id))); assert.equal(toolbarCount(), 0); assert.equal(writes.length, 0); // Saving writes exactly one ordered/hidden patch and retains other preferences. controls.get('overview-layout-unlock').fire('click'); await refresh(); grid.fire('dragstart', { target: grip('requests'), dataTransfer: dataTransfer() }); grid.fire('drop', { target: cards.get('active'), clientX: 90 }); reject = true; controls.get('overview-inline-save').fire('click'); await refresh(); assert(editor.isEditing(), 'failed PUT preserves the draft'); assert.equal(controls.get('overview-inline-hint').textContent, 'server refused'); assert.equal(writes.length, 0); reject = false; controls.get('overview-inline-save').fire('click'); await refresh(); assert(!editor.isEditing()); assert.equal(toolbarCount(), 0); assert.equal(writes.length, 1); assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']); assert.deepEqual([...current.dashboard_hidden_widgets], hiddenDefault); assert.equal(current.theme, 'light'); assert.equal(notifications.length, 1); // Locking without changes does not write again. Navigation discard and dialog guard. controls.get('overview-layout-unlock').fire('click'); await refresh(); controls.get('overview-layout-unlock').fire('click'); await refresh(); assert(!editor.isEditing()); assert.equal(writes.length, 1); controls.get('overview-layout-dialog').open = true; controls.get('overview-layout-unlock').fire('click'); await refresh(); assert(!editor.isEditing(), 'do not edit actual cards underneath the open modal'); controls.get('overview-layout-dialog').open = false; controls.get('overview-layout-unlock').fire('click'); await refresh(); grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() }); grid.fire('drop', { target: cards.get('requests'), clientX: 0 }); assert.notDeepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id))); editor.cancel(); assert.deepEqual(visible(), [...current.dashboard_widgets].filter(id => !hiddenDefault.includes(id))); assert.equal(writes.length, 1); console.log('PASS: direct Overview unlock, handles, move arrows, cancel, one save, retry, lock, navigation and modal guard'); // Hide persists across unlock sessions and the list allows recovery without // touching unrelated account settings. The same API path handles a large panel. controls.get('overview-layout-unlock').fire('click'); await refresh(); button('backend_table', '.overview-inline-hide').fire('click'); assert(cards.get('backend_table').hidden); assert.equal(writes.length, 1); controls.get('overview-inline-save').fire('click'); await refresh(); assert.equal(writes.length, 2); assert(current.dashboard_hidden_widgets.includes('backend_table')); assert(cards.get('backend_table').hidden); assert.equal(current.theme, 'light'); controls.get('overview-layout-unlock').fire('click'); await refresh(); controls.get('overview-inline-hidden-list').children .find(item => item.children.some(child => child.textContent === 'label:backend_table')) .querySelector('.overview-inline-restore').fire('click'); assert(!cards.get('backend_table').hidden); controls.get('overview-inline-save').fire('click'); await refresh(); assert.equal(writes.length, 3); assert(!current.dashboard_hidden_widgets.includes('backend_table')); assert.deepEqual([...current.dashboard_widgets.slice(0, 3)], ['active', 'requests', 'bandwidth']); console.log('PASS: inline hide, unsaved cancel, hidden shelf restore and one-request persistence'); // In the real browser the row controller groups cards by footprint. A cross- // group drop should not appear to move a card into an incompatible-height row. window.rustProxyMasonry = {groupFor: id => id === 'traffic_chart' ? 1 : 0}; const beforeGroups = [...current.dashboard_widgets]; controls.get('overview-layout-unlock').fire('click'); await refresh(); const beforeCrossDrop = visible(); grid.fire('dragstart', { target: grip('traffic_chart'), dataTransfer: dataTransfer() }); grid.fire('drop', { target: cards.get('requests'), clientX: 50 }); assert.deepEqual(visible(), beforeCrossDrop, 'different-size cards must not switch groups'); controls.get('overview-inline-save').fire('click'); await refresh(); assert.equal(writes.length, 3, 'blocked cross-group move must not write'); assert.deepEqual([...current.dashboard_widgets], beforeGroups); console.log('PASS: cross-size drops cannot break symmetric grid groups');