1211 lines
49 KiB
TypeScript
1211 lines
49 KiB
TypeScript
import { test, expect } from "../../fixtures/test-base";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { execSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
import { makeGitEnv } from "../../helpers/git-helper";
|
|
import {
|
|
assertLocatorWithinViewportX,
|
|
assertNoElementHorizontalOverflow,
|
|
} from "../../helpers/layout-assertions";
|
|
import {
|
|
clearFakeKotlinLspModes,
|
|
createKotlinTask,
|
|
expectedMonacoModelUri,
|
|
expectFakeLspEvent,
|
|
expectFakeLspMarkerCount,
|
|
expectFakeLspMarkerMessages,
|
|
installAdditionalFakeLspBinary,
|
|
installFakeKotlinLsp,
|
|
LONG_LSP_PROGRESS_MESSAGE,
|
|
openDesktopFile,
|
|
openLspStatus,
|
|
performLspAction,
|
|
readFakeLspEvents,
|
|
releaseFakeLspInitialization,
|
|
removeFakeKotlinLsp,
|
|
} from "./lsp-e2e-helpers";
|
|
|
|
const EDITORS_SETTINGS_PATH = "/settings/preferences/terminal-editors";
|
|
const RESERVED_SOURCE_PATH = "Main # query? 100%.kt";
|
|
const DEFINITION_PARENT_PATH = "nested/references";
|
|
const DEFINITION_TARGET_PATH = `${DEFINITION_PARENT_PATH}/Definition Target # query? 100%.kt`;
|
|
|
|
function isProcessAlive(pid: number): boolean {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ESRCH") return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
test.describe("LSP file intelligence", () => {
|
|
test.describe.configure({ timeout: 90_000 });
|
|
|
|
test("persists Kotlin auto-start from Editors settings", async ({ testPage, apiClient }) => {
|
|
const initial = await apiClient.getUserSettings();
|
|
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
|
|
? (initial.settings.lsp_auto_start_languages as string[])
|
|
: [];
|
|
const initialAutoInstall = Array.isArray(initial.settings.lsp_auto_install_languages)
|
|
? (initial.settings.lsp_auto_install_languages as string[])
|
|
: [];
|
|
const initialConfigs =
|
|
typeof initial.settings.lsp_server_configs === "object" &&
|
|
initial.settings.lsp_server_configs !== null
|
|
? initial.settings.lsp_server_configs
|
|
: {};
|
|
|
|
try {
|
|
await testPage.goto(EDITORS_SETTINGS_PATH);
|
|
await expect(testPage.getByRole("heading", { name: "Editors", exact: true })).toBeVisible();
|
|
|
|
const kotlinCard = testPage.getByTestId("lsp-language-card-kotlin");
|
|
await expect(kotlinCard).toBeVisible({ timeout: 20_000 });
|
|
await expect(kotlinCard).toContainText("Kotlin (experimental)");
|
|
await expect(kotlinCard).toContainText("Manual install required");
|
|
await expect(kotlinCard.getByTestId("lsp-auto-install-kotlin")).toHaveCount(0);
|
|
await expect(
|
|
testPage.getByTestId("lsp-language-card-rust").getByTestId("lsp-auto-install-rust"),
|
|
).toBeVisible();
|
|
|
|
const autoStart = kotlinCard.getByTestId("lsp-auto-start-kotlin");
|
|
const shouldEnable = !initialAutoStart.includes("kotlin");
|
|
if ((await autoStart.isChecked()) !== shouldEnable) await autoStart.click();
|
|
|
|
await expect(kotlinCard).toHaveAttribute("data-settings-dirty", "true");
|
|
const floatingSave = testPage.getByTestId("settings-floating-save");
|
|
await expect(floatingSave).toBeVisible();
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible({ timeout: 15_000 });
|
|
|
|
const persisted = await apiClient.getUserSettings();
|
|
const persistedAutoStart = persisted.settings.lsp_auto_start_languages as string[];
|
|
expect(persistedAutoStart.includes("kotlin")).toBe(shouldEnable);
|
|
|
|
await testPage.reload();
|
|
await expect(testPage.getByTestId("lsp-auto-start-kotlin")).toBeChecked({
|
|
checked: shouldEnable,
|
|
});
|
|
} finally {
|
|
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
lsp_auto_start_languages: initialAutoStart,
|
|
lsp_auto_install_languages: initialAutoInstall,
|
|
lsp_server_configs: initialConfigs,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("surfaces task-host installation guidance when kotlin-lsp is missing", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
removeFakeKotlinLsp(backend);
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Missing Binary",
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
|
|
const statusButton = testPage.getByTestId("lsp-status-button");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
|
|
await performLspAction(testPage, "start");
|
|
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "unavailable", {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(testPage.getByText("Language server unavailable", { exact: true })).toBeVisible();
|
|
await expect(await openLspStatus(testPage)).toContainText(
|
|
"Install kotlin-lsp on the task host",
|
|
);
|
|
await expect(testPage.getByText(/Enable auto-install/)).toHaveCount(0);
|
|
});
|
|
|
|
test("shows server-reported project work without claiming full indexing", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend, {
|
|
progress: {
|
|
title: "Importing Kotlin project",
|
|
beginPercentage: 5,
|
|
message: LONG_LSP_PROGRESS_MESSAGE,
|
|
percentage: 42,
|
|
endMessage: "Project model loaded",
|
|
},
|
|
});
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Project Progress",
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
|
|
const statusButton = testPage.getByTestId("lsp-status-button");
|
|
await performLspAction(testPage, "start");
|
|
const surface = await openLspStatus(testPage);
|
|
const projectProgress = surface.getByTestId("lsp-project-progress");
|
|
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "active", {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "starting");
|
|
await expect(projectProgress).toContainText("Importing Kotlin project");
|
|
const progressMessage = projectProgress.getByText(LONG_LSP_PROGRESS_MESSAGE, { exact: true });
|
|
await expect(progressMessage).toBeVisible();
|
|
await expect(projectProgress).toContainText("Cross-file definitions and references");
|
|
await expect(projectProgress.getByTestId("lsp-work-progress-bar")).toHaveAttribute(
|
|
"aria-valuenow",
|
|
"42",
|
|
);
|
|
await expect(projectProgress).toContainText(/Elapsed \d+ sec/);
|
|
await expect(surface.getByTestId("lsp-lifecycle-action")).toHaveAttribute(
|
|
"data-lsp-action",
|
|
"stop",
|
|
);
|
|
await assertNoElementHorizontalOverflow(progressMessage, "desktop toolbar LSP progress text");
|
|
await assertNoElementHorizontalOverflow(surface, "desktop toolbar LSP progress popover");
|
|
await assertLocatorWithinViewportX(surface, "desktop toolbar LSP progress popover");
|
|
|
|
releaseFakeLspInitialization(backend);
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "completed");
|
|
await expect(projectProgress).toContainText("Server-reported work finished");
|
|
await expect(projectProgress).toContainText("Project model loaded");
|
|
await expect(projectProgress).toContainText("not full project indexing");
|
|
await expect(projectProgress).not.toContainText(/fully indexed/i);
|
|
|
|
await performLspAction(testPage, "stop");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
|
|
});
|
|
|
|
test("shows initialization timing and an honest no-report fallback", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend, { holdInitialize: true });
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Initialization Progress",
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
|
|
const statusButton = testPage.getByTestId("lsp-status-button");
|
|
await performLspAction(testPage, "start");
|
|
const surface = await openLspStatus(testPage);
|
|
const projectProgress = surface.getByTestId("lsp-project-progress");
|
|
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "initializing", {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(projectProgress).toHaveAttribute(
|
|
"data-lsp-initialization-stage",
|
|
"initialize_pending",
|
|
);
|
|
await expect(projectProgress).toContainText("Server process started");
|
|
await expect(projectProgress).toContainText(
|
|
"Waiting for the language server to respond to the LSP initialize request.",
|
|
);
|
|
await expect(projectProgress).toContainText(/Elapsed \d+ sec/);
|
|
await expect(projectProgress.getByTestId("lsp-work-progress-bar")).toHaveCount(0);
|
|
await expect(projectProgress).not.toContainText(/ETA|time remaining/i);
|
|
await expect(surface.getByTestId("lsp-lifecycle-action")).toHaveAttribute(
|
|
"data-lsp-action",
|
|
"stop",
|
|
);
|
|
await expect(surface.getByTestId("lsp-lifecycle-action")).toBeEnabled();
|
|
|
|
await testPage.clock.setFixedTime(Date.now() + 61_000);
|
|
await expect(projectProgress).toHaveAttribute("data-lsp-initialization-stage", "long_running", {
|
|
timeout: 5_000,
|
|
});
|
|
await expect(projectProgress).toContainText("Initialization is taking longer than usual");
|
|
await expect(projectProgress).toContainText("Kotlin LSP may be importing the Gradle project");
|
|
await expect(projectProgress).toContainText(
|
|
"Cross-file features remain unavailable until initialization completes.",
|
|
);
|
|
await expect(projectProgress.getByTestId("lsp-work-progress-bar")).toHaveCount(0);
|
|
await expect(projectProgress).not.toContainText(/ETA|time remaining|\d+%/i);
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "starting");
|
|
await expect(surface.getByTestId("lsp-lifecycle-action")).toHaveAttribute(
|
|
"data-lsp-action",
|
|
"stop",
|
|
);
|
|
await expect(surface.getByTestId("lsp-lifecycle-action")).toBeEnabled();
|
|
|
|
releaseFakeLspInitialization(backend);
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "idle");
|
|
await expect(projectProgress).toContainText("No background work reported");
|
|
await expect(projectProgress).toContainText("Cross-file results may still warm up");
|
|
await expect(projectProgress.getByTestId("lsp-work-progress-bar")).toHaveCount(0);
|
|
|
|
await performLspAction(testPage, "stop");
|
|
});
|
|
|
|
test("persists status-bar placement and follows the active editor", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const initial = await apiClient.getUserSettings();
|
|
const initialLocation =
|
|
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
|
|
const initialStatusBarEnabled = initial.settings.app_status_bar_enabled === true;
|
|
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
app_status_bar_enabled: true,
|
|
lsp_status_location: "toolbar",
|
|
});
|
|
|
|
try {
|
|
await testPage.goto(EDITORS_SETTINGS_PATH);
|
|
await expect(testPage.getByRole("heading", { name: "Editors", exact: true })).toBeVisible();
|
|
|
|
const statusBarChoice = testPage.getByRole("radio", {
|
|
name: /Application status bar/,
|
|
});
|
|
await expect(statusBarChoice).not.toBeChecked();
|
|
await statusBarChoice.click();
|
|
await expect(
|
|
testPage.getByRole("radiogroup", { name: "LSP status location" }).locator(".."),
|
|
).toHaveAttribute("data-settings-dirty", "true");
|
|
|
|
const floatingSave = testPage.getByTestId("settings-floating-save");
|
|
await floatingSave.getByRole("button", { name: "Save changes" }).click();
|
|
await expect(floatingSave).not.toBeVisible({ timeout: 15_000 });
|
|
expect((await apiClient.getUserSettings()).settings.lsp_status_location).toBe("status_bar");
|
|
|
|
await testPage.reload();
|
|
await expect(testPage.getByRole("radio", { name: /Application status bar/ })).toBeChecked();
|
|
|
|
installFakeKotlinLsp(backend, {
|
|
progress: {
|
|
title: "Importing Kotlin project",
|
|
message: LONG_LSP_PROGRESS_MESSAGE,
|
|
percentage: 42,
|
|
endMessage: "Status-bar project model loaded",
|
|
},
|
|
});
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Status Bar Placement",
|
|
filePaths: ["Main.kt", "README.md", "Binary.kt"],
|
|
fileContents: [
|
|
'package e2e\n\nfun main() = println("status")\n',
|
|
"# Unsupported editor\n",
|
|
Buffer.from([0x00, 0x01, 0x02, 0xff]),
|
|
],
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
|
|
await expect(testPage.getByTestId("lsp-status-button")).toHaveCount(0);
|
|
const statusItem = testPage.getByTestId("app-status-lsp");
|
|
await expect(statusItem).toBeVisible();
|
|
await expect(statusItem).toHaveAttribute("data-lsp-language", "kotlin");
|
|
await expect(testPage.locator('[data-status-item-id="builtin:lsp"]')).toHaveAttribute(
|
|
"data-status-side",
|
|
"right",
|
|
);
|
|
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusItem).toHaveAttribute("data-lsp-state", "starting", {
|
|
timeout: 15_000,
|
|
});
|
|
const statusSurface = await openLspStatus(testPage);
|
|
const statusProgress = statusSurface.getByTestId("lsp-project-progress");
|
|
await expect(statusProgress).toHaveAttribute("data-lsp-progress-kind", "active");
|
|
const statusMessage = statusProgress.getByText(LONG_LSP_PROGRESS_MESSAGE, { exact: true });
|
|
await expect(statusMessage).toBeVisible();
|
|
await assertNoElementHorizontalOverflow(statusMessage, "status-bar LSP progress text");
|
|
await assertNoElementHorizontalOverflow(statusSurface, "status-bar LSP progress popover");
|
|
await assertLocatorWithinViewportX(statusSurface, "status-bar LSP progress popover");
|
|
|
|
releaseFakeLspInitialization(backend);
|
|
await expect(statusItem).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
const kotlinPreview = testPage.getByTestId("preview-tab-file-editor");
|
|
await kotlinPreview.dblclick();
|
|
await expect(kotlinPreview).not.toHaveAttribute(
|
|
"title",
|
|
"Double-click to keep this tab open",
|
|
);
|
|
|
|
await task.session.clickTab("Files");
|
|
const binaryNode = task.session.fileTreeNode(task.filePaths[2]);
|
|
await expect(binaryNode).toBeVisible({ timeout: 15_000 });
|
|
await binaryNode.click();
|
|
await expect(testPage.getByText("Binary file", { exact: true })).toBeVisible();
|
|
await expect(statusItem).toHaveCount(0);
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[1]);
|
|
await expect(statusItem).toHaveCount(0);
|
|
await task.session.clickSessionChatTab();
|
|
await expect(statusItem).toHaveCount(0);
|
|
|
|
await testPage
|
|
.locator(".dv-default-tab", { hasText: path.basename(task.filePaths[0]) })
|
|
.click();
|
|
await expect(statusItem).toHaveAttribute("data-lsp-state", "ready");
|
|
await performLspAction(testPage, "stop");
|
|
await expect(statusItem).toHaveAttribute("data-lsp-state", "disabled");
|
|
} finally {
|
|
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
app_status_bar_enabled: initialStatusBarEnabled,
|
|
lsp_status_location: initialLocation,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("runs Kotlin intelligence through the task host", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend);
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Full Protocol",
|
|
filePaths: [RESERVED_SOURCE_PATH, DEFINITION_TARGET_PATH],
|
|
});
|
|
const lspSockets: string[] = [];
|
|
testPage.on("websocket", (socket) => {
|
|
if (socket.url().includes("/lsp/")) lspSockets.push(socket.url());
|
|
});
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
const statusButton = testPage.getByTestId("lsp-status-button");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-language", "kotlin");
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
|
|
const started = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "started",
|
|
"task-host process start",
|
|
);
|
|
expect(started.argv).toEqual(["--stdio"]);
|
|
expect(started.cwd).toMatch(/\/repos\/e2e-repo$/);
|
|
const workspaceUri = pathToFileURL(started.cwd!).href;
|
|
const sourceUri = pathToFileURL(path.join(started.cwd!, task.filePaths[0])).href;
|
|
const definitionUri = pathToFileURL(path.join(started.cwd!, task.filePaths[1])).href;
|
|
const definitionModelUri = expectedMonacoModelUri(definitionUri, task.sessionId);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "initialize" &&
|
|
event.params?.rootUri === workspaceUri,
|
|
"initialize with task workspace",
|
|
);
|
|
const didOpen = await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
(event.params?.textDocument as { languageId?: string } | undefined)?.languageId ===
|
|
"kotlin",
|
|
"didOpen for the reserved-character Kotlin file",
|
|
);
|
|
expect(didOpen.params?.textDocument).toMatchObject({
|
|
uri: sourceUri,
|
|
languageId: "kotlin",
|
|
version: 1,
|
|
});
|
|
expect(lspSockets).toHaveLength(1);
|
|
await expectFakeLspMarkerCount(testPage, 1);
|
|
|
|
const editor = testPage.locator(".monaco-editor:visible");
|
|
await editor.click();
|
|
await testPage.keyboard.press("Control+Space");
|
|
const manualCompletion = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/completion",
|
|
"completion request",
|
|
);
|
|
expect(manualCompletion.params?.context).toEqual({ triggerKind: 1 });
|
|
await expect(testPage.locator(".suggest-widget")).toContainText("fakeGreeting");
|
|
await testPage.keyboard.insertText("f");
|
|
const incompleteRetrigger = await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/completion" &&
|
|
(event.params?.context as { triggerKind?: number } | undefined)?.triggerKind === 3,
|
|
"incomplete completion retrigger",
|
|
);
|
|
expect(incompleteRetrigger.params?.context).toEqual({ triggerKind: 3 });
|
|
await testPage.keyboard.press("Enter");
|
|
await expect(testPage.locator(".monaco-editor:visible .view-lines")).toContainText(
|
|
"fakeGreeting",
|
|
);
|
|
await testPage.keyboard.press("Control+Z");
|
|
await testPage.keyboard.press("Control+Z");
|
|
|
|
await testPage
|
|
.locator(".monaco-editor:visible .view-line")
|
|
.nth(2)
|
|
.hover({
|
|
position: { x: 80, y: 8 },
|
|
});
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/hover",
|
|
"hover request",
|
|
);
|
|
await expect(testPage.getByText("Fake Kotlin hover", { exact: true })).toBeVisible();
|
|
await testPage.keyboard.press("Escape");
|
|
|
|
const nestedFolder = task.session.fileTreeNode("nested");
|
|
const definitionFolder = task.session.fileTreeNode(DEFINITION_PARENT_PATH);
|
|
const definitionTarget = task.session.fileTreeNode(DEFINITION_TARGET_PATH);
|
|
await expect(nestedFolder.locator(".tabler-icon-chevron-right")).toBeVisible();
|
|
await expect(definitionFolder).toHaveCount(0);
|
|
await expect(definitionTarget).toHaveCount(0);
|
|
|
|
await testPage.keyboard.press("F12");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/definition",
|
|
"definition request",
|
|
);
|
|
await expect(
|
|
testPage.locator(".dv-default-tab", { hasText: path.basename(task.filePaths[1]) }),
|
|
).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.locator(".monaco-editor:visible .view-lines")).toContainText(
|
|
"fun greeting1(name: String): String",
|
|
);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
"canonical didOpen for the definition target",
|
|
);
|
|
await expect
|
|
.poll(() =>
|
|
testPage.evaluate((expectedUri) => {
|
|
const monaco = (
|
|
window as typeof window & {
|
|
monaco?: {
|
|
editor: {
|
|
getEditors: () => Array<{
|
|
getModel: () => { uri: { toString: () => string } } | null;
|
|
getPosition: () => { lineNumber: number; column: number } | null;
|
|
hasTextFocus: () => boolean;
|
|
}>;
|
|
getModels: () => Array<{
|
|
getValue: () => string;
|
|
uri: { toString: () => string };
|
|
}>;
|
|
};
|
|
};
|
|
}
|
|
).monaco;
|
|
const targetModel = monaco?.editor
|
|
.getModels()
|
|
.find((model) => model.uri.toString() === expectedUri);
|
|
const activeEditor = monaco?.editor
|
|
.getEditors()
|
|
.find((candidate) => candidate.hasTextFocus());
|
|
return {
|
|
modelUri: targetModel?.uri.toString() ?? null,
|
|
modelContent: targetModel?.getValue() ?? null,
|
|
activeUri: activeEditor?.getModel()?.uri.toString() ?? null,
|
|
position: activeEditor?.getPosition() ?? null,
|
|
};
|
|
}, definitionModelUri),
|
|
)
|
|
.toEqual({
|
|
modelUri: definitionModelUri,
|
|
modelContent: expect.stringContaining("fun greeting1(name: String): String"),
|
|
activeUri: definitionModelUri,
|
|
position: { lineNumber: 3, column: 5 },
|
|
});
|
|
|
|
await expect(nestedFolder.locator(".tabler-icon-chevron-down")).toBeVisible();
|
|
await expect(definitionFolder.locator(".tabler-icon-chevron-down")).toBeVisible();
|
|
await expect(definitionTarget).toBeVisible();
|
|
await expect(definitionTarget).toHaveAttribute("data-active", "true");
|
|
|
|
await testPage.keyboard.press("Shift+F12");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/references",
|
|
"references request",
|
|
);
|
|
await testPage.keyboard.press("Escape");
|
|
|
|
await editor.click();
|
|
await testPage.keyboard.press("Control+Shift+Space");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/signatureHelp",
|
|
"signature-help request",
|
|
);
|
|
await testPage.keyboard.press("Escape");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/semanticTokens/full",
|
|
"semantic-tokens request",
|
|
);
|
|
|
|
await editor.click();
|
|
await testPage.keyboard.press("Control+End");
|
|
await testPage.keyboard.insertText("\n// e2e change");
|
|
await testPage.getByRole("button", { name: "Save (Ctrl+S)", exact: true }).click();
|
|
const didSave = await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didSave" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
"document save",
|
|
);
|
|
expect(didSave.params?.text).toContain("// e2e change");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didChange" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
"document change before save",
|
|
);
|
|
const synchronizationEvents = readFakeLspEvents(backend);
|
|
const didChangeIndex = synchronizationEvents.findIndex(
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didChange" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
);
|
|
const didSaveIndex = synchronizationEvents.findIndex(
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didSave" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
);
|
|
expect(didChangeIndex).toBeGreaterThanOrEqual(0);
|
|
expect(didSaveIndex).toBeGreaterThan(didChangeIndex);
|
|
await expectFakeLspMarkerCount(testPage, 2);
|
|
|
|
await editor.click();
|
|
await testPage.keyboard.press("Control+End");
|
|
await testPage.keyboard.type(".");
|
|
const triggeredCompletion = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => {
|
|
const context = event.params?.context as
|
|
| { triggerKind?: number; triggerCharacter?: string }
|
|
| undefined;
|
|
return (
|
|
event.event === "message" &&
|
|
event.method === "textDocument/completion" &&
|
|
context?.triggerKind === 2 &&
|
|
context.triggerCharacter === "."
|
|
);
|
|
},
|
|
"trigger-character completion request",
|
|
);
|
|
expect(triggeredCompletion.params?.context).toEqual({
|
|
triggerKind: 2,
|
|
triggerCharacter: ".",
|
|
});
|
|
await testPage.keyboard.press("Escape");
|
|
await testPage.keyboard.press("Control+Z");
|
|
|
|
await performLspAction(testPage, "stop");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
|
|
await expectFakeLspMarkerCount(testPage, 0);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
(event.event === "message" && event.method === "shutdown") || event.event === "signal",
|
|
"graceful server stop",
|
|
);
|
|
await expect
|
|
.poll(() => readFakeLspEvents(backend).filter((event) => event.event === "started").length)
|
|
.toBe(1);
|
|
});
|
|
|
|
test("auto-starts one shared server, forwards configuration, and honors Stop", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
const initial = await apiClient.getUserSettings();
|
|
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
|
|
? (initial.settings.lsp_auto_start_languages as string[])
|
|
: [];
|
|
const initialConfigs =
|
|
typeof initial.settings.lsp_server_configs === "object" &&
|
|
initial.settings.lsp_server_configs !== null
|
|
? (initial.settings.lsp_server_configs as Record<string, Record<string, unknown>>)
|
|
: {};
|
|
|
|
try {
|
|
installFakeKotlinLsp(backend);
|
|
await apiClient.saveUserSettings({
|
|
lsp_auto_start_languages: ["kotlin"],
|
|
lsp_server_configs: {
|
|
kotlin: { e2e: { enabled: true }, compiler: { jvmTarget: "21" } },
|
|
},
|
|
});
|
|
const lspSockets: string[] = [];
|
|
testPage.on("websocket", (socket) => {
|
|
if (socket.url().includes("/lsp/")) lspSockets.push(socket.url());
|
|
});
|
|
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Shared Connection",
|
|
fileCount: 2,
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
await expect(testPage.locator('[data-testid="lsp-status-button"]:visible')).toHaveAttribute(
|
|
"data-lsp-state",
|
|
"ready",
|
|
{ timeout: 15_000 },
|
|
);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "response" &&
|
|
Array.isArray(event.result) &&
|
|
JSON.stringify(event.result).includes('"jvmTarget":"21"'),
|
|
"custom workspace configuration response",
|
|
);
|
|
|
|
const started = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "started",
|
|
"shared task-host process start",
|
|
);
|
|
const firstDocumentUri = pathToFileURL(path.join(started.cwd!, task.filePaths[0])).href;
|
|
const secondDocumentUri = pathToFileURL(path.join(started.cwd!, task.filePaths[1])).href;
|
|
const firstModelUri = expectedMonacoModelUri(firstDocumentUri, task.sessionId);
|
|
const secondModelUri = expectedMonacoModelUri(secondDocumentUri, task.sessionId);
|
|
await expectFakeLspMarkerMessages(testPage, firstModelUri, ["Fake Kotlin diagnostic"]);
|
|
const firstPreview = testPage.getByTestId("preview-tab-file-editor");
|
|
await firstPreview.dblclick();
|
|
await expect(firstPreview).not.toHaveAttribute("title", "Double-click to keep this tab open");
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[1]);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
JSON.stringify(event.params).includes(task.filePaths[1]),
|
|
"didOpen for the second file",
|
|
);
|
|
await expectFakeLspMarkerMessages(testPage, secondModelUri, ["Fake Kotlin diagnostic"]);
|
|
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
|
|
1,
|
|
);
|
|
expect(lspSockets).toHaveLength(1);
|
|
|
|
await expectFakeLspMarkerMessages(testPage, firstModelUri, ["Fake Kotlin diagnostic"]);
|
|
await expectFakeLspMarkerMessages(testPage, secondModelUri, ["Fake Kotlin diagnostic"]);
|
|
await testPage.locator(".monaco-editor:visible").click();
|
|
await testPage.keyboard.press("Control+End");
|
|
await testPage.keyboard.insertText("\n// second document edit");
|
|
const didChange = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/didChange",
|
|
"document change",
|
|
);
|
|
expect(didChange.params?.textDocument).toMatchObject({ uri: secondDocumentUri });
|
|
const contentChanges = didChange.params?.contentChanges as
|
|
| Array<{
|
|
range?: {
|
|
start: { line: number; character: number };
|
|
end: { line: number; character: number };
|
|
};
|
|
text?: string;
|
|
}>
|
|
| undefined;
|
|
expect(contentChanges).toHaveLength(1);
|
|
expect(contentChanges?.[0]).toMatchObject({
|
|
range: {
|
|
start: { line: expect.any(Number), character: expect.any(Number) },
|
|
end: { line: expect.any(Number), character: expect.any(Number) },
|
|
},
|
|
text: "\n// second document edit",
|
|
});
|
|
expect(contentChanges?.[0].range?.start).toEqual(contentChanges?.[0].range?.end);
|
|
await expectFakeLspMarkerMessages(testPage, secondModelUri, [
|
|
"Fake Kotlin diagnostic after edit",
|
|
]);
|
|
await expectFakeLspMarkerMessages(testPage, firstModelUri, ["Fake Kotlin diagnostic"]);
|
|
const firstTab = testPage.locator(".dv-default-tab", {
|
|
hasText: path.basename(task.filePaths[0]),
|
|
});
|
|
await expect(firstTab).toHaveCount(1);
|
|
|
|
const closeCountBefore = readFakeLspEvents(backend).filter(
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didClose" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === secondDocumentUri,
|
|
).length;
|
|
const secondTab = testPage.locator(".dv-default-tab", {
|
|
hasText: path.basename(task.filePaths[1]),
|
|
});
|
|
await secondTab.hover();
|
|
await secondTab.locator(".dv-default-tab-action").click();
|
|
await expect(secondTab).toHaveCount(0);
|
|
await expect(firstTab).toHaveCount(1);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
readFakeLspEvents(backend).filter(
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didClose" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri ===
|
|
secondDocumentUri,
|
|
).length,
|
|
{ message: "waiting for didClose for the second file" },
|
|
)
|
|
.toBe(closeCountBefore + 1);
|
|
await expectFakeLspMarkerMessages(testPage, secondModelUri, []);
|
|
await expectFakeLspMarkerMessages(testPage, firstModelUri, ["Fake Kotlin diagnostic"]);
|
|
|
|
const activeStatus = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "stop");
|
|
await expect(activeStatus).toHaveAttribute("data-lsp-state", "disabled");
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[1]);
|
|
await expect(activeStatus).toHaveAttribute("data-lsp-state", "disabled");
|
|
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
|
|
1,
|
|
);
|
|
|
|
await performLspAction(testPage, "start");
|
|
await expect(activeStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
await expect
|
|
.poll(() => readFakeLspEvents(backend).filter((event) => event.event === "started").length)
|
|
.toBe(2);
|
|
await performLspAction(testPage, "stop");
|
|
} finally {
|
|
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
|
|
lsp_auto_start_languages: initialAutoStart,
|
|
lsp_server_configs: initialConfigs,
|
|
});
|
|
}
|
|
});
|
|
|
|
test("uses the task-host root for a secondary repository document URI", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend);
|
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const repositoryName = `lsp-secondary-${suffix}`;
|
|
const secondaryFilePath = "src/Multi Repo # query? 100%.kt";
|
|
const secondaryDefinitionPath = path.posix.join(
|
|
path.posix.dirname(secondaryFilePath),
|
|
DEFINITION_TARGET_PATH,
|
|
);
|
|
const repositoryDirectory = path.join(backend.tmpDir, "repos", repositoryName);
|
|
const gitEnv = makeGitEnv(backend.tmpDir);
|
|
fs.mkdirSync(path.join(repositoryDirectory, path.dirname(secondaryDefinitionPath)), {
|
|
recursive: true,
|
|
});
|
|
execSync("git init -b main", { cwd: repositoryDirectory, env: gitEnv });
|
|
fs.writeFileSync(
|
|
path.join(repositoryDirectory, secondaryFilePath),
|
|
[
|
|
"package secondary",
|
|
"",
|
|
"fun secondaryGreeting(name: String): String {",
|
|
' return "Hello, $name"',
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(repositoryDirectory, secondaryDefinitionPath),
|
|
[
|
|
"package secondary.definition",
|
|
"",
|
|
"fun secondaryDefinition(name: String): String {",
|
|
' return "Hello, $name"',
|
|
"}",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
execSync("git add -A", { cwd: repositoryDirectory, env: gitEnv });
|
|
execSync('git commit -m "add secondary Kotlin fixture"', {
|
|
cwd: repositoryDirectory,
|
|
env: gitEnv,
|
|
});
|
|
const secondaryRepository = await apiClient.createRepository(
|
|
seedData.workspaceId,
|
|
repositoryDirectory,
|
|
"main",
|
|
{ name: repositoryName, pull_before_worktree: false },
|
|
);
|
|
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Multi-Repo URI",
|
|
executorProfileId: seedData.worktreeExecutorProfileId,
|
|
repositoryIds: [seedData.repositoryId, secondaryRepository.id],
|
|
});
|
|
const taskRelativePath = `${repositoryName}/${secondaryFilePath}`;
|
|
const definitionTaskRelativePath = `${repositoryName}/${secondaryDefinitionPath}`;
|
|
await openDesktopFile(testPage, task.session, taskRelativePath);
|
|
const statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
|
|
const started = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "started",
|
|
"multi-repo task-host process start",
|
|
);
|
|
expect(started.cwd).toBeTruthy();
|
|
const workspaceUri = pathToFileURL(started.cwd!).href;
|
|
const documentUri = pathToFileURL(path.join(started.cwd!, taskRelativePath)).href;
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "initialize" &&
|
|
event.params?.rootUri === workspaceUri,
|
|
"multi-repo initialize with task root",
|
|
);
|
|
const didOpen = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/didOpen",
|
|
"secondary repository didOpen",
|
|
);
|
|
expect(didOpen.params?.textDocument).toMatchObject({
|
|
uri: documentUri,
|
|
languageId: "kotlin",
|
|
version: 1,
|
|
});
|
|
await expect(testPage.locator(".monaco-editor:visible .view-lines")).toContainText(
|
|
"fun secondaryGreeting(name: String): String",
|
|
);
|
|
|
|
// Pin the source, then open and pin the definition through the Files tree.
|
|
// That tree path is task-root-relative (`repo/path`) while LSP resolves the
|
|
// same target as `{ repo, path }`; navigation must reuse this tab identity.
|
|
const sourceTab = testPage.locator(".dv-default-tab", {
|
|
hasText: path.basename(secondaryFilePath),
|
|
});
|
|
await sourceTab.dblclick();
|
|
|
|
const nestedPath = `${repositoryName}/src/nested`;
|
|
const referencesPath = `${repositoryName}/src/${DEFINITION_PARENT_PATH}`;
|
|
const targetPath = `${repositoryName}/${secondaryDefinitionPath}`;
|
|
const nestedFolder = task.session.fileTreeNode(nestedPath);
|
|
const referencesFolder = task.session.fileTreeNode(referencesPath);
|
|
const definitionTarget = task.session.fileTreeNode(targetPath);
|
|
await expect(nestedFolder.locator(".tabler-icon-chevron-right")).toBeVisible();
|
|
await expect(referencesFolder).toHaveCount(0);
|
|
await expect(definitionTarget).toHaveCount(0);
|
|
|
|
await openDesktopFile(testPage, task.session, definitionTaskRelativePath);
|
|
const definitionTabs = testPage.locator(".dv-default-tab", {
|
|
hasText: path.basename(secondaryDefinitionPath),
|
|
});
|
|
await expect(definitionTabs).toHaveCount(1);
|
|
await definitionTabs.dblclick();
|
|
await sourceTab.click();
|
|
await expect(testPage.locator(".monaco-editor:visible .view-lines")).toContainText(
|
|
"fun secondaryGreeting(name: String): String",
|
|
);
|
|
|
|
await testPage.locator(".monaco-editor:visible").click();
|
|
await testPage.keyboard.press("F12");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "message" && event.method === "textDocument/definition",
|
|
"secondary repository definition request",
|
|
);
|
|
const definitionUri = pathToFileURL(path.join(started.cwd!, definitionTaskRelativePath)).href;
|
|
await expect(definitionTabs).toHaveCount(1);
|
|
await expect(definitionTabs).toBeVisible({ timeout: 15_000 });
|
|
await expect(testPage.locator(".monaco-editor:visible .view-lines")).toContainText(
|
|
"fun secondaryDefinition(name: String): String",
|
|
);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
(event.params?.textDocument as { uri?: string } | undefined)?.uri === definitionUri,
|
|
"secondary repository definition didOpen",
|
|
);
|
|
await expect(nestedFolder.locator(".tabler-icon-chevron-down")).toBeVisible();
|
|
await expect(referencesFolder.locator(".tabler-icon-chevron-down")).toBeVisible();
|
|
await expect(definitionTarget).toBeVisible();
|
|
await expect(definitionTarget).toHaveAttribute("data-active", "true");
|
|
|
|
await performLspAction(testPage, "stop");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
|
|
});
|
|
|
|
test("restores a manual connection after reload and forgets it after stop", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend);
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Manual Persistence",
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
let statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
const storageKey = `kandev-lsp:${task.sessionId}:kotlin`;
|
|
expect(await testPage.evaluate((key) => localStorage.getItem(key), storageKey)).toBe("1");
|
|
|
|
await testPage.reload();
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
await expect
|
|
.poll(() => readFakeLspEvents(backend).filter((event) => event.event === "started").length)
|
|
.toBeGreaterThanOrEqual(2);
|
|
|
|
await performLspAction(testPage, "stop");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "disabled");
|
|
expect(await testPage.evaluate((key) => localStorage.getItem(key), storageKey)).toBeNull();
|
|
});
|
|
|
|
test("cleans up a crashed server and reconnects", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend, { crashOnOpen: true });
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Crash Recovery",
|
|
});
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
const statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "crashing" && event.reason === "didOpen",
|
|
"intentional server crash",
|
|
);
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "error", {
|
|
timeout: 15_000,
|
|
});
|
|
const crashStatus = await openLspStatus(testPage);
|
|
await expect(crashStatus).toContainText("Error");
|
|
await expect(crashStatus).toContainText("language server exited");
|
|
await expectFakeLspMarkerCount(testPage, 0);
|
|
|
|
clearFakeKotlinLspModes(backend);
|
|
await performLspAction(testPage, "retry");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
await expectFakeLspMarkerCount(testPage, 1);
|
|
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(2);
|
|
await performLspAction(testPage, "stop");
|
|
});
|
|
|
|
test("keeps TypeScript intelligence active when the Kotlin connection crashes", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend);
|
|
installAdditionalFakeLspBinary(backend, "typescript-language-server");
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "LSP Cross Connection Isolation",
|
|
extensions: ["kt", "ts"],
|
|
});
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[0]);
|
|
let statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
const kotlinOpen = await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
JSON.stringify(event.params).includes('"languageId":"kotlin"'),
|
|
"Kotlin didOpen",
|
|
);
|
|
const kotlinPreviewTab = testPage.getByTestId("preview-tab-file-editor");
|
|
await expect(kotlinPreviewTab).toBeVisible();
|
|
await kotlinPreviewTab.dblclick();
|
|
|
|
await openDesktopFile(testPage, task.session, task.filePaths[1]);
|
|
statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await expect(statusButton).toHaveAttribute("data-lsp-language", "typescript");
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
const typescriptOpen = await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.event === "message" &&
|
|
event.method === "textDocument/didOpen" &&
|
|
JSON.stringify(event.params).includes('"languageId":"typescript"'),
|
|
"TypeScript didOpen",
|
|
);
|
|
await expectFakeLspMarkerCount(testPage, 2);
|
|
|
|
process.kill(kotlinOpen.pid, "SIGKILL");
|
|
await testPage.locator(".dv-default-tab", { hasText: task.filePaths[0] }).click();
|
|
await expect(testPage.locator('[data-testid="lsp-status-button"]:visible')).toHaveAttribute(
|
|
"data-lsp-state",
|
|
"error",
|
|
{ timeout: 15_000 },
|
|
);
|
|
|
|
await testPage.locator(".dv-default-tab", { hasText: task.filePaths[1] }).click();
|
|
const typescriptStatus = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await expect(typescriptStatus).toHaveAttribute("data-lsp-state", "ready");
|
|
await expectFakeLspMarkerCount(testPage, 1);
|
|
await testPage.locator(".monaco-editor:visible").click();
|
|
await testPage.keyboard.press("Control+Space");
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.pid === typescriptOpen.pid &&
|
|
event.event === "message" &&
|
|
event.method === "textDocument/completion",
|
|
"TypeScript completion after Kotlin cleanup",
|
|
);
|
|
await expect(testPage.locator(".suggest-widget")).toContainText("fakeGreeting");
|
|
await testPage.keyboard.press("Escape");
|
|
await performLspAction(testPage, "stop");
|
|
await expectFakeLspMarkerCount(testPage, 0);
|
|
});
|
|
|
|
test("stops the task-host process when its task is archived", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
installFakeKotlinLsp(backend);
|
|
const archiveTarget = "Main-archive-cleanup.kt";
|
|
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Archive Cleanup",
|
|
// Keep the target outside the initial virtualized viewport. This proves
|
|
// the file-opening helper uses the exact search path when a valid row is
|
|
// not mounted in the tree.
|
|
filePaths: [
|
|
archiveTarget,
|
|
...Array.from({ length: 48 }, (_, index) => `A-archive-noise-${index}.kt`),
|
|
],
|
|
});
|
|
await openDesktopFile(testPage, task.session, archiveTarget);
|
|
const statusButton = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expect(statusButton).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
|
|
const started = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "started",
|
|
"task-host process start",
|
|
);
|
|
|
|
await apiClient.archiveTask(task.taskId);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "signal" || event.event === "stdin ended",
|
|
"task teardown signal",
|
|
);
|
|
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
|
|
});
|
|
|
|
test("rejects excess connections and succeeds after capacity is released", async ({
|
|
testPage,
|
|
apiClient,
|
|
seedData,
|
|
backend,
|
|
}) => {
|
|
test.setTimeout(150_000);
|
|
await backend.restart({ KANDEV_LSP_MAX_CONNECTIONS: "1" });
|
|
installFakeKotlinLsp(backend);
|
|
let firstLspSocketObserved = false;
|
|
let firstLspSocketClosed = false;
|
|
testPage.on("websocket", (socket) => {
|
|
if (firstLspSocketObserved || !socket.url().includes("/lsp/")) return;
|
|
firstLspSocketObserved = true;
|
|
socket.on("close", () => {
|
|
firstLspSocketClosed = true;
|
|
});
|
|
});
|
|
const secondPage = await testPage.context().newPage();
|
|
try {
|
|
const first = await createKotlinTask(testPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Capacity One",
|
|
});
|
|
await openDesktopFile(testPage, first.session, first.filePaths[0]);
|
|
const firstStatus = testPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(testPage, "start");
|
|
await expect(firstStatus).toHaveAttribute("data-lsp-state", "ready", {
|
|
timeout: 15_000,
|
|
});
|
|
const firstStarted = await expectFakeLspEvent(
|
|
backend,
|
|
(event) => event.event === "started",
|
|
"first capacity-limited task-host process",
|
|
);
|
|
|
|
const second = await createKotlinTask(secondPage, apiClient, seedData, backend, {
|
|
title: "Kotlin LSP Capacity Two",
|
|
});
|
|
await openDesktopFile(secondPage, second.session, second.filePaths[0]);
|
|
const secondStatus = secondPage.locator('[data-testid="lsp-status-button"]:visible');
|
|
await performLspAction(secondPage, "start");
|
|
await expect(secondStatus).toHaveAttribute("data-lsp-state", "unavailable", {
|
|
timeout: 15_000,
|
|
});
|
|
await expect(
|
|
secondPage
|
|
.getByTestId("lsp-progress-details")
|
|
.getByText(/Too many language servers are active/),
|
|
).toBeVisible();
|
|
await expect(secondPage.getByText(/Enable auto-install/)).toHaveCount(0);
|
|
|
|
await performLspAction(testPage, "stop");
|
|
await expect(firstStatus).toHaveAttribute("data-lsp-state", "disabled");
|
|
await expect.poll(() => firstLspSocketObserved && firstLspSocketClosed).toBe(true);
|
|
await expectFakeLspEvent(
|
|
backend,
|
|
(event) =>
|
|
event.pid === firstStarted.pid &&
|
|
(event.event === "exit" || event.event === "signal" || event.event === "stdin ended"),
|
|
"first capacity-limited task-host process stop",
|
|
);
|
|
await expect.poll(() => isProcessAlive(firstStarted.pid)).toBe(false);
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const state = await secondStatus.getAttribute("data-lsp-state");
|
|
if (state === "disabled" || state === "unavailable") {
|
|
await performLspAction(secondPage, state === "unavailable" ? "retry" : "start");
|
|
}
|
|
return secondStatus.getAttribute("data-lsp-state");
|
|
},
|
|
{ timeout: 15_000, message: "waiting for the released LSP slot to become available" },
|
|
)
|
|
.toBe("ready");
|
|
await expect
|
|
.poll(() => readFakeLspEvents(backend).filter((event) => event.event === "started").length)
|
|
.toBe(2);
|
|
} finally {
|
|
await secondPage.close();
|
|
await backend.restart();
|
|
}
|
|
});
|
|
});
|