Skip to content

Commit cafec6c

Browse files
committed
test: cover webview script interactions
1 parent 7799632 commit cafec6c

1 file changed

Lines changed: 229 additions & 0 deletions

File tree

‎client/src/webview/script.spec.ts‎

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
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

Comments
 (0)