From 67037d6e8090aa61887752a8700385225d9a94e6 Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Fri, 2 Oct 2026 17:28:18 +0100 Subject: [PATCH 1/3] Test real webview messaging and server lifecycle --- client/src/extension.ts | 1 + client/src/lsp/server.ts | 7 +- client/src/test/lifecycle.test.ts | 116 ++++++++++++++++++ client/src/types/test-api.ts | 2 +- client/src/utils/utils.ts | 6 +- .../src/main/java/FailingRefinement.java | 9 +- 6 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 client/src/test/lifecycle.test.ts diff --git a/client/src/extension.ts b/client/src/extension.ts index b3e52e0e..0f689211 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -45,6 +45,7 @@ export async function activate(context: vscode.ExtensionContext): Promise ({ status: extension.status, diagnostics: [...(extension.diagnostics ?? [])], + serverPid: extension.serverProcess?.pid, }), onWebviewMessage: extension.webview!.onWebviewMessage, }; diff --git a/client/src/lsp/server.ts b/client/src/lsp/server.ts index fe43b7f1..984b42a9 100644 --- a/client/src/lsp/server.ts +++ b/client/src/lsp/server.ts @@ -26,7 +26,8 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx cwd: workspaceFolder ? normalizeFilePath(workspaceFolder.uri.fsPath) : context.extensionPath, // root path }; extension.logger!.client.info("Creating language server process..."); - extension.serverProcess = child_process.spawn(javaExecutablePath, args, options); + const serverProcess = child_process.spawn(javaExecutablePath, args, options); + extension.serverProcess = serverProcess; // listen to process events extension.serverProcess.stdout?.on("data", (data) => { @@ -39,9 +40,9 @@ export async function runLanguageServer(context: vscode.ExtensionContext, javaEx extension.serverProcess.on("error", (err) => { extension.logger!.server.error(`Failed to start: ${err}`); }); - extension.serverProcess.on("close", (code) => { + serverProcess.on("close", (code) => { extension.logger!.server.info(`Process exited with code ${code}`); - extension.serverProcess = undefined; + if (extension.serverProcess === serverProcess) extension.serverProcess = undefined; }); return port; } diff --git a/client/src/test/lifecycle.test.ts b/client/src/test/lifecycle.test.ts new file mode 100644 index 00000000..460b925c --- /dev/null +++ b/client/src/test/lifecycle.test.ts @@ -0,0 +1,116 @@ +import * as assert from 'node:assert/strict'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import type { LJDiagnostic } from '../types/diagnostics'; +import type { LJContext } from '../types/context'; +import type { LiquidJavaTestApi } from '../types/test-api'; + +suite('Bundled LiquidJava webview and lifecycle', () => { + test('sends verification messages and verifies again after Stop, Start, and Restart', async () => { + const installed = vscode.extensions.getExtension('AlcidesFonseca.liquid-java'); + assert.ok(installed); + const api = await installed.activate(); + await api.ready; + + const workspace = vscode.workspace.workspaceFolders?.[0]; + assert.ok(workspace); + const uri = vscode.Uri.joinPath(workspace.uri, 'src/main/java/FailingRefinement.java'); + const sameFile = (file: string) => vscode.Uri.file(path.resolve(file)).fsPath === uri.fsPath; + const subscriptions: vscode.Disposable[] = []; + const nextEvent = (event: vscode.Event, matches: (value: T) => boolean) => + new Promise(resolve => { + const subscription = event(value => { + if (matches(value)) { + subscription.dispose(); + resolve(value); + } + }); + subscriptions.push(subscription); + }); + const isFixtureDiagnostic = (diagnostics: LJDiagnostic[]) => diagnostics.some(diagnostic => + diagnostic.type === 'refinement-error' && sameFile(diagnostic.file)); + const nextDiagnostics = () => nextEvent(api.onDiagnostics, isFixtureDiagnostic); + const assertServerRunning = () => { + const pid = api.getState().serverPid; + assert.ok(pid, 'the bundled extension must expose its running Java server'); + assert.doesNotThrow(() => process.kill(pid, 0)); + return pid; + }; + const assertServerStopped = (pid: number) => { + assert.equal(api.getState().serverPid, undefined); + assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' }, 'Stop must terminate the Java process'); + assert.equal(api.getState().status, 'stopped'); + assert.deepEqual(api.getState().diagnostics, []); + }; + + try { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await vscode.commands.executeCommand('liquidjava.stop'); + await vscode.commands.executeCommand('liquidjava.start'); + const ready = nextEvent(api.onWebviewMessage, event => + event.direction === 'fromWebview' && event.message.type === 'ready'); + await vscode.commands.executeCommand('liquidjava.showView'); + await ready; + + const initialDiagnostics = nextDiagnostics(); + const document = await vscode.workspace.openTextDocument(uri); + await vscode.window.showTextDocument(document); + await initialDiagnostics; + + const diagnosticMessage = nextEvent(api.onWebviewMessage, event => + event.direction === 'toWebview' && event.message.type === 'diagnostics' && + isFixtureDiagnostic(event.message.diagnostics)); + const contextMessage = nextEvent(api.onWebviewMessage, event => + event.direction === 'toWebview' && event.message.type === 'context'); + const manualDiagnostics = nextDiagnostics(); + await vscode.commands.executeCommand('liquidjava.verify'); + assert.equal(api.getState().status, 'loading'); + const [diagnostics, outboundDiagnostics, outboundContext] = await Promise.all([ + manualDiagnostics, diagnosticMessage, contextMessage, + ]); + assert.deepEqual(outboundDiagnostics.message.diagnostics, diagnostics); + const context: LJContext = outboundContext.message.context; + const valid = context.localVars.find(variable => variable.name === 'valid'); + assert.ok(valid, JSON.stringify(context)); + assert.ok(valid.position); + assert.ok(sameFile(valid.position.file)); + assert.equal(valid.type, 'int'); + assert.ok(valid.mainRefinement.includes('> 0')); + assert.equal(api.getState().status, 'failed'); + + const originalPid = assertServerRunning(); + const stopped = nextEvent(api.onWebviewMessage, event => + event.direction === 'toWebview' && event.message.type === 'status' && event.message.status === 'stopped'); + await vscode.commands.executeCommand('liquidjava.stop'); + await stopped; + assertServerStopped(originalPid); + + const startDiagnostics = nextDiagnostics(); + await vscode.commands.executeCommand('liquidjava.start'); + await startDiagnostics; + const startedPid = assertServerRunning(); + assert.notEqual(startedPid, originalPid); + assert.equal(api.getState().status, 'failed'); + + const restartStopped = nextEvent(api.onWebviewMessage, event => + event.direction === 'toWebview' && event.message.type === 'status' && event.message.status === 'stopped'); + const restartDiagnostics = nextDiagnostics(); + await vscode.commands.executeCommand('liquidjava.restart'); + await restartStopped; + await restartDiagnostics; + const restartedPid = assertServerRunning(); + assert.notEqual(restartedPid, startedPid); + assert.throws(() => process.kill(startedPid, 0), { code: 'ESRCH' }); + + const finalDiagnostics = nextDiagnostics(); + await vscode.commands.executeCommand('liquidjava.verify'); + await finalDiagnostics; + assert.equal(api.getState().status, 'failed'); + } finally { + subscriptions.forEach(subscription => subscription.dispose()); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await vscode.commands.executeCommand('liquidjava.stop'); + await vscode.commands.executeCommand('liquidjava.start'); + } + }); +}); diff --git a/client/src/types/test-api.ts b/client/src/types/test-api.ts index 07a8aa71..ade2ddb7 100644 --- a/client/src/types/test-api.ts +++ b/client/src/types/test-api.ts @@ -10,6 +10,6 @@ export interface WebviewMessage { export interface LiquidJavaTestApi { readonly ready: Promise; readonly onDiagnostics: vscode.Event; - getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[] }; + getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[]; serverPid: number | undefined }; readonly onWebviewMessage: vscode.Event; } diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts index 13a0c6d1..940baa8b 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -100,7 +100,7 @@ export async function connectToPort( */ export async function killProcess(proc?: child_process.ChildProcess) { return new Promise((resolve, reject) => { - if (!proc || proc.killed || proc.pid === undefined) { + if (!proc || proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) { // already killed resolve(); return; @@ -116,10 +116,12 @@ export async function killProcess(proc?: child_process.ChildProcess) { }); } else { // Unix + const onClose = () => resolve(); + proc.once("close", onClose); try { process.kill(proc.pid, "SIGKILL"); - resolve(); } catch (err) { + proc.removeListener("close", onClose); reject(err); } } diff --git a/client/test-fixtures/failing/src/main/java/FailingRefinement.java b/client/test-fixtures/failing/src/main/java/FailingRefinement.java index 5a41b628..45e4682f 100644 --- a/client/test-fixtures/failing/src/main/java/FailingRefinement.java +++ b/client/test-fixtures/failing/src/main/java/FailingRefinement.java @@ -1,6 +1,11 @@ import liquidjava.specification.Refinement; public class FailingRefinement { - @Refinement("_ > 0") - int positive = -1; + void check() { + @Refinement("_ > 0") + int valid = 1; + + @Refinement("_ > 0") + int positive = -1; + } } From d6e718e7e35debcbaeaf1af2c3dcdd81cdacf87f Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Fri, 2 Oct 2026 17:31:55 +0100 Subject: [PATCH 2/3] Reset the webview before observing readiness --- client/src/test/lifecycle.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/test/lifecycle.test.ts b/client/src/test/lifecycle.test.ts index 460b925c..a334d0ea 100644 --- a/client/src/test/lifecycle.test.ts +++ b/client/src/test/lifecycle.test.ts @@ -44,6 +44,7 @@ suite('Bundled LiquidJava webview and lifecycle', () => { }; try { + await vscode.commands.executeCommand('workbench.action.closeSidebar'); await vscode.commands.executeCommand('workbench.action.closeAllEditors'); await vscode.commands.executeCommand('liquidjava.stop'); await vscode.commands.executeCommand('liquidjava.start'); From 6ded2e6e7a5f2839f039bc6c44687bb2b28aafe1 Mon Sep 17 00:00:00 2001 From: Ricardo Costa Date: Sun, 4 Oct 2026 15:37:37 +0100 Subject: [PATCH 3/3] Assert fresh context and concrete webview diagnostics Co-authored-by: Codex --- client/src/test/lifecycle.test.ts | 51 +++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/client/src/test/lifecycle.test.ts b/client/src/test/lifecycle.test.ts index a334d0ea..ec4978e4 100644 --- a/client/src/test/lifecycle.test.ts +++ b/client/src/test/lifecycle.test.ts @@ -1,5 +1,6 @@ import * as assert from 'node:assert/strict'; import * as path from 'node:path'; +import { readFile, writeFile } from 'node:fs/promises'; import * as vscode from 'vscode'; import type { LJDiagnostic } from '../types/diagnostics'; import type { LJContext } from '../types/context'; @@ -18,18 +19,32 @@ suite('Bundled LiquidJava webview and lifecycle', () => { const sameFile = (file: string) => vscode.Uri.file(path.resolve(file)).fsPath === uri.fsPath; const subscriptions: vscode.Disposable[] = []; const nextEvent = (event: vscode.Event, matches: (value: T) => boolean) => - new Promise(resolve => { + new Promise((resolve, reject) => { + const failure = api.onFailure(() => { + subscription.dispose(); + failure.dispose(); + reject(new Error('LiquidJava verifier crashed while waiting for lifecycle verification')); + }); + subscriptions.push(failure); const subscription = event(value => { if (matches(value)) { subscription.dispose(); + failure.dispose(); resolve(value); } }); subscriptions.push(subscription); }); - const isFixtureDiagnostic = (diagnostics: LJDiagnostic[]) => diagnostics.some(diagnostic => - diagnostic.type === 'refinement-error' && sameFile(diagnostic.file)); - const nextDiagnostics = () => nextEvent(api.onDiagnostics, isFixtureDiagnostic); + const nextDiagnostics = () => nextEvent(api.onDiagnostics, () => true); + const assertFixtureDiagnostics = (diagnostics: LJDiagnostic[]) => { + const error = diagnostics.find(diagnostic => diagnostic.type === 'refinement-error' && sameFile(diagnostic.file)); + assert.ok(error, JSON.stringify(diagnostics)); + assert.equal(error.category, 'error'); + assert.equal(error.title, 'Refinement Error'); + assert.ok(error.position); + assert.ok(error.position.file && sameFile(error.position.file)); + }; + const originalSource = await readFile(uri.fsPath, 'utf8'); const assertServerRunning = () => { const pid = api.getState().serverPid; assert.ok(pid, 'the bundled extension must expose its running Java server'); @@ -54,24 +69,33 @@ suite('Bundled LiquidJava webview and lifecycle', () => { await ready; const initialDiagnostics = nextDiagnostics(); + const initialContext = nextEvent(api.onWebviewMessage, event => + event.direction === 'toWebview' && event.message.type === 'context' && + event.message.context.localVars.some((variable: LJContext['localVars'][number]) => variable.name === 'valid')); const document = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(document); - await initialDiagnostics; + const [initialResult] = await Promise.all([initialDiagnostics, initialContext]); + assertFixtureDiagnostics(initialResult); + + // change the context on disk without triggering automatic verification on save + assert.ok(originalSource.includes('int valid = 1;')); + await writeFile(uri.fsPath, originalSource.replace('int valid = 1;', 'int verifiedNow = 1;')); const diagnosticMessage = nextEvent(api.onWebviewMessage, event => - event.direction === 'toWebview' && event.message.type === 'diagnostics' && - isFixtureDiagnostic(event.message.diagnostics)); + event.direction === 'toWebview' && event.message.type === 'diagnostics'); const contextMessage = nextEvent(api.onWebviewMessage, event => - event.direction === 'toWebview' && event.message.type === 'context'); + event.direction === 'toWebview' && event.message.type === 'context' && + event.message.context.localVars.some((variable: LJContext['localVars'][number]) => variable.name === 'verifiedNow')); const manualDiagnostics = nextDiagnostics(); await vscode.commands.executeCommand('liquidjava.verify'); assert.equal(api.getState().status, 'loading'); const [diagnostics, outboundDiagnostics, outboundContext] = await Promise.all([ manualDiagnostics, diagnosticMessage, contextMessage, ]); - assert.deepEqual(outboundDiagnostics.message.diagnostics, diagnostics); + assertFixtureDiagnostics(diagnostics); + assertFixtureDiagnostics(outboundDiagnostics.message.diagnostics); const context: LJContext = outboundContext.message.context; - const valid = context.localVars.find(variable => variable.name === 'valid'); + const valid = context.localVars.find(variable => variable.name === 'verifiedNow'); assert.ok(valid, JSON.stringify(context)); assert.ok(valid.position); assert.ok(sameFile(valid.position.file)); @@ -88,7 +112,7 @@ suite('Bundled LiquidJava webview and lifecycle', () => { const startDiagnostics = nextDiagnostics(); await vscode.commands.executeCommand('liquidjava.start'); - await startDiagnostics; + assertFixtureDiagnostics(await startDiagnostics); const startedPid = assertServerRunning(); assert.notEqual(startedPid, originalPid); assert.equal(api.getState().status, 'failed'); @@ -98,17 +122,18 @@ suite('Bundled LiquidJava webview and lifecycle', () => { const restartDiagnostics = nextDiagnostics(); await vscode.commands.executeCommand('liquidjava.restart'); await restartStopped; - await restartDiagnostics; + assertFixtureDiagnostics(await restartDiagnostics); const restartedPid = assertServerRunning(); assert.notEqual(restartedPid, startedPid); assert.throws(() => process.kill(startedPid, 0), { code: 'ESRCH' }); const finalDiagnostics = nextDiagnostics(); await vscode.commands.executeCommand('liquidjava.verify'); - await finalDiagnostics; + assertFixtureDiagnostics(await finalDiagnostics); assert.equal(api.getState().status, 'failed'); } finally { subscriptions.forEach(subscription => subscription.dispose()); + await writeFile(uri.fsPath, originalSource); await vscode.commands.executeCommand('workbench.action.closeAllEditors'); await vscode.commands.executeCommand('liquidjava.stop'); await vscode.commands.executeCommand('liquidjava.start');