Files
kandev/apps/web/e2e/tests/lsp/mobile-lsp-file-intelligence.spec.ts
T

319 lines
13 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import {
assertNoDocumentHorizontalOverflow,
assertNoElementHorizontalOverflow,
} from "../../helpers/layout-assertions";
import {
createKotlinTask,
expectFakeLspEvent,
installFakeKotlinLsp,
LONG_LSP_PROGRESS_MESSAGE,
openLspStatus,
performLspAction,
readFakeLspEvents,
releaseFakeLspInitialization,
} from "./lsp-e2e-helpers";
import { dwell } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
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("Mobile LSP boundaries", () => {
test.describe.configure({ timeout: 90_000 });
test("opens Kotlin in the mobile viewer without starting a language server", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const initialLocation =
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
try {
installFakeKotlinLsp(backend);
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: ["kotlin"],
lsp_status_location: "status_bar",
});
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: "Mobile Kotlin LSP Boundary",
});
await testPage.getByRole("button", { name: "Files", exact: true }).tap();
const fileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
const viewer = testPage.getByTestId("mobile-file-viewer-panel");
await expect(viewer).toBeVisible();
await expect(viewer.locator(".cm-editor")).toBeVisible();
await expect(viewer.getByTestId("lsp-status-button")).toHaveCount(0);
await expect(testPage.getByTestId("app-status-lsp")).toHaveCount(0);
await dwell(
testPage,
1_000,
"negative-assertion",
"asserts that no LSP socket is ever opened on mobile; a connection that must not happen publishes nothing, so the check needs the window in which it would have opened to elapse",
);
expect(lspSockets).toEqual([]);
expect(readFakeLspEvents(backend)).toEqual([]);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
lsp_status_location: initialLocation,
});
await releaseFeature();
}
});
test("shows the same project progress in a contained tablet drawer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
const initial = await apiClient.getUserSettings();
const initialLocation =
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
try {
await testPage.setViewportSize({ width: 820, height: 900 });
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_status_location: "status_bar",
});
installFakeKotlinLsp(backend, {
progress: {
title: "Importing Kotlin project",
message: LONG_LSP_PROGRESS_MESSAGE,
percentage: 42,
endMessage: "Tablet project model loaded",
},
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Tablet Kotlin LSP Progress",
});
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
const fileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
await expect(testPage.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
const statusButton = testPage.getByTestId("lsp-status-button");
await expect(statusButton).toBeVisible();
await expect(testPage.getByTestId("app-status-lsp")).toHaveCount(0);
expect((await apiClient.getUserSettings()).settings.lsp_status_location).toBe("status_bar");
const triggerBox = await statusButton.boundingBox();
expect(triggerBox?.width).toBeGreaterThanOrEqual(44);
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
await performLspAction(testPage, "start");
const drawer = await openLspStatus(testPage);
await expect(drawer).toHaveAttribute("data-testid", "lsp-status-drawer");
await expect(testPage.getByTestId("lsp-status-popover")).toHaveCount(0);
const projectProgress = drawer.getByTestId("lsp-project-progress");
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "active", {
timeout: 15_000,
});
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("42%");
const drawerBox = await drawer.boundingBox();
expect(drawerBox).not.toBeNull();
expect(drawerBox!.x).toBeGreaterThanOrEqual(0);
expect(drawerBox!.y).toBeGreaterThanOrEqual(0);
expect(drawerBox!.x + drawerBox!.width).toBeLessThanOrEqual(820);
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(900);
const actionBox = await drawer.getByTestId("lsp-lifecycle-action").boundingBox();
expect(actionBox?.height).toBeGreaterThanOrEqual(44);
const verticalScrollOwner = drawer.locator("[data-vaul-no-drag]");
await expect(verticalScrollOwner).toHaveCount(1);
await expect(verticalScrollOwner).toHaveCSS("overflow-y", "auto");
await assertNoElementHorizontalOverflow(progressMessage, "tablet LSP progress text");
await assertNoElementHorizontalOverflow(drawer, "tablet LSP progress drawer");
await assertNoDocumentHorizontalOverflow(testPage, "tablet LSP progress drawer");
releaseFakeLspInitialization(backend);
await expect(projectProgress).toHaveAttribute("data-lsp-progress-kind", "completed", {
timeout: 15_000,
});
await expect(projectProgress).toContainText("Tablet project model loaded");
await performLspAction(testPage, "stop");
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_status_location: initialLocation,
});
}
});
test("reattaches the retained language server in the tablet drawer", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(180_000);
const releaseFeature = await backend.useEnv({
KANDEV_FEATURES_LSP_BROWSER_CONTINUITY: "true",
});
const initial = await apiClient.getUserSettings();
const initialAutoStart = Array.isArray(initial.settings.lsp_auto_start_languages)
? (initial.settings.lsp_auto_start_languages as string[])
: [];
const initialLocation =
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
try {
await testPage.setViewportSize({ width: 820, height: 900 });
installFakeKotlinLsp(backend, {
keepProgress: true,
progress: {
title: "Importing Kotlin project",
message: "Tablet project model is warming up",
percentage: 42,
},
});
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [...new Set([...initialAutoStart, "kotlin"])],
lsp_status_location: "toolbar",
});
const task = await createKotlinTask(testPage, apiClient, seedData, backend, {
title: "Tablet Kotlin LSP Continuity",
});
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
const fileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(fileNode).toBeVisible({ timeout: 15_000 });
await fileNode.tap();
await expect(testPage.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
const initialStatus = testPage.getByTestId("lsp-status-button");
await expect(initialStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
const started = await expectFakeLspEvent(
backend,
(event) => event.event === "started",
"tablet task-host process",
);
const initializeCount = readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
).length;
expect(initializeCount).toBe(1);
await testPage.reload();
await testPage.setViewportSize({ width: 820, height: 900 });
const reopenedSession = new SessionPage(testPage);
await reopenedSession.waitForLoad(45_000);
testPage.setDefaultTimeout(15_000);
await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible();
const reopenedFileNode = testPage.locator(
`[data-testid="file-tree-node"][data-path="${task.filePaths[0]}"]`,
);
await expect(reopenedFileNode).toBeVisible({ timeout: 15_000 });
await reopenedFileNode.tap();
await expect(testPage.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
const reopenedStatus = testPage.getByTestId("lsp-status-button");
await expect(reopenedStatus).toHaveAttribute("data-lsp-state", "ready", { timeout: 15_000 });
await expect(testPage.getByTestId("app-status-lsp")).toHaveCount(0);
const drawer = await openLspStatus(testPage);
await expect(drawer).toHaveAttribute("data-testid", "lsp-status-drawer");
await expect(drawer.getByTestId("lsp-project-progress")).toContainText(
"Tablet project model is warming up",
);
expect(readFakeLspEvents(backend).filter((event) => event.event === "started")).toHaveLength(
1,
);
expect(
readFakeLspEvents(backend).filter(
(event) => event.event === "message" && event.method === "initialize",
),
).toHaveLength(initializeCount);
expect(isProcessAlive(started.pid)).toBe(true);
await performLspAction(testPage, "stop");
await expect.poll(() => isProcessAlive(started.pid)).toBe(false);
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
lsp_status_location: initialLocation,
});
await releaseFeature();
}
});
test("persists Kotlin settings with usable mobile guidance", 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 initialLocation =
initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar";
try {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: [],
lsp_status_location: "toolbar",
});
await testPage.goto("/settings/preferences/terminal-editors");
await expect(testPage.getByRole("heading", { name: "Editors", exact: true })).toBeVisible();
const kotlinCard = testPage.getByTestId("lsp-language-card-kotlin");
await expect(kotlinCard).toBeVisible();
await expect(kotlinCard.getByTestId("lsp-install-guidance-kotlin")).toContainText(
"inside the task container",
);
await expect(
testPage.getByText("the mobile file viewer does not start them", { exact: false }),
).toBeVisible();
await expect(testPage.getByRole("radio", { name: /Application status bar/ })).toBeVisible();
await expect(testPage.getByText(/touch-oriented layout is active/)).toBeVisible();
const autoStart = kotlinCard.getByTestId("lsp-auto-start-kotlin");
await expect(autoStart).not.toBeChecked();
await autoStart.tap();
const floatingSave = testPage.getByTestId("settings-floating-save");
await floatingSave.getByRole("button", { name: "Save changes" }).tap();
await expect(floatingSave).not.toBeVisible({ timeout: 15_000 });
expect(
(
(await apiClient.getUserSettings()).settings.lsp_auto_start_languages as string[]
).includes("kotlin"),
).toBe(true);
await testPage.reload();
await expect(testPage.getByTestId("lsp-auto-start-kotlin")).toBeChecked();
await assertNoDocumentHorizontalOverflow(testPage, "mobile Editors settings");
} finally {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
lsp_auto_start_languages: initialAutoStart,
lsp_status_location: initialLocation,
});
}
});
});