import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { readFileSync } from 'fs'; import path from 'path'; // The viewer is hand-maintained HTML; run its script against its own markup const html = readFileSync(path.resolve(__dirname, '../../web/popout-viewer.html'), 'utf8'); const bodyHtml = html.slice(html.indexOf('') + 6, html.indexOf('')); describe('Pop-out viewer editing', () => { let channel: { name: string; onmessage: ((e: { data: unknown }) => void) | null; postMessage: ReturnType }; let rows: any[]; const posted = (type: string) => channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === type).map((m: any) => m.data); const edits = () => posted('widget-edit'); const sidebar = () => document.getElementById('sidebar-content')!; const valueOf = (name: string) => [...sidebar().querySelectorAll('[data-kind="value"]')] .find(el => rows[Number((el as HTMLElement).dataset.widget)].widgetName === name) as HTMLElement; // A row as the Inspector builds it const row = (widgetName: string, widgetType: string, rawValue: unknown, extra: object = {}) => ({ label: widgetName, widgetName, widgetType, value: String(rawValue), rawValue, isEditable: true, isLinked: false, isActionable: false, constraints: {}, ...extra }); const node = (id: unknown = 5) => ({ id, title: 'Node', type: 'Node', mode: 'Always', executionId: String(id), graphPath: [], isSubgraph: false }); const nav = (canEdit = true) => ({ source: 'locked', locked: canEdit, canBack: false, canForward: false, canEdit }); const links = { inputs: [], outputs: [] }; const sendInfo = (list: any[], canEdit = true) => { rows = list; channel.onmessage!({ data: { type: 'info', data: { node: node(), nav: nav(canEdit), rows, links } } }); }; const receive = (type: string, data?: unknown) => channel.onmessage!({ data: { type, data } }); const navButton = (action: string) => document.querySelector(`.node-nav [data-action="${action}"]`) as HTMLButtonElement; const palette = { scheme: 'dark', fontFamily: 'Inter, Arial, sans-serif', fontSize: '16px', properties: { '--fg-color': '#dddddd' } }; const key = (el: Element, k: string, init: KeyboardEventInit = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init })); beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('localStorage', new Storage()); vi.stubGlobal('BroadcastChannel', class { onmessage = null; postMessage = vi.fn(); close() { } constructor(public name: string) { channel = this as any; } }); history.replaceState(null, '', '/popout-viewer.html?v=1&session=abc123'); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ clearRect: vi.fn(), drawImage: vi.fn() } as any); vi.spyOn(window, 'close').mockImplementation(() => { }); document.body.innerHTML = bodyHtml; new Function(script)(); }); afterEach(() => { history.replaceState(null, '', '/'); document.documentElement.removeAttribute('style'); document.documentElement.className = ''; document.body.innerHTML = ''; vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); it('edits names with quotes, and text keeps its newlines in a textarea', () => { sendInfo([row(`it's "quoted"`, 'multiline', 'line one\nline two')]); valueOf(`it's "quoted"`).click(); const textarea = sidebar().querySelector('textarea')!; expect(textarea.value).toBe('line one\nline two'); key(textarea, 'Enter'); // a newline, not a commit expect(edits()).toEqual([]); textarea.value = 'line one\nline three'; key(textarea, 'Enter', { ctrlKey: true }); expect(edits()).toEqual([{ nodeId: 5, widgetName: `it's "quoted"`, value: 'line one\nline three' }]); }); it('number inputs take min, max and step from the Inspector, and Escape cancels without closing', () => { sendInfo([row('denoise', 'FLOAT', 0.5, { constraints: { min: 0, max: 1, step: 0.01 } }), row('seed', 'INT', 3), row('cfg', 'FLOAT', 8)]); valueOf('denoise').click(); const input = sidebar().querySelector('input[type="number"]') as HTMLInputElement; expect([input.min, input.max, input.step]).toEqual(['0', '1', '0.01']); input.value = '0.7'; key(input, 'Escape'); expect(edits()).toEqual([]); expect(window.close).not.toHaveBeenCalled(); expect(sidebar().querySelector('input[type="number"]')).toBeNull(); valueOf('denoise').click(); const again = sidebar().querySelector('input[type="number"]') as HTMLInputElement; again.value = '0.7'; key(again, 'Enter'); expect(edits()).toEqual([{ nodeId: 5, widgetName: 'denoise', value: 0.7 }]); // INT steps by whole numbers without a step of its own; FLOAT takes any value valueOf('seed').click(); expect((sidebar().querySelector('input[type="number"]') as HTMLInputElement).step).toBe('1'); key(sidebar().querySelector('input[type="number"]')!, 'Escape'); valueOf('cfg').click(); expect((sidebar().querySelector('input[type="number"]') as HTMLInputElement).step).toBe('any'); }); it('numbers step with the arrows and steppers, Shift x10 and Alt x0.1, at their precision; INT stays whole', () => { sendInfo([ row('cfg', 'FLOAT', 8, { constraints: { min: 0, max: 30, step: 0.5, precision: 2 } }), row('steps', 'INT', 20, { constraints: { min: 1, max: 100, step: 1, precision: 0 } }) ]); const input = () => sidebar().querySelector('input[type="number"]') as HTMLInputElement; valueOf('cfg').click(); expect(input().value).toBe('8.00'); key(input(), 'ArrowUp'); key(input(), 'ArrowUp', { shiftKey: true }); key(input(), 'ArrowDown', { altKey: true }); (sidebar().querySelector('.widget-editor-stepper[aria-label="Decrease"]') as HTMLElement).click(); expect(input().value).toBe('12.95'); // Each step is sent at once, as in the Inspector expect(edits().map(e => e.value)).toEqual([8.5, 13.5, 13.45, 12.95]); // Leaving without another change sends nothing more input().dispatchEvent(new Event('blur')); expect(edits()).toHaveLength(4); valueOf('steps').click(); key(input(), 'ArrowDown', { altKey: true }); expect(input().value).toBe('19'); input().value = '100.4'; key(input(), 'ArrowUp', { shiftKey: true }); expect(input().value).toBe('100'); input().value = '42.6'; key(input(), 'Enter'); expect(edits().slice(4).map(e => e.value)).toEqual([19, 100, 43]); }); it('a value is sent only when it changed', () => { sendInfo([row('denoise', 'FLOAT', 0.5), row('prompt', 'text', 'cat')]); valueOf('denoise').click(); key(sidebar().querySelector('input')!, 'Enter'); valueOf('prompt').click(); sidebar().querySelector('input')!.dispatchEvent(new Event('blur')); expect(edits()).toEqual([]); // Unchanged, the rows are back as they were expect(valueOf('prompt').textContent).toBe('cat'); valueOf('prompt').click(); (sidebar().querySelector('input') as HTMLInputElement).value = 'dog'; key(sidebar().querySelector('input')!, 'Enter'); expect(edits()).toEqual([{ nodeId: 5, widgetName: 'prompt', value: 'dog' }]); // Shown until ComfyUI answers, and its answer renders even when it kept the old value expect(valueOf('prompt').textContent).toBe('dog'); sendInfo(rows); expect(valueOf('prompt').textContent).toBe('cat'); }); it('a combo asks for its options, drops a reply for another widget, and a pick keeps the option type', () => { sendInfo([row('size', 'combo', 512)]); valueOf('size').click(); expect(posted('request-options')).toEqual([{ nodeId: 5, widgetName: 'size' }]); let select = sidebar().querySelector('select')!; // An info push leaves the waiting editor open sendInfo(rows); expect(sidebar().querySelector('select')).toBe(select); channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'other', options: ['a'] } } }); expect(select.options).toHaveLength(1); channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'size', options: [512, 768] } } }); expect([...select.options].map(o => o.textContent)).toEqual(['512', '768']); // It opens on the current value, and leaving it unchanged sends nothing expect(select.selectedIndex).toBe(0); select.dispatchEvent(new Event('blur')); expect(edits()).toEqual([]); expect(sidebar().querySelector('select')).toBeNull(); valueOf('size').click(); select = sidebar().querySelector('select')!; channel.onmessage!({ data: { type: 'options', data: { nodeId: 5, widgetName: 'size', options: [512, 768] } } }); select.selectedIndex = 1; select.dispatchEvent(new Event('change')); expect(edits()).toEqual([{ nodeId: 5, widgetName: 'size', value: 768 }]); }); it('a combo whose list can no longer open itself still fills', () => { // The click's user activation ran out while the options were on their way HTMLSelectElement.prototype.showPicker = () => { throw new DOMException('No user activation', 'NotAllowedError'); }; sendInfo([row('size', 'combo', 512)]); valueOf('size').click(); receive('options', { nodeId: 5, widgetName: 'size', options: [512, 768] }); delete (HTMLSelectElement.prototype as any).showPicker; const select = sidebar().querySelector('select')!; expect([...select.options].map(o => o.textContent)).toEqual(['512', '768']); select.selectedIndex = 1; select.dispatchEvent(new Event('change')); expect(edits()).toEqual([{ nodeId: 5, widgetName: 'size', value: 768 }]); }); it('keeps an open editor while info arrives, and values without a primitive raw value are not editable', () => { sendInfo([row('seed', 'INT', 1), row('data', 'text', undefined, { value: '{"a": 1}' })]); expect(valueOf('data')).toBeUndefined(); valueOf('seed').click(); const input = sidebar().querySelector('input[type="number"]'); sendInfo(rows); expect(sidebar().querySelector('input[type="number"]')).toBe(input); }); it('edits only rows the Inspector allows, and shows an empty string as an editable empty value', () => { sendInfo([row('model', 'combo', 'a', { isLinked: true, isEditable: false }), row('prompt', 'text', '')]); expect(valueOf('model')).toBeUndefined(); expect(sidebar().textContent).toContain('linked'); const empty = valueOf('prompt'); expect(empty.querySelector('.value-empty')!.textContent).toBe('empty'); empty.click(); expect((sidebar().querySelector('input[type="text"]') as HTMLInputElement).value).toBe(''); key(sidebar().querySelector('input[type="text"]')!, 'Escape'); // Without a lock, Sticky or the selection nothing is editable sendInfo(rows, false); expect(sidebar().querySelector('[data-kind="value"]')).toBeNull(); expect(sidebar().querySelector('.value-empty')).not.toBeNull(); }); it('buttons send their widget name, booleans flip, and a button with a boolean value is still a button', () => { sendInfo([ row(`o'clock`, 'button', false, { isActionable: true, isEditable: false }), row(`run "now"`, 'button', null, { isActionable: true, isEditable: false }), row('enabled', 'boolean', false) ]); expect(sidebar().querySelector('[data-kind="toggle"]')).toBeNull(); for (const button of sidebar().querySelectorAll('[data-kind="action"]')) button.click(); expect(posted('button-click')).toEqual([{ nodeId: 5, widgetName: `o'clock` }, { nodeId: 5, widgetName: `run "now"` }]); // A boolean is a switch, disabled until the node can be edited const toggle = valueOf('enabled'); expect([toggle.getAttribute('role'), toggle.getAttribute('aria-checked')]).toEqual(['switch', 'false']); toggle.click(); expect(edits()).toEqual([{ nodeId: 5, widgetName: 'enabled', value: true }]); expect(toggle.getAttribute('aria-checked')).toBe('true'); sendInfo(rows, false); expect((sidebar().querySelector('[role="switch"]') as HTMLButtonElement).disabled).toBe(true); }); it('a data:image value renders only the image, never its markup', () => { const png = 'data:image/png;base64,iVBORw0KGgo='; sendInfo([ row('evil', 'text', ` data:image/png;base64,AAAA`), row('plot', 'text', ``), ]); expect(sidebar().querySelector('[onerror]')).toBeNull(); expect(sidebar().querySelector('img[src="x"]')).toBeNull(); expect([...sidebar().querySelectorAll('img')].map(img => img.getAttribute('src'))).toEqual(['data:image/png;base64,AAAA', png]); expect((window as any).__pwned).toBeUndefined(); }); it('node ids stay text, and navigation posts the shown node through delegated listeners', () => { const id = `1); window.__pwned = (1`; channel.onmessage!({ data: { type: 'info', data: { node: node(id), nav: nav(false), rows: [], links } } }); expect(document.querySelector('[onclick]')).toBeNull(); expect(document.querySelector('.node-id')!.textContent).toBe(`#${id}`); for (const action of ['back', 'forward', 'prev-node', 'next-node', 'focus-node', 'lock-node']) navButton(action).click(); const sent = channel.postMessage.mock.calls.map(([m]) => m).filter((m: any) => m.type === 'panel-action'); // Back and Forward have nowhere to go expect(sent).toEqual(['prev-node', 'next-node', 'focus-node', 'lock-node'].map(name => ({ type: 'panel-action', data: { name, nodeId: id } }))); expect((window as any).__pwned).toBeUndefined(); }); it('the header mirrors the Inspector: identity, navigation state, lock and breadcrumb', () => { const title = () => document.querySelector('.node-name')!.textContent; expect(title()).toBe('Inspector'); expect(navButton('prev-node').disabled).toBe(true); channel.onmessage!({ data: { type: 'info', data: { node: { ...node(), title: 'Sampler', type: 'KSampler', graphPath: ['Refine'], group: 'Sampling', isSubgraph: true }, nav: { source: 'hover', locked: false, canBack: true, canForward: false, canEdit: false }, rows: [row('seed', 'INT', 1)], links } } }); expect([title(), document.querySelector('.node-id')!.textContent, document.querySelector('.node-badge')!.textContent]).toEqual(['Sampler', '#5', 'KSampler']); expect([navButton('back').disabled, navButton('forward').disabled, navButton('lock-node').getAttribute('aria-pressed')]).toEqual([false, true, 'false']); const breadcrumb = document.querySelector('.node-breadcrumb') as HTMLElement; expect([...breadcrumb.querySelectorAll('.crumb')].map(c => c.textContent)).toEqual(['Root', 'Refine']); expect(breadcrumb.querySelector('b')).toBeNull(); expect(breadcrumb.querySelector('.crumb-group')!.textContent).toBe('Sampling'); // Unlocked, the rows offer the lock; Enter subgraph and the hint post like the buttons (breadcrumb.querySelector('[data-action="enter-subgraph"]') as HTMLElement).click(); (sidebar().querySelector('.edit-hint') as HTMLElement).click(); expect(posted('panel-action').map((d: any) => d.name)).toEqual(['enter-subgraph', 'lock-node']); sendInfo(rows); expect(navButton('lock-node').getAttribute('aria-pressed')).toBe('true'); expect(sidebar().querySelector('.edit-hint')).toBeNull(); expect(breadcrumb.hidden).toBe(true); }); it('re-renders only when the info changed, so hover, focus and selection stay', () => { sendInfo([row('seed', 'INT', 1)]); const copy = sidebar().querySelector('.copy-btn') as HTMLButtonElement; copy.focus(); sendInfo([row('seed', 'INT', 1)]); expect(sidebar().querySelector('.copy-btn')).toBe(copy); expect(document.activeElement).toBe(copy); sendInfo([row('seed', 'INT', 2)]); expect(sidebar().querySelector('.copy-btn')).not.toBe(copy); expect(valueOf('seed').textContent).toBe('2'); }); it('copies values and the title, and long values show more or less across renders', async () => { const writeText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { clipboard: { writeText } }); const long = 'word '.repeat(60); sendInfo([row('seed', 'INT', 7), row('prompt', 'text', long)]); const copies = sidebar().querySelectorAll('.copy-btn'); expect(copies[0].getAttribute('aria-label')).toBe('Copy Mode'); copies[copies.length - 1].click(); (document.querySelector('.node-identity') as HTMLButtonElement).click(); await vi.waitFor(() => expect(writeText).toHaveBeenCalledTimes(2)); expect(writeText.mock.calls).toEqual([[long], ['Node #5']]); await vi.waitFor(() => expect(copies[copies.length - 1].classList.contains('copied')).toBe(true)); const expand = sidebar().querySelector('.value-expand') as HTMLButtonElement; expect(valueOf('prompt').classList.contains('is-clamped')).toBe(true); expand.click(); expect([expand.textContent, expand.getAttribute('aria-expanded')]).toEqual(['Show less', 'true']); sendInfo([row('seed', 'INT', 8), row('prompt', 'text', long)]); expect(sidebar().querySelector('.info-row.is-expanded')).not.toBeNull(); expect(sidebar().querySelector('.value-expand')!.textContent).toBe('Show less'); }); it('sections collapse from a button that says whether it is expanded', () => { sendInfo([row('seed', 'INT', 1)]); const header = () => sidebar().querySelector('.section-header') as HTMLButtonElement; expect(header().tagName).toBe('BUTTON'); header().click(); expect(header().getAttribute('aria-expanded')).toBe('false'); sendInfo([row('seed', 'INT', 2)]); expect(header().getAttribute('aria-expanded')).toBe('false'); expect(sidebar().querySelector('.info-section.collapsed')).not.toBeNull(); }); it('the picker searches in ComfyUI, drops stale replies, and picks with the keyboard', () => { sendInfo([]); const picker = document.querySelector('.node-picker') as HTMLElement; const filter = document.querySelector('.node-picker-filter') as HTMLInputElement; const items = () => [...picker.querySelectorAll('[role="option"]')]; const nodes = [{ id: 3, title: 'Loader', type: 'Checkpoint', order: 0 }, { id: 5, title: 'Node', type: 'Node', order: 1 }]; navButton('go-to-node').click(); expect(picker.hidden).toBe(false); expect(document.activeElement).toBe(filter); expect(posted('search-nodes')).toEqual(['']); receive('nodes-list', { query: '', nodes }); // Unfiltered, it opens on the shown node expect(items().map(i => [i.getAttribute('aria-selected'), i.classList.contains('active')])).toEqual([['false', false], ['true', true]]); filter.value = 'lo'; filter.dispatchEvent(new Event('input')); expect(posted('search-nodes')).toEqual(['', 'lo']); // A reply to an earlier query is dropped receive('nodes-list', { query: 'l', nodes: [] }); expect(items()).toHaveLength(2); receive('nodes-list', { query: 'lo', nodes: nodes.slice(0, 1) }); expect(items().map(i => i.querySelector('.dropdown-item-title')!.textContent)).toEqual(['Loader']); key(filter, 'Enter'); expect(posted('node-select')).toEqual([3]); expect(picker.hidden).toBe(true); expect(document.activeElement).toBe(navButton('go-to-node')); }); it('←/→, L and Ctrl+K act outside typing fields only, and Escape closes the picker, never the window', () => { sendInfo([row('prompt', 'text', 'cat')]); const picker = document.querySelector('.node-picker') as HTMLElement; key(document.body, 'ArrowRight'); key(document.body, 'ArrowLeft'); key(document.body, 'l'); key(document.body, 'ArrowLeft', { ctrlKey: true }); expect(posted('panel-action').map((d: any) => d.name)).toEqual(['next-node', 'prev-node', 'lock-node']); valueOf('prompt').click(); key(sidebar().querySelector('input')!, 'ArrowRight'); key(sidebar().querySelector('input')!, 'l'); expect(posted('panel-action')).toHaveLength(3); key(sidebar().querySelector('input')!, 'Escape'); key(document.body, 'k', { ctrlKey: true }); expect(picker.hidden).toBe(false); key(document.querySelector('.node-picker-filter')!, 'Escape'); expect(picker.hidden).toBe(true); navButton('go-to-node').click(); key(document.body, 'Escape'); expect(picker.hidden).toBe(true); key(document.body, 'Escape'); expect(window.close).not.toHaveBeenCalled(); expect(channel.postMessage.mock.calls.map(([m]) => m.type)).not.toContain('close'); // Nothing to navigate while the Inspector is off channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: false } } }); key(document.body, 'ArrowRight'); key(document.body, 'k', { ctrlKey: true }); expect(posted('panel-action')).toHaveLength(3); expect(picker.hidden).toBe(true); }); it('the run bar shows the running node, its progress and the queue, and the shown node its state', () => { const run = (data: object) => receive('run', { queue: 0, nodeId: null, title: '', value: 0, max: 0, cached: [], done: [], error: null, here: true, ...data }); const bar = () => [...document.querySelectorAll('.run-bar > span')].map(el => el.textContent); const fill = () => (document.querySelector('.run-progress-fill') as HTMLElement).style.width; const badge = () => document.querySelector('.run-badge') as HTMLElement; expect(bar()).toEqual(['Idle', 'Queue 0']); sendInfo([row('seed', 'INT', 1)]); valueOf('seed').click(); const input = sidebar().querySelector('input[type="number"]'); expect(badge().hidden).toBe(true); run({ queue: 2, nodeId: '9', title: 'Loader', value: 1, max: 4 }); expect(bar()).toEqual(['Loader #9', 'Queue 2']); expect(document.querySelector('.run-bar b, .run-bar i')).toBeNull(); expect(fill()).toBe('25%'); expect(badge().hidden).toBe(true); run({ queue: 2, nodeId: '5', title: 'Node', value: 3, max: 20, cached: ['1'], done: ['9'] }); expect([badge().hidden, badge().textContent, badge().dataset.state]).toEqual([false, 'Running 3/20', 'running']); // The badge updates without rendering the rows, so an open editor stays expect(sidebar().querySelector('input[type="number"]')).toBe(input); run({ queue: 1, cached: ['5'] }); expect([badge().textContent, bar()[0], fill()]).toEqual(['Cached', 'Queued', '0%']); run({ done: ['5'] }); expect([badge().textContent, bar()[0]]).toEqual(['Done', 'Idle']); // Another node shown takes its own state key(input!, 'Escape'); receive('info', { node: node(9), nav: nav(), rows: [], links }); expect(badge().hidden).toBe(true); }); it('shows the last error with Go to node and Copy traceback, and marks the inputs that failed validation', async () => { const writeText = vi.fn().mockResolvedValue(undefined); vi.stubGlobal('navigator', { clipboard: { writeText } }); const banner = document.querySelector('.error-banner') as HTMLElement; const error = { nodeId: '7:5', title: '', type: 'KSampler', message: '', traceback: 'Traceback:\n line 1\n' }; expect(banner.hidden).toBe(true); receive('info', { node: { ...node(5), executionId: '7:5' }, nav: nav(), rows: [], links }); const failed = { queue: 0, nodeId: null, title: '', value: 0, max: 0, cached: [], done: ['7:5'], error, here: true }; receive('run', failed); expect(banner.hidden).toBe(false); expect(banner.querySelector('.error-text')!.textContent).toBe(`KSampler #7:5 ${error.message}`); expect(banner.querySelector('img')).toBeNull(); expect(document.querySelector('.run-badge')!.textContent).toBe('Error'); // In another workflow tab, node 7:5 is another node receive('run', { ...failed, here: false }); expect((document.querySelector('.run-badge') as HTMLElement).hidden).toBe(true); expect(banner.hidden).toBe(false); (banner.querySelector('[data-action="go-to-error"]') as HTMLElement).click(); expect(posted('run-node-select')).toEqual(['7:5']); (banner.querySelector('[data-action="copy-traceback"]') as HTMLElement).click(); await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(`Traceback:\n line 1\n${error.message}`)); receive('run', { queue: 0, nodeId: null, title: '', value: 0, max: 0, cached: [], done: [], error: null, here: true }); expect(banner.hidden).toBe(true); sendInfo([row('ckpt_name', 'combo', 'x'), row('steps', 'INT', 20, { error: 'Value not in list: x' })]); const marked = [...sidebar().querySelectorAll('.info-row.has-error')]; expect(marked.map(el => el.querySelector('.row-error')!.textContent)).toEqual(['Value not in list: x']); expect(marked[0].querySelector('b')).toBeNull(); expect((window as any).__pwned).toBeUndefined(); }); it('shows the node outputs as thumbnails, one large in Output, and Escape returns to the glass', () => { const main = document.querySelector('.main-container') as HTMLElement; const strip = document.querySelector('.node-outputs') as HTMLElement; const thumbs = () => [...strip.querySelectorAll('.output-thumb')] as HTMLButtonElement[]; const large = document.querySelector('.output-image') as HTMLImageElement; const outputs = (id: number, urls: string[]) => receive('info', { node: node(id), nav: nav(), rows: [], outputs: urls, links }); receive('ping'); (document.querySelector('[data-view="glass"]') as HTMLElement).click(); outputs(5, []); expect(strip.hidden).toBe(true); outputs(5, ['/api/view?filename=a.png&type=output', '/api/view?filename=b">.png&type=output']); expect(strip.hidden).toBe(false); expect(thumbs().map(thumb => thumb.querySelector('img')!.getAttribute('src'))).toEqual(['/api/view?filename=a.png&type=output', '/api/view?filename=b">.png&type=output']); expect(strip.querySelectorAll('img')).toHaveLength(2); thumbs()[1].click(); expect(main.dataset.view).toBe('output'); expect(large.getAttribute('src')).toBe('/api/view?filename=b">.png&type=output'); expect(thumbs().map(thumb => thumb.getAttribute('aria-pressed'))).toEqual(['false', 'true']); // No glass frame is shown expect(posted('visibility').at(-1)).toBe(true); // A live preview replaces the results in place const first = thumbs()[0]; outputs(5, ['blob:http://127.0.0.1/preview']); expect(thumbs()).toEqual([first]); expect(large.getAttribute('src')).toBe('blob:http://127.0.0.1/preview'); // New previews leave the rows as they are receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/a'], links }); const rows = sidebar().innerHTML; const seed = valueOf('seed'); receive('info', { node: node(5), nav: nav(), rows: [row('seed', 'INT', 1)], outputs: ['blob:http://127.0.0.1/b'], links }); expect(valueOf('seed')).toBe(seed); expect(sidebar().innerHTML).toBe(rows); expect(large.getAttribute('src')).toBe('blob:http://127.0.0.1/b'); // Another node without outputs outputs(6, []); expect([strip.hidden, large.hidden]).toEqual([true, true]); expect((document.querySelector('.output-empty') as HTMLElement).hidden).toBe(false); // An editor's Escape stays in Output; Escape then returns to the view with the glass sendInfo([row('seed', 'INT', 1)]); valueOf('seed').click(); key(sidebar().querySelector('input')!, 'Escape'); expect(main.dataset.view).toBe('output'); key(document.body, 'Escape'); expect(main.dataset.view).toBe('glass'); expect(posted('visibility').at(-1)).toBe(false); expect(JSON.parse(localStorage.getItem('magnifyglass-viewer-layout')!).view).toBe('glass'); (document.querySelector('[data-view="output"]') as HTMLElement).click(); expect(main.dataset.view).toBe('output'); }); it('lists the inputs and outputs with a chip for each linked node, which jumps to it, and flags required inputs left unconnected', () => { const section = (id: string) => sidebar().querySelector(`[data-section-id="${id}"]`); const chips = (id: string) => [...section(id)!.querySelectorAll('.link-chip')] as HTMLButtonElement[]; receive('info', { node: node(5), nav: nav(), rows: [], outputs: [], links: { inputs: [ { name: 'model', type: 'MODEL', linked: [{ id: 1, title: '', slot: 'MODEL' }], missing: false }, { name: 'latent_image', type: 'LATENT', linked: [], missing: true }, { name: 'mask', type: 'MASK', linked: [], missing: false } ], outputs: [{ name: 'LATENT', type: 'LATENT', linked: [{ id: 8, title: 'Decode', slot: 'samples' }, { id: 9, title: 'Preview', slot: 'images' }, { title: 'Subgraph output', slot: 'latent' }] }] } }); expect(chips('inputs').map(chip => chip.textContent)).toEqual([' #1 · MODEL']); expect(sidebar().querySelector('img')).toBeNull(); expect(chips('outputs').map(chip => chip.textContent)).toEqual(['Decode #8 · samples', 'Preview #9 · images', 'Subgraph output · latent']); expect(chips('outputs').map(chip => chip.tagName)).toEqual(['BUTTON', 'BUTTON', 'SPAN']); const inputs = [...section('inputs')!.querySelectorAll('.info-row')]; expect(inputs.map(row => row.classList.contains('has-error'))).toEqual([false, true, false]); expect(inputs[1].querySelector('.row-error')!.textContent).toBe('Required, but nothing is connected'); expect(inputs[2].querySelector('.value-empty')!.textContent).toBe('not connected'); // node-select locks and centres the node in ComfyUI chips('outputs')[2].click(); chips('outputs')[1].click(); chips('inputs')[0].click(); expect(posted('node-select')).toEqual([9, 1]); expect((window as any).__pwned).toBeUndefined(); (section('outputs')!.querySelector('.section-header') as HTMLElement).click(); expect(JSON.parse(localStorage.getItem('magnifyglass-viewer-layout')!).collapsed).toEqual(['outputs']); // A node without slots has neither section receive('info', { node: node(6), nav: nav(), rows: [], outputs: [], links }); expect([section('node'), section('inputs'), section('outputs')].map(Boolean)).toEqual([true, false, false]); }); it('shows the empty state for no node, with no Cursor section', () => { channel.onmessage!({ data: { type: 'info', data: { node: null, nav: nav(false), rows: [] } } }); expect(sidebar().querySelector('.empty-state')).not.toBeNull(); expect(sidebar().textContent).not.toContain('Cursor'); }); it('listens on the channel of the session in its URL, and announces itself until pinged', () => { const pongs = () => channel.postMessage.mock.calls.filter(([m]) => m.type === 'pong').length; expect(channel.name).toBe('comfyui-magnifyglass-popout:abc123'); expect(pongs()).toBe(1); vi.advanceTimersByTime(2000); expect(pongs()).toBe(3); channel.onmessage!({ data: { type: 'ping' } }); expect(pongs()).toBe(4); vi.advanceTimersByTime(2000); expect(pongs()).toBe(4); }); it('status follows pings and config, and never wipes the sidebar or an open editor', () => { const label = () => document.querySelector('.status-label')!.textContent; const ping = () => channel.onmessage!({ data: { type: 'ping' } }); const config = (glass: string) => channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass, inspector: true } } }); ping(); config('off'); sendInfo([row('seed', 'INT', 1)]); valueOf('seed').click(); const input = sidebar().querySelector('input[type="number"]'); expect(label()).toBe('Glass off'); expect(document.getElementById('viewer-canvas')!.classList.contains('dimmed')).toBe(true); // No frames for a while is not a disconnect for (let i = 0; i < 5; i++) { vi.advanceTimersByTime(1000); ping(); } expect(label()).toBe('Glass off'); config('hidden'); expect(label()).toBe('Glass hidden'); vi.advanceTimersByTime(4000); expect(label()).toBe('ComfyUI tab inactive'); channel.onmessage!({ data: { type: 'close' } }); expect(label()).toBe('Disconnected from ComfyUI'); expect(window.close).not.toHaveBeenCalled(); expect(sidebar().querySelector('input[type="number"]')).toBe(input); }); it('says when the Inspector is off in ComfyUI', () => { sendInfo([row('seed', 'INT', 1)]); channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: false } } }); expect(sidebar().textContent).toContain('The Inspector is off in ComfyUI'); channel.onmessage!({ data: { type: 'config', data: { palette, reduceMotion: false, zoom: 300, glass: 'on', inspector: true } } }); expect(valueOf('seed')).toBeDefined(); }); it('applies the palette ComfyUI sends, drops an override that went away, and follows Reduce Motion', () => { const root = document.documentElement; const config = (properties: Record, scheme: string, reduceMotion: boolean) => channel.onmessage!({ data: { type: 'config', data: { glass: 'on', inspector: true, reduceMotion, zoom: 300, palette: { ...palette, scheme, properties } } } }); config({ '--fg-color': '#222222', '--mg-panel-accent': '#ff8800' }, 'light', true); expect(root.style.getPropertyValue('--fg-color')).toBe('#222222'); expect(root.style.getPropertyValue('--mg-panel-accent')).toBe('#ff8800'); expect(root.style.colorScheme).toBe('light'); expect(root.style.fontFamily).toBe('Inter, Arial, sans-serif'); expect(root.style.fontSize).toBe('16px'); expect(root.classList.contains('reduce-motion')).toBe(true); config({ '--fg-color': '#dddddd', '--mg-panel-accent': '' }, 'dark', false); expect(root.style.getPropertyValue('--fg-color')).toBe('#dddddd'); expect(root.style.getPropertyValue('--mg-panel-accent')).toBe(''); expect(root.style.colorScheme).toBe('dark'); expect(root.classList.contains('reduce-motion')).toBe(false); }); it('zoom buttons and the wheel write the zoom once they rest for 250 ms, within 100-1000%', () => { const zoomIn = document.getElementById('zoom-in') as HTMLButtonElement; const zoomOut = document.getElementById('zoom-out') as HTMLButtonElement; const shown = () => document.querySelector('.zoom-value')!.textContent; const config = (zoom: number) => receive('config', { palette, reduceMotion: false, zoom, glass: 'on', inspector: true }); const wheel = (deltaY: number) => document.querySelector('.canvas-section')!.dispatchEvent(new WheelEvent('wheel', { deltaY, bubbles: true, cancelable: true })); expect(zoomIn.disabled).toBe(true); receive('ping'); config(300); expect(shown()).toBe('300%'); zoomIn.click(); zoomIn.click(); wheel(-100); expect(shown()).toBe('375%'); vi.advanceTimersByTime(249); // ComfyUI's older zoom does not replace one still waiting to be written config(300); expect(shown()).toBe('375%'); expect(posted('zoom')).toEqual([]); vi.advanceTimersByTime(1); expect(posted('zoom')).toEqual([375]); // A touchpad's small deltas add up to a step wheel(40); wheel(40); expect(shown()).toBe('375%'); wheel(40); expect(shown()).toBe('350%'); zoomOut.click(); vi.advanceTimersByTime(250); expect(posted('zoom')).toEqual([375, 325]); config(1000); expect(zoomIn.disabled).toBe(true); wheel(-100); vi.advanceTimersByTime(250); expect(posted('zoom')).toHaveLength(2); config(110); zoomOut.click(); vi.advanceTimersByTime(250); expect(posted('zoom')).toEqual([375, 325, 100]); }); it('keeps the view mode, the Inspector width and the collapsed sections across reloads', () => { const main = document.querySelector('.main-container') as HTMLElement; const viewButton = (view: string) => document.querySelector(`[data-view="${view}"]`) as HTMLButtonElement; const divider = document.querySelector('.inspector-divider') as HTMLElement; vi.spyOn(main, 'getBoundingClientRect').mockReturnValue({ right: 1000, width: 1000 } as DOMRect); divider.setPointerCapture = vi.fn(); receive('ping'); sendInfo([row('seed', 'INT', 1)]); viewButton('info').click(); expect(main.dataset.view).toBe('info'); expect(viewButton('info').getAttribute('aria-pressed')).toBe('true'); expect(viewButton('split').getAttribute('aria-pressed')).toBe('false'); expect(main.hasAttribute('aria-pressed')).toBe(false); // Info shows no frame, so ComfyUI need not send any expect(posted('visibility').at(-1)).toBe(true); viewButton('glass').click(); expect(posted('visibility').at(-1)).toBe(false); divider.dispatchEvent(new PointerEvent('pointerdown', { button: 0, pointerId: 1 })); divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 600 })); expect(main.style.getPropertyValue('--inspector-w')).toBe('400px'); // The frame keeps its minimum, the Inspector its own divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 0 })); expect(main.style.getPropertyValue('--inspector-w')).toBe('808px'); divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 990 })); expect(main.style.getPropertyValue('--inspector-w')).toBe('240px'); divider.dispatchEvent(new PointerEvent('pointermove', { clientX: 650 })); divider.dispatchEvent(new Event('lostpointercapture')); const visibilityPosts = posted('visibility').length; (sidebar().querySelector('.section-header') as HTMLElement).click(); expect(posted('visibility').length).toBe(visibilityPosts); document.body.innerHTML = bodyHtml; new Function(script)(); sendInfo([row('seed', 'INT', 1)]); const reloaded = document.querySelector('.main-container') as HTMLElement; expect(reloaded.dataset.view).toBe('glass'); expect(document.querySelector('[data-view="glass"]')!.getAttribute('aria-pressed')).toBe('true'); expect(reloaded.style.getPropertyValue('--inspector-w')).toBe('350px'); expect(sidebar().querySelector('.info-section.collapsed')).not.toBeNull(); // Double-click resets the width document.querySelector('.inspector-divider')!.dispatchEvent(new Event('dblclick')); expect(reloaded.style.getPropertyValue('--inspector-w')).toBe(''); expect(JSON.parse(localStorage.getItem('magnifyglass-viewer-layout')!).inspectorWidth).toBeNull(); }); it('the divider resizes with the arrow keys, not Prev/Next', () => { const main = document.querySelector('.main-container') as HTMLElement; const divider = document.querySelector('.inspector-divider') as HTMLElement; vi.spyOn(main, 'getBoundingClientRect').mockReturnValue({ right: 1000, width: 1000 } as DOMRect); vi.spyOn(document.getElementById('inspector')!, 'getBoundingClientRect').mockReturnValue({ width: 320 } as DOMRect); sendInfo([]); key(divider, 'ArrowLeft'); expect(main.style.getPropertyValue('--inspector-w')).toBe('336px'); key(divider, 'ArrowRight'); expect(main.style.getPropertyValue('--inspector-w')).toBe('304px'); expect(posted('panel-action')).toEqual([]); }); it('draws the frames ComfyUI hands it at their own size, and announces its window to its opener', () => { const canvas = document.getElementById('viewer-canvas') as HTMLCanvasElement; const ctx = canvas.getContext('2d') as any; const source = { width: 400, height: 400 }; (window as any).drawFrame(source); expect([canvas.width, canvas.height]).toEqual([400, 400]); expect(ctx.drawImage).toHaveBeenCalledWith(source, 0, 0); expect(document.querySelector('.canvas-placeholder')!.classList.contains('hidden')).toBe(true); const opener = { postMessage: vi.fn() }; vi.stubGlobal('opener', opener); vi.advanceTimersByTime(1000); expect(opener.postMessage).toHaveBeenCalledWith({ type: 'magnifyglass-viewer-window', session: 'abc123' }, location.origin); }); it('starts from its defaults when storage is blocked', () => { vi.stubGlobal('localStorage', { getItem() { throw new DOMException('Blocked', 'SecurityError'); } }); document.body.innerHTML = bodyHtml; new Function(script)(); expect((document.querySelector('.main-container') as HTMLElement).dataset.view).toBe('split'); expect(document.getElementById('fit-btn')!.getAttribute('aria-pressed')).toBe('false'); }); });