|
| 1 | +import { JSDOM } from 'jsdom'; |
| 2 | +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; |
| 3 | +import type { LJContext, LJVariable } from '../types/context'; |
| 4 | +import type { LJDiagnostic, SourcePosition } from '../types/diagnostics'; |
| 5 | +import type { LJStateMachine } from '../types/fsm'; |
| 6 | + |
| 7 | +const currentFile = '/workspace/src/Main.java'; |
| 8 | +const otherFile = '/workspace/src/Other.java'; |
| 9 | +const position: SourcePosition = { |
| 10 | + file: currentFile, |
| 11 | + lineStart: 4, |
| 12 | + colStart: 8, |
| 13 | + lineEnd: 4, |
| 14 | + colEnd: 13, |
| 15 | +}; |
| 16 | + |
| 17 | +function diagnostic(file: string, title: string, category: 'error' | 'warning' = 'error'): LJDiagnostic { |
| 18 | + const details = { |
| 19 | + title, |
| 20 | + message: `${title} message`, |
| 21 | + hint: null, |
| 22 | + file, |
| 23 | + position: { ...position, file }, |
| 24 | + }; |
| 25 | + return category === 'error' |
| 26 | + ? { ...details, category, type: 'custom-error' } |
| 27 | + : { ...details, category, type: 'custom-warning' }; |
| 28 | +} |
| 29 | + |
| 30 | +function context(file = currentFile): LJContext { |
| 31 | + const variable: LJVariable = { |
| 32 | + name: 'count', |
| 33 | + internalName: 'count_1', |
| 34 | + type: 'int', |
| 35 | + refinement: 'count > 0', |
| 36 | + mainRefinement: 'count > 0', |
| 37 | + position: { ...position, file }, |
| 38 | + annotationPosition: null, |
| 39 | + }; |
| 40 | + return { |
| 41 | + localVars: [variable], |
| 42 | + globalVars: [], |
| 43 | + ghosts: [], |
| 44 | + aliases: [], |
| 45 | + methods: [], |
| 46 | + visibleVars: [variable], |
| 47 | + allVars: [variable], |
| 48 | + fileScopes: {}, |
| 49 | + }; |
| 50 | +} |
| 51 | + |
| 52 | +const stateMachine: LJStateMachine = { |
| 53 | + className: 'Counter', |
| 54 | + initialTransitions: [{ to: 'Open' }], |
| 55 | + states: ['Open', 'Closed'], |
| 56 | + transitions: [{ from: 'Open', to: 'Closed', label: 'close()' }], |
| 57 | + errorContext: null, |
| 58 | +}; |
| 59 | + |
| 60 | +describe('webview script', () => { |
| 61 | + let dom: JSDOM; |
| 62 | + let root: HTMLElement; |
| 63 | + let postMessage: Mock<(message: unknown) => void>; |
| 64 | + let runMermaid: Mock<(options: { nodes: NodeListOf<Element> }) => Promise<void>>; |
| 65 | + |
| 66 | + beforeEach(async () => { |
| 67 | + // fresh modules and DOM isolate diagram state and event listeners |
| 68 | + vi.resetModules(); |
| 69 | + dom = new JSDOM('<div id="root"></div>'); |
| 70 | + vi.stubGlobal('window', dom.window); |
| 71 | + vi.stubGlobal('document', dom.window.document); |
| 72 | + vi.stubGlobal('Element', dom.window.Element); |
| 73 | + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); |
| 74 | + Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollIntoView', { value: vi.fn() }); |
| 75 | + runMermaid = vi.fn().mockResolvedValue(undefined); |
| 76 | + Object.defineProperty(dom.window, 'mermaid', { value: { run: runMermaid } }); |
| 77 | + postMessage = vi.fn(); |
| 78 | + root = dom.window.document.getElementById('root')!; |
| 79 | + const { getScript } = await import('./script'); |
| 80 | + getScript({ postMessage }, dom.window.document, dom.window as unknown as Window); |
| 81 | + }); |
| 82 | + |
| 83 | + afterEach(() => { |
| 84 | + dom.window.close(); |
| 85 | + vi.unstubAllGlobals(); |
| 86 | + vi.restoreAllMocks(); |
| 87 | + }); |
| 88 | + |
| 89 | + function receive(data: unknown) { |
| 90 | + dom.window.dispatchEvent(new dom.window.MessageEvent('message', { data })); |
| 91 | + } |
| 92 | + |
| 93 | + function click(selector: string): HTMLElement { |
| 94 | + const element = root.querySelector<HTMLElement>(selector); |
| 95 | + expect(element, `missing clickable element: ${selector}`).not.toBeNull(); |
| 96 | + element!.click(); |
| 97 | + return element!; |
| 98 | + } |
| 99 | + |
| 100 | + function showDiagnostics(diagnostics: LJDiagnostic[]) { |
| 101 | + receive({ type: 'file', file: currentFile }); |
| 102 | + receive({ type: 'diagnostics', diagnostics }); |
| 103 | + receive({ type: 'status', status: diagnostics.some(d => d.category === 'error') ? 'failed' : 'passed' }); |
| 104 | + } |
| 105 | + |
| 106 | + it('posts ready and renders the initial loading view', () => { |
| 107 | + expect(postMessage.mock.calls).toEqual([[{ type: 'ready' }]]); |
| 108 | + expect(root.querySelector('h2')?.textContent).toBe('Verification Pending'); |
| 109 | + expect(root.querySelector('.info')?.textContent).toContain('Running the LiquidJava verification'); |
| 110 | + }); |
| 111 | + |
| 112 | + it('switches between diagnostics, context, and state machine tabs', async () => { |
| 113 | + showDiagnostics([diagnostic(currentFile, 'Current error')]); |
| 114 | + receive({ type: 'context', context: context() }); |
| 115 | + receive({ type: 'fsm', sm: stateMachine }); |
| 116 | + postMessage.mockClear(); |
| 117 | + |
| 118 | + click('[data-tab="context"]'); |
| 119 | + expect(root.querySelector('.nav-tab.selected')?.getAttribute('data-tab')).toBe('context'); |
| 120 | + expect(root.querySelector('#context-vars')?.textContent).toContain('count'); |
| 121 | + expect(root.querySelector('.diagnostic-item')).toBeNull(); |
| 122 | + |
| 123 | + click('[data-tab="fsm"]'); |
| 124 | + expect(root.querySelector('.nav-tab.selected')?.getAttribute('data-tab')).toBe('fsm'); |
| 125 | + expect(root.querySelector('.diagram-title')?.textContent).toBe('Counter'); |
| 126 | + expect(root.querySelector('.mermaid')?.textContent).toContain('Open --> Closed : close()'); |
| 127 | + expect(runMermaid).toHaveBeenCalledOnce(); |
| 128 | + expect(runMermaid.mock.calls[0][0].nodes[0]).toBe(root.querySelector('.mermaid')); |
| 129 | + await vi.waitFor(() => { |
| 130 | + expect(root.querySelector<HTMLElement>('#diagram-wrapper')?.style.transform).toBe('matrix(1, 0, 0, 1, 0, 0)'); |
| 131 | + }); |
| 132 | + |
| 133 | + click('[data-tab="diagnostics"]'); |
| 134 | + expect(root.querySelector('.nav-tab.selected')?.getAttribute('data-tab')).toBe('diagnostics'); |
| 135 | + expect(root.querySelector('.diagnostic-item h3')?.textContent).toBe('Current error'); |
| 136 | + expect(root.querySelector('.diagram-container')).toBeNull(); |
| 137 | + expect(postMessage.mock.calls).toEqual([ |
| 138 | + [{ type: 'highlight', range: null }], |
| 139 | + [{ type: 'highlight', range: null }], |
| 140 | + [{ type: 'highlight', range: null }], |
| 141 | + ]); |
| 142 | + }); |
| 143 | + |
| 144 | + it('toggles between file and workspace errors by clicking scope buttons', () => { |
| 145 | + showDiagnostics([ |
| 146 | + diagnostic(currentFile, 'Current error'), |
| 147 | + diagnostic(otherFile, 'Other error'), |
| 148 | + ]); |
| 149 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(1); |
| 150 | + expect(root.querySelector('.diagnostics-scope-button.selected')?.getAttribute('data-diagnostics-scope')).toBe('file'); |
| 151 | + |
| 152 | + // clicking a child still activates the scope button |
| 153 | + click('[data-diagnostics-scope="workspace"] .diagnostics-scope-count'); |
| 154 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(2); |
| 155 | + expect(root.textContent).toContain('Other error'); |
| 156 | + expect(root.querySelector('.diagnostics-scope-button.selected')?.getAttribute('data-diagnostics-scope')).toBe('workspace'); |
| 157 | + |
| 158 | + click('[data-diagnostics-scope="file"]'); |
| 159 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(1); |
| 160 | + expect(root.textContent).not.toContain('Other error'); |
| 161 | + }); |
| 162 | + |
| 163 | + it('toggles show-all diagnostics when only warnings are present', () => { |
| 164 | + showDiagnostics([ |
| 165 | + diagnostic(currentFile, 'Current warning', 'warning'), |
| 166 | + diagnostic(otherFile, 'Other warning', 'warning'), |
| 167 | + ]); |
| 168 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(1); |
| 169 | + expect(root.querySelector('#show-all-button')?.textContent?.trim()).toBe('Show all diagnostics'); |
| 170 | + |
| 171 | + click('#show-all-button'); |
| 172 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(2); |
| 173 | + expect(root.textContent).toContain('Other warning'); |
| 174 | + expect(root.querySelector('#show-all-button')?.textContent?.trim()).toBe('Show file diagnostics'); |
| 175 | + |
| 176 | + click('#show-all-button'); |
| 177 | + expect(root.querySelectorAll('.diagnostic-item')).toHaveLength(1); |
| 178 | + expect(root.textContent).not.toContain('Other warning'); |
| 179 | + }); |
| 180 | + |
| 181 | + it('posts the file and zero-based position when a diagnostic location is clicked', () => { |
| 182 | + showDiagnostics([diagnostic(currentFile, 'Current error')]); |
| 183 | + postMessage.mockClear(); |
| 184 | + |
| 185 | + click('.location-link'); |
| 186 | + expect(postMessage.mock.calls).toEqual([[{ |
| 187 | + type: 'openFile', |
| 188 | + filePath: currentFile, |
| 189 | + line: position.lineStart, |
| 190 | + character: position.colStart, |
| 191 | + }]]); |
| 192 | + }); |
| 193 | + |
| 194 | + it('highlights a current-file variable and clears the highlight on a second click', () => { |
| 195 | + showDiagnostics([]); |
| 196 | + receive({ type: 'context', context: context() }); |
| 197 | + click('[data-tab="context"]'); |
| 198 | + postMessage.mockClear(); |
| 199 | + const { file: _file, ...range } = position; |
| 200 | + |
| 201 | + const button = click('.highlight-var-btn code'); |
| 202 | + expect(button.closest('.highlight-var-btn')?.classList.contains('selected')).toBe(true); |
| 203 | + expect(postMessage.mock.calls).toEqual([[{ type: 'highlight', range }]]); |
| 204 | + |
| 205 | + click('.highlight-var-btn'); |
| 206 | + expect(root.querySelector('.highlight-var-btn.selected')).toBeNull(); |
| 207 | + expect(postMessage.mock.calls).toEqual([ |
| 208 | + [{ type: 'highlight', range }], |
| 209 | + [{ type: 'highlight', range: null }], |
| 210 | + ]); |
| 211 | + }); |
| 212 | + |
| 213 | + it('opens another file with the variable range when its highlight button is clicked', () => { |
| 214 | + showDiagnostics([]); |
| 215 | + receive({ type: 'context', context: context(otherFile) }); |
| 216 | + click('[data-tab="context"]'); |
| 217 | + postMessage.mockClear(); |
| 218 | + const { file: _file, ...range } = position; |
| 219 | + |
| 220 | + click('.highlight-var-btn'); |
| 221 | + expect(postMessage.mock.calls).toEqual([[{ |
| 222 | + type: 'openFile', |
| 223 | + filePath: otherFile, |
| 224 | + line: position.lineStart, |
| 225 | + character: position.colStart, |
| 226 | + highlightRange: range, |
| 227 | + }]]); |
| 228 | + }); |
| 229 | +}); |
0 commit comments