diff --git a/client/src/extension.ts b/client/src/extension.ts index b3e52e0..0f68921 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 fe43b7f..984b42a 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 0000000..a334d0e --- /dev/null +++ b/client/src/test/lifecycle.test.ts @@ -0,0 +1,117 @@ +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.closeSidebar'); + 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 07a8aa7..ade2ddb 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 13a0c6d..940baa8 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 5a41b62..45e4682 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; + } }