682 lines
28 KiB
TypeScript
682 lines
28 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
createDeferred,
|
|
createLspManagerHarness,
|
|
FakeWebSocket,
|
|
markerMessages,
|
|
publishDiagnostic,
|
|
type TestModel,
|
|
} from "./lsp-client-manager.test-harness";
|
|
import { modelUriForDocument } from "./file-uri";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
getMonacoInstance: vi.fn(),
|
|
waitForMonacoInstance: vi.fn(),
|
|
getWebSocketClient: vi.fn(),
|
|
registerLspProviders: vi.fn(),
|
|
requestFileContent: vi.fn(),
|
|
registerBuiltinTsSuppression: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/components/editors/monaco/monaco-init", () => ({
|
|
getMonacoInstance: mocks.getMonacoInstance,
|
|
waitForMonacoInstance: mocks.waitForMonacoInstance,
|
|
}));
|
|
vi.mock("@/components/editors/monaco/builtin-providers", () => ({
|
|
registerBuiltinTsSuppression: mocks.registerBuiltinTsSuppression,
|
|
withLspProviderRegistration: <T>(register: () => T) => register(),
|
|
}));
|
|
vi.mock("./lsp-providers", () => ({ registerLspProviders: mocks.registerLspProviders }));
|
|
|
|
vi.mock("@/lib/ws/connection", () => ({
|
|
getWebSocketClient: mocks.getWebSocketClient,
|
|
}));
|
|
|
|
vi.mock("@/lib/ws/workspace-files", () => ({
|
|
requestFileContent: mocks.requestFileContent,
|
|
}));
|
|
|
|
import { lspClientManager } from "./lsp-client-manager";
|
|
|
|
const {
|
|
connectReady,
|
|
connectReadyWithRelease,
|
|
createMonacoHarness,
|
|
createPlaceholder,
|
|
requireModel,
|
|
} = createLspManagerHarness(lspClientManager, mocks);
|
|
|
|
const REPLACEMENT_SESSION_ID = "replacement-session";
|
|
const WORKSPACE_PATH = "/workspace";
|
|
const WORKSPACE_URI = "file:///workspace";
|
|
const PRIMARY_DOCUMENT_URI = `${WORKSPACE_URI}/Main.ts`;
|
|
const REFERENCED_DOCUMENT_URI = `${WORKSPACE_URI}/Referenced.ts`;
|
|
const FIRST_SESSION_ID = "first-session";
|
|
const SECOND_SESSION_ID = "second-session";
|
|
const WINDOWS_SESSION_ID = "windows-session";
|
|
const EXPECTED_SOCKET_ERROR = "expected an LSP WebSocket";
|
|
const SESSION_ID = "session";
|
|
|
|
function modelUri(documentUri: string, sessionId = SESSION_ID): string {
|
|
return modelUriForDocument(documentUri, sessionId);
|
|
}
|
|
|
|
function beginInitialization(
|
|
sessionId = SESSION_ID,
|
|
ready: Record<string, unknown> = { workspacePath: WORKSPACE_PATH },
|
|
userConfigs?: Record<string, Record<string, unknown>>,
|
|
) {
|
|
const release = lspClientManager.connect(sessionId, "typescript", userConfigs);
|
|
const socket = FakeWebSocket.instances.at(-1);
|
|
if (!socket) throw new Error(EXPECTED_SOCKET_ERROR);
|
|
socket.open();
|
|
socket.emitMessage(JSON.stringify({ status: "ready", ...ready }));
|
|
const initializeId = (JSON.parse(socket.sent[0]) as { id: number }).id;
|
|
return { initializeId, release, socket };
|
|
}
|
|
|
|
function completeInitialization(socket: FakeWebSocket, initializeId: number): void {
|
|
socket.emitMessage(
|
|
JSON.stringify({ jsonrpc: "2.0", id: initializeId, result: { capabilities: {} } }),
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
lspClientManager.disconnectAll();
|
|
FakeWebSocket.instances = [];
|
|
vi.resetAllMocks();
|
|
vi.stubGlobal("WebSocket", FakeWebSocket);
|
|
mocks.waitForMonacoInstance.mockImplementation(async () => mocks.getMonacoInstance());
|
|
mocks.registerBuiltinTsSuppression.mockReturnValue({ dispose: vi.fn() });
|
|
});
|
|
|
|
afterEach(() => {
|
|
lspClientManager.disconnectAll();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("LSP editor readiness", () => {
|
|
it("does not report ready or register providers before Monaco is available", async () => {
|
|
const monacoReady = createDeferred<unknown>();
|
|
const primaryUri = PRIMARY_DOCUMENT_URI;
|
|
const primaryModelUri = modelUri(primaryUri);
|
|
const { markersByUri, monaco } = createMonacoHarness([primaryModelUri]);
|
|
mocks.waitForMonacoInstance.mockReturnValue(monacoReady.promise);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const { socket, initializeId } = beginInitialization();
|
|
completeInitialization(socket, initializeId);
|
|
await Promise.resolve();
|
|
|
|
expect(mocks.registerLspProviders).not.toHaveBeenCalled();
|
|
expect(mocks.registerBuiltinTsSuppression).not.toHaveBeenCalled();
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "starting" });
|
|
lspClientManager.openDocument("session", "typescript", {
|
|
uri: primaryUri,
|
|
languageId: "typescript",
|
|
text: "export {};",
|
|
});
|
|
expect(socket.sent.some((message) => message.includes("textDocument/didOpen"))).toBe(false);
|
|
|
|
monacoReady.resolve(monaco);
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "ready" });
|
|
});
|
|
expect(mocks.registerLspProviders).toHaveBeenCalledOnce();
|
|
|
|
lspClientManager.openDocument("session", "typescript", {
|
|
uri: primaryUri,
|
|
languageId: "typescript",
|
|
text: "export {};",
|
|
});
|
|
expect(socket.sent.some((message) => message.includes("textDocument/didOpen"))).toBe(true);
|
|
publishDiagnostic(socket, primaryUri, "ready issue");
|
|
expect(markerMessages(markersByUri, primaryModelUri)).toContain("ready issue");
|
|
});
|
|
|
|
it("does not finish initialization after the connection is stopped while Monaco loads", async () => {
|
|
const monacoReady = createDeferred<unknown>();
|
|
const { monaco } = createMonacoHarness([]);
|
|
mocks.waitForMonacoInstance.mockReturnValue(monacoReady.promise);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const { socket, initializeId } = beginInitialization();
|
|
completeInitialization(socket, initializeId);
|
|
await Promise.resolve();
|
|
|
|
lspClientManager.stop("session", "typescript");
|
|
monacoReady.resolve(monaco);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(mocks.registerLspProviders).not.toHaveBeenCalled();
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "disabled" });
|
|
});
|
|
});
|
|
|
|
describe("LSP diagnostic readiness", () => {
|
|
it("replays diagnostics when their matching Monaco model is created", async () => {
|
|
const primaryUri = "file:///workspace/Late.ts";
|
|
const { markersByUri, monaco } = createMonacoHarness([]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const socket = await connectReady("session", WORKSPACE_PATH);
|
|
|
|
publishDiagnostic(socket, primaryUri, "late model issue");
|
|
const primaryModelUri = modelUri(primaryUri);
|
|
expect(markerMessages(markersByUri, primaryModelUri)).toEqual([]);
|
|
|
|
monaco.editor.createModel("", "typescript", monaco.Uri.parse(primaryModelUri));
|
|
|
|
expect(markerMessages(markersByUri, primaryModelUri)).toContain("late model issue");
|
|
});
|
|
|
|
it("replays diagnostics cached while Monaco loads onto an existing model", async () => {
|
|
const monacoReady = createDeferred<unknown>();
|
|
const primaryUri = "file:///workspace/Existing.ts";
|
|
const primaryModelUri = modelUri(primaryUri);
|
|
const { markersByUri, monaco } = createMonacoHarness([primaryModelUri]);
|
|
mocks.getMonacoInstance.mockReturnValue(null);
|
|
mocks.waitForMonacoInstance.mockReturnValue(monacoReady.promise);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const { socket, initializeId } = beginInitialization();
|
|
completeInitialization(socket, initializeId);
|
|
await Promise.resolve();
|
|
|
|
publishDiagnostic(socket, primaryUri, "cached while loading");
|
|
expect(markerMessages(markersByUri, primaryModelUri)).toEqual([]);
|
|
|
|
mocks.getMonacoInstance.mockReturnValue(monaco);
|
|
monacoReady.resolve(monaco);
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "ready" });
|
|
});
|
|
expect(markerMessages(markersByUri, primaryModelUri)).toContain("cached while loading");
|
|
});
|
|
});
|
|
|
|
describe("LSP readiness failure cleanup", () => {
|
|
it("cleans up a failed readiness attempt so retry creates a fresh connection", async () => {
|
|
const { monaco } = createMonacoHarness([]);
|
|
mocks.waitForMonacoInstance
|
|
.mockRejectedValueOnce(new Error("Monaco chunk failed"))
|
|
.mockResolvedValue(monaco);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const { socket: firstSocket, initializeId, release: firstRelease } = beginInitialization();
|
|
completeInitialization(firstSocket, initializeId);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({
|
|
state: "error",
|
|
reason: "Monaco chunk failed",
|
|
});
|
|
});
|
|
expect(firstSocket.readyState).toBe(FakeWebSocket.CLOSED);
|
|
|
|
await connectReady("session", WORKSPACE_PATH);
|
|
expect(FakeWebSocket.instances).toHaveLength(2);
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "ready" });
|
|
firstRelease();
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("disposes the model listener and RPC when provider registration fails", async () => {
|
|
const primaryUri = PRIMARY_DOCUMENT_URI;
|
|
const primaryModelUri = modelUri(primaryUri);
|
|
const { markersByUri, monaco } = createMonacoHarness([primaryModelUri]);
|
|
const listenerDispose = vi.fn();
|
|
monaco.editor.onDidCreateModel.mockReturnValue({ dispose: listenerDispose });
|
|
mocks.registerLspProviders.mockImplementation(() => {
|
|
throw new Error("provider registration failed");
|
|
});
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
|
|
const { socket, initializeId } = beginInitialization();
|
|
completeInitialization(socket, initializeId);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus("session", "typescript").state).toBe("error");
|
|
});
|
|
expect(listenerDispose).toHaveBeenCalledOnce();
|
|
expect(socket.readyState).toBe(FakeWebSocket.CLOSED);
|
|
publishDiagnostic(socket, primaryUri, "late stale issue");
|
|
expect(markerMessages(markersByUri, primaryModelUri)).not.toContain("late stale issue");
|
|
consoleError.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("LSP client connection cleanup", () => {
|
|
it("fully restores editor state when a ready TypeScript server closes unexpectedly", async () => {
|
|
const providerDispose = vi.fn();
|
|
const primaryUri = PRIMARY_DOCUMENT_URI;
|
|
const placeholderUri = REFERENCED_DOCUMENT_URI;
|
|
const primaryModelUri = modelUri(primaryUri);
|
|
const placeholderModelUri = modelUri(placeholderUri);
|
|
const { models, monaco } = createMonacoHarness([primaryModelUri]);
|
|
mocks.registerLspProviders.mockReturnValue([{ dispose: providerDispose }]);
|
|
|
|
const socket = await connectReady("session", WORKSPACE_PATH);
|
|
createPlaceholder(0, placeholderUri);
|
|
const placeholder = requireModel(models, placeholderModelUri);
|
|
|
|
socket.failClosed(1006, "language server crashed");
|
|
|
|
const status = lspClientManager.getStatus("session", "typescript");
|
|
expect(status).toEqual({ state: "error", reason: "language server crashed" });
|
|
expect(providerDispose).toHaveBeenCalledOnce();
|
|
expect(placeholder.dispose).toHaveBeenCalledOnce();
|
|
expect(monaco.editor.setModelMarkers).toHaveBeenCalledWith(
|
|
requireModel(models, primaryModelUri),
|
|
expect.stringMatching(/^lsp:session:typescript:\d+$/),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it("isolates editor state for sessions that share task-host paths", async () => {
|
|
const documentUri = PRIMARY_DOCUMENT_URI;
|
|
const firstModelUri = modelUri(documentUri, FIRST_SESSION_ID);
|
|
const secondModelUri = modelUri(documentUri, SECOND_SESSION_ID);
|
|
const { markersByUri, models } = createMonacoHarness([firstModelUri, secondModelUri]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const firstSocket = await connectReady(FIRST_SESSION_ID, WORKSPACE_PATH);
|
|
const secondSocket = await connectReady(SECOND_SESSION_ID, WORKSPACE_PATH);
|
|
expect(mocks.registerBuiltinTsSuppression).toHaveBeenCalledTimes(2);
|
|
|
|
const firstProvider = mocks.registerLspProviders.mock.calls[0][0] as {
|
|
getDocumentUri: (model: TestModel) => string | null;
|
|
};
|
|
const secondProvider = mocks.registerLspProviders.mock.calls[1][0] as {
|
|
getDocumentUri: (model: TestModel) => string | null;
|
|
};
|
|
expect(firstProvider.getDocumentUri(requireModel(models, firstModelUri))).toBe(documentUri);
|
|
expect(firstProvider.getDocumentUri(requireModel(models, secondModelUri))).toBeNull();
|
|
expect(secondProvider.getDocumentUri(requireModel(models, firstModelUri))).toBeNull();
|
|
expect(secondProvider.getDocumentUri(requireModel(models, secondModelUri))).toBe(documentUri);
|
|
|
|
const placeholderUri = REFERENCED_DOCUMENT_URI;
|
|
const firstPlaceholderModelUri = modelUri(placeholderUri, FIRST_SESSION_ID);
|
|
const secondPlaceholderModelUri = modelUri(placeholderUri, SECOND_SESSION_ID);
|
|
createPlaceholder(0, placeholderUri);
|
|
createPlaceholder(1, placeholderUri);
|
|
const firstPlaceholder = requireModel(models, firstPlaceholderModelUri);
|
|
const secondPlaceholder = requireModel(models, secondPlaceholderModelUri);
|
|
publishDiagnostic(firstSocket, documentUri, "first issue");
|
|
publishDiagnostic(secondSocket, documentUri, "second issue");
|
|
|
|
firstSocket.failClosed(1006, "first language server crashed");
|
|
|
|
expect(firstPlaceholder.dispose).toHaveBeenCalledOnce();
|
|
expect(secondPlaceholder.dispose).not.toHaveBeenCalled();
|
|
expect(models.has(secondPlaceholderModelUri)).toBe(true);
|
|
expect(markerMessages(markersByUri, firstModelUri)).not.toContain("first issue");
|
|
expect(markerMessages(markersByUri, secondModelUri)).toContain("second issue");
|
|
|
|
secondSocket.failClosed(1006, "second language server crashed");
|
|
|
|
expect(secondPlaceholder.dispose).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
describe("LSP workspace handshake", () => {
|
|
it("updates configuration on a reused connection without restarting the server", async () => {
|
|
createMonacoHarness([]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const updatedConfig = { typescript: { preferences: { quoteStyle: "double" } } };
|
|
const { socket, initializeId } = beginInitialization(
|
|
SESSION_ID,
|
|
{ workspacePath: WORKSPACE_PATH },
|
|
{ typescript: { preferences: { quoteStyle: "single" } } },
|
|
);
|
|
completeInitialization(socket, initializeId);
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus(SESSION_ID, "typescript")).toEqual({ state: "ready" });
|
|
});
|
|
|
|
lspClientManager.connect(SESSION_ID, "typescript", updatedConfig);
|
|
|
|
expect(FakeWebSocket.instances).toHaveLength(1);
|
|
expect(socket.sent.map((message) => JSON.parse(message))).toContainEqual({
|
|
jsonrpc: "2.0",
|
|
method: "workspace/didChangeConfiguration",
|
|
params: { settings: updatedConfig.typescript },
|
|
});
|
|
|
|
socket.emitMessage(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: 99,
|
|
method: "workspace/configuration",
|
|
params: { items: [{ section: "typescript" }] },
|
|
}),
|
|
);
|
|
expect(JSON.parse(socket.sent.at(-1) ?? "null")).toEqual({
|
|
jsonrpc: "2.0",
|
|
id: 99,
|
|
result: [updatedConfig.typescript],
|
|
});
|
|
});
|
|
|
|
it("uses the task-host URI and repository list instead of host path metadata", async () => {
|
|
createMonacoHarness([]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
lspClientManager.connect("session", "typescript");
|
|
const socket = FakeWebSocket.instances.at(-1);
|
|
if (!socket) throw new Error(EXPECTED_SOCKET_ERROR);
|
|
socket.open();
|
|
socket.emitMessage(
|
|
JSON.stringify({
|
|
status: "ready",
|
|
workspacePath: "/host/tmp/worktree",
|
|
workspaceUri: WORKSPACE_URI,
|
|
repoSubpaths: ["backend"],
|
|
}),
|
|
);
|
|
|
|
const initialize = JSON.parse(socket.sent[0]) as {
|
|
id: number;
|
|
params: { rootUri: string; workspaceFolders: Array<{ uri: string }> };
|
|
};
|
|
expect(initialize.params.rootUri).toBe(WORKSPACE_URI);
|
|
expect(initialize.params.workspaceFolders).toEqual([{ uri: WORKSPACE_URI, name: "worktree" }]);
|
|
socket.emitMessage(
|
|
JSON.stringify({ jsonrpc: "2.0", id: initialize.id, result: { capabilities: {} } }),
|
|
);
|
|
await vi.waitFor(() => {
|
|
expect(lspClientManager.getStatus("session", "typescript")).toEqual({ state: "ready" });
|
|
});
|
|
expect(lspClientManager.getWorkspaceUriForSession("session")).toBe(WORKSPACE_URI);
|
|
expect(lspClientManager.getRepositorySubpaths("session")).toEqual(["backend"]);
|
|
|
|
lspClientManager.stop("session", "typescript");
|
|
expect(lspClientManager.getWorkspaceUriForSession("session")).toBe(WORKSPACE_URI);
|
|
expect(lspClientManager.getRepositorySubpaths("session")).toEqual(["backend"]);
|
|
});
|
|
|
|
it("falls back to the task-host path when its URI field is malformed", () => {
|
|
createMonacoHarness([]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
lspClientManager.connect("session", "typescript");
|
|
const socket = FakeWebSocket.instances.at(-1);
|
|
if (!socket) throw new Error(EXPECTED_SOCKET_ERROR);
|
|
socket.open();
|
|
socket.emitMessage(
|
|
JSON.stringify({
|
|
status: "ready",
|
|
workspacePath: WORKSPACE_PATH,
|
|
workspaceUri: "https://invalid.example/workspace",
|
|
}),
|
|
);
|
|
|
|
const initialize = JSON.parse(socket.sent[0]) as { params: { rootUri: string } };
|
|
expect(initialize.params.rootUri).toBe(WORKSPACE_URI);
|
|
});
|
|
});
|
|
|
|
describe("LSP diagnostic URI identity", () => {
|
|
it("matches Windows URI casing while keeping POSIX paths case-sensitive", async () => {
|
|
const windowsDocumentUri = "file:///C:/TaskRoot/src/Main.ts";
|
|
const posixDocumentUri = PRIMARY_DOCUMENT_URI;
|
|
const windowsModelUri = modelUri(windowsDocumentUri, WINDOWS_SESSION_ID);
|
|
const posixModelUri = modelUri(posixDocumentUri, "posix-session");
|
|
const { markersByUri } = createMonacoHarness([windowsModelUri, posixModelUri]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const windowsSocket = await connectReady(WINDOWS_SESSION_ID, "C:\\TaskRoot");
|
|
publishDiagnostic(windowsSocket, "file:///c:/taskroot/SRC/main.ts", "windows issue");
|
|
expect(markerMessages(markersByUri, windowsModelUri)).toContain("windows issue");
|
|
|
|
const posixSocket = await connectReady("posix-session", WORKSPACE_PATH);
|
|
publishDiagnostic(posixSocket, `${WORKSPACE_URI}/main.ts`, "wrong-case POSIX issue");
|
|
expect(markerMessages(markersByUri, posixModelUri)).not.toContain("wrong-case POSIX issue");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
sessionId: WINDOWS_SESSION_ID,
|
|
workspacePath: "C:\\TaskRoot",
|
|
documentUri: "file:///C:/TaskRoot/src/Main.ts",
|
|
serverUri: "file:///c:/taskroot/SRC/main.ts",
|
|
targetDocumentUri: "file:///C:/TaskRoot/src/Definition.ts",
|
|
targetServerUri: "file:///c:/taskroot/SRC/definition.ts",
|
|
},
|
|
{
|
|
sessionId: "unc-session",
|
|
workspacePath: "\\\\build-server\\Work",
|
|
documentUri: "file://build-server/Work/src/Main.ts",
|
|
serverUri: "file://BUILD-SERVER/work/SRC/main.ts",
|
|
targetDocumentUri: "file://build-server/Work/src/Definition.ts",
|
|
targetServerUri: "file://BUILD-SERVER/work/SRC/definition.ts",
|
|
},
|
|
])("reuses an existing $sessionId model for a server case variant", async (testCase) => {
|
|
const existingModelUri = modelUri(testCase.documentUri, testCase.sessionId);
|
|
const { models, monaco } = createMonacoHarness([existingModelUri]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
await connectReady(testCase.sessionId, testCase.workspacePath);
|
|
const provider = mocks.registerLspProviders.mock.calls[0][0] as {
|
|
ensureModelsExist: (uris: string[]) => void;
|
|
getModelUri: (uri: string) => string | null;
|
|
};
|
|
provider.ensureModelsExist([testCase.serverUri]);
|
|
|
|
expect(models.size).toBe(1);
|
|
expect(provider.getModelUri(testCase.serverUri)).toBe(existingModelUri);
|
|
|
|
provider.ensureModelsExist([testCase.targetServerUri]);
|
|
const placeholderUri = modelUri(testCase.targetServerUri, testCase.sessionId);
|
|
const placeholder = requireModel(models, placeholderUri);
|
|
const realModelUri = modelUri(testCase.targetDocumentUri, testCase.sessionId);
|
|
const realModel = monaco.editor.createModel("", "typescript", monaco.Uri.parse(realModelUri));
|
|
|
|
lspClientManager.promoteDocumentModel(
|
|
testCase.sessionId,
|
|
testCase.targetDocumentUri,
|
|
"authoritative content",
|
|
);
|
|
|
|
expect(placeholder.dispose).toHaveBeenCalledOnce();
|
|
expect(models.has(placeholderUri)).toBe(false);
|
|
expect(realModel.setValue).toHaveBeenCalledWith("authoritative content");
|
|
expect(provider.getModelUri(testCase.targetServerUri)).toBe(realModelUri);
|
|
});
|
|
});
|
|
|
|
describe("LSP replacement connection cleanup", () => {
|
|
it("ignores a delayed release from a replaced connection", async () => {
|
|
createMonacoHarness([]);
|
|
const currentProviderDispose = vi.fn();
|
|
mocks.registerLspProviders
|
|
.mockReturnValueOnce([])
|
|
.mockReturnValueOnce([{ dispose: currentProviderDispose }]);
|
|
|
|
const { socket: oldSocket, release: releaseOldConnection } = await connectReadyWithRelease(
|
|
REPLACEMENT_SESSION_ID,
|
|
"/old",
|
|
);
|
|
oldSocket.readyState = FakeWebSocket.CLOSING;
|
|
const currentSocket = await connectReady(REPLACEMENT_SESSION_ID, "/replacement");
|
|
|
|
vi.useFakeTimers();
|
|
try {
|
|
releaseOldConnection();
|
|
await vi.advanceTimersByTimeAsync(2 * 60 * 1000 + 1);
|
|
|
|
expect(currentSocket.readyState).toBe(FakeWebSocket.OPEN);
|
|
expect(currentProviderDispose).not.toHaveBeenCalled();
|
|
expect(lspClientManager.getStatus(REPLACEMENT_SESSION_ID, "typescript")).toEqual({
|
|
state: "ready",
|
|
});
|
|
} finally {
|
|
oldSocket.failClosed(1000, "old connection released");
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("ignores a delayed close from a replaced TypeScript connection", async () => {
|
|
const oldDocumentUri = "file:///old/Main.ts";
|
|
const currentDocumentUri = "file:///replacement/Main.ts";
|
|
const oldModelUri = modelUri(oldDocumentUri, REPLACEMENT_SESSION_ID);
|
|
const currentModelUri = modelUri(currentDocumentUri, REPLACEMENT_SESSION_ID);
|
|
const currentPlaceholderUri = "file:///replacement/Referenced.ts";
|
|
const currentPlaceholderModelUri = modelUri(currentPlaceholderUri, REPLACEMENT_SESSION_ID);
|
|
const { markersByUri, models } = createMonacoHarness([oldModelUri, currentModelUri]);
|
|
const oldProviderDispose = vi.fn();
|
|
const currentProviderDispose = vi.fn();
|
|
mocks.registerLspProviders
|
|
.mockReturnValueOnce([{ dispose: oldProviderDispose }])
|
|
.mockReturnValueOnce([{ dispose: currentProviderDispose }]);
|
|
|
|
const oldSocket = await connectReady(REPLACEMENT_SESSION_ID, "/old");
|
|
publishDiagnostic(oldSocket, oldDocumentUri, "old issue");
|
|
expect(markerMessages(markersByUri, oldModelUri)).toContain("old issue");
|
|
oldSocket.readyState = FakeWebSocket.CLOSING;
|
|
const currentSocket = await connectReady(REPLACEMENT_SESSION_ID, "/replacement");
|
|
|
|
expect(oldProviderDispose).toHaveBeenCalledOnce();
|
|
expect(markerMessages(markersByUri, oldModelUri)).not.toContain("old issue");
|
|
createPlaceholder(1, currentPlaceholderUri);
|
|
const currentPlaceholder = requireModel(models, currentPlaceholderModelUri);
|
|
publishDiagnostic(currentSocket, currentDocumentUri, "current issue");
|
|
|
|
oldSocket.failClosed(1006, "old language server finished closing");
|
|
|
|
expect(oldProviderDispose).toHaveBeenCalledOnce();
|
|
expect(currentProviderDispose).not.toHaveBeenCalled();
|
|
expect(currentPlaceholder.dispose).not.toHaveBeenCalled();
|
|
expect(markerMessages(markersByUri, currentModelUri)).toContain("current issue");
|
|
expect(lspClientManager.getStatus(REPLACEMENT_SESSION_ID, "typescript")).toEqual({
|
|
state: "ready",
|
|
});
|
|
});
|
|
|
|
it("ignores delayed initialization from a replaced connection", async () => {
|
|
createMonacoHarness([]);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
|
|
const { socket: oldSocket, initializeId: oldInitializeId } = beginInitialization(
|
|
REPLACEMENT_SESSION_ID,
|
|
{ workspacePath: "/old" },
|
|
);
|
|
oldSocket.readyState = FakeWebSocket.CLOSING;
|
|
|
|
await connectReady(REPLACEMENT_SESSION_ID, "/replacement");
|
|
oldSocket.emitMessage(
|
|
JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: oldInitializeId,
|
|
result: { capabilities: {} },
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
|
|
expect(mocks.registerLspProviders).toHaveBeenCalledOnce();
|
|
expect(mocks.registerBuiltinTsSuppression).toHaveBeenCalledOnce();
|
|
expect(lspClientManager.getStatus(REPLACEMENT_SESSION_ID, "typescript")).toEqual({
|
|
state: "ready",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("LSP shared placeholder cleanup", () => {
|
|
it("loads encoded placeholders through the correct repository scope", async () => {
|
|
const currentUri = "file:///workspace/backend/src/Main.ts";
|
|
const definitionUri = "file:///workspace/backend/src/My%20Definition%23.ts";
|
|
mocks.getWebSocketClient.mockReturnValue({});
|
|
mocks.requestFileContent.mockResolvedValue({ content: "export const target = true;" });
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
createMonacoHarness([modelUri(currentUri)]);
|
|
|
|
await connectReady("session", WORKSPACE_PATH);
|
|
lspClientManager.openDocument("session", "typescript", {
|
|
uri: currentUri,
|
|
languageId: "typescript",
|
|
text: "export {};",
|
|
repo: "backend",
|
|
});
|
|
createPlaceholder(0, definitionUri);
|
|
|
|
await vi.waitFor(() => {
|
|
expect(mocks.requestFileContent).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
"session",
|
|
"src/My Definition#.ts",
|
|
"backend",
|
|
);
|
|
});
|
|
});
|
|
|
|
it("does not create or fetch placeholders outside the workspace boundary", async () => {
|
|
const outsideUri = "file:///workspace-other/Secret.ts";
|
|
mocks.getWebSocketClient.mockReturnValue({});
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const { models } = createMonacoHarness([]);
|
|
|
|
await connectReady("session", WORKSPACE_PATH);
|
|
createPlaceholder(0, outsideUri);
|
|
|
|
expect(models.has(outsideUri)).toBe(false);
|
|
expect(mocks.requestFileContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("promotes a placeholder for a real editor without a matching LSP", async () => {
|
|
const documentUri = `${WORKSPACE_URI}/NormalOpen.java`;
|
|
const staleContent = createDeferred<{ content: string }>();
|
|
mocks.getWebSocketClient.mockReturnValue({});
|
|
mocks.requestFileContent.mockReturnValue(staleContent.promise);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const { models } = createMonacoHarness([]);
|
|
|
|
const socket = await connectReady(SESSION_ID, WORKSPACE_PATH);
|
|
createPlaceholder(0, documentUri);
|
|
await vi.waitFor(() => expect(mocks.requestFileContent).toHaveBeenCalledOnce());
|
|
const placeholder = requireModel(models, modelUri(documentUri));
|
|
|
|
lspClientManager.promoteDocumentModel(SESSION_ID, documentUri, "authoritative editor content");
|
|
staleContent.resolve({ content: "stale placeholder content" });
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
expect(placeholder.setValue).toHaveBeenCalledWith("authoritative editor content");
|
|
expect(placeholder.setValue).not.toHaveBeenCalledWith("stale placeholder content");
|
|
socket.failClosed(1006, "language server crashed");
|
|
expect(placeholder.dispose).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("isolates same-path placeholder models and content between task sessions", async () => {
|
|
const sharedUri = "file:///workspace/Shared.ts";
|
|
const firstContent = createDeferred<{ content: string }>();
|
|
const secondContent = createDeferred<{ content: string }>();
|
|
mocks.getWebSocketClient.mockReturnValue({});
|
|
mocks.requestFileContent
|
|
.mockReturnValueOnce(firstContent.promise)
|
|
.mockReturnValueOnce(secondContent.promise);
|
|
mocks.registerLspProviders.mockReturnValue([]);
|
|
const { models } = createMonacoHarness([]);
|
|
|
|
const firstSocket = await connectReady(FIRST_SESSION_ID, WORKSPACE_PATH);
|
|
await connectReady(SECOND_SESSION_ID, WORKSPACE_PATH);
|
|
createPlaceholder(0, sharedUri);
|
|
await vi.waitFor(() => expect(mocks.requestFileContent).toHaveBeenCalledOnce());
|
|
createPlaceholder(1, sharedUri);
|
|
await vi.waitFor(() => expect(mocks.requestFileContent).toHaveBeenCalledTimes(2));
|
|
const firstModel = requireModel(models, modelUri(sharedUri, FIRST_SESSION_ID));
|
|
const secondModel = requireModel(models, modelUri(sharedUri, SECOND_SESSION_ID));
|
|
expect(firstModel).not.toBe(secondModel);
|
|
|
|
firstSocket.failClosed(1006, "first language server crashed");
|
|
firstContent.resolve({ content: "stale first-session content" });
|
|
secondContent.resolve({ content: "surviving second-session content" });
|
|
|
|
await vi.waitFor(() => {
|
|
expect(secondModel.setValue).toHaveBeenCalledWith("surviving second-session content");
|
|
});
|
|
expect(firstModel.setValue).not.toHaveBeenCalledWith("stale first-session content");
|
|
expect(firstModel.dispose).toHaveBeenCalledOnce();
|
|
expect(secondModel.setValue).not.toHaveBeenCalledWith("stale first-session content");
|
|
expect(secondModel.dispose).not.toHaveBeenCalled();
|
|
});
|
|
});
|