Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions client/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export async function activate(context: vscode.ExtensionContext): Promise<Liquid
getState: () => ({
status: extension.status,
diagnostics: [...(extension.diagnostics ?? [])],
serverPid: extension.serverProcess?.pid,
}),
onWebviewMessage: extension.webview!.onWebviewMessage,
};
Expand Down
7 changes: 4 additions & 3 deletions client/src/lsp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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;
}
Expand Down
117 changes: 117 additions & 0 deletions client/src/test/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<LiquidJavaTestApi>('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 = <T>(event: vscode.Event<T>, matches: (value: T) => boolean) =>
new Promise<T>(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 =>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can match the old context. handleLJDiagnostics sends a context message with the cached extension.context from the previous verification, so this listener can resolve on that message, not on the context Verify produces. The fixture doesn't change between runs, so the old and new contexts look the same, and the assertions on valid pass even if Verify stopped sending a context.

Suggest waiting for the context message that follows the liquidjava/context notification for this run, for example by clearing the cached context before Verify, or by matching on something that changes per run.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This compares an array with itself. handleLJDiagnostics passes the same diagnostics array to sendMessage and to diagnosticsEmitter.fire, and sendMessage fires onWebviewMessage with that object before postMessage. So outboundDiagnostics.message.diagnostics and diagnostics are the same reference, and deepEqual always passes. To check what the webview receives, compare against a copy taken before sending, or check specific fields such as the error type and file.

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');
}
});
});
2 changes: 1 addition & 1 deletion client/src/types/test-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ export interface WebviewMessage {
export interface LiquidJavaTestApi {
readonly ready: Promise<void>;
readonly onDiagnostics: vscode.Event<LJDiagnostic[]>;
getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[] };
getState(): { status: ExtensionStatus | undefined; diagnostics: LJDiagnostic[]; serverPid: number | undefined };
readonly onWebviewMessage: vscode.Event<WebviewMessage>;
}
6 changes: 4 additions & 2 deletions client/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export async function connectToPort(
*/
export async function killProcess(proc?: child_process.ChildProcess) {
return new Promise<void>((resolve, reject) => {
if (!proc || proc.killed || proc.pid === undefined) {
if (!proc || proc.exitCode !== null || proc.signalCode !== null || proc.pid === undefined) {
// already killed
resolve();
return;
Expand All @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading