531 lines
17 KiB
TypeScript
531 lines
17 KiB
TypeScript
import { expect, type Locator, type Page } from "@playwright/test";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import type { BackendContext } from "../../fixtures/backend";
|
|
import type { ApiClient } from "../../helpers/api-client";
|
|
import { GitHelper, makeGitEnv } from "../../helpers/git-helper";
|
|
import { SessionPage } from "../../pages/session-page";
|
|
|
|
export type KotlinTask = {
|
|
taskId: string;
|
|
sessionId: string;
|
|
session: SessionPage;
|
|
filePaths: string[];
|
|
};
|
|
|
|
type KotlinSeedData = {
|
|
workspaceId: string;
|
|
workflowId: string;
|
|
startStepId: string;
|
|
repositoryId: string;
|
|
agentProfileId: string;
|
|
};
|
|
|
|
type CreateKotlinTaskOptions = {
|
|
title: string;
|
|
fileCount?: number;
|
|
fileContents?: Array<string | Buffer>;
|
|
filePaths?: string[];
|
|
extensions?: string[];
|
|
executorProfileId?: string;
|
|
navigate?: boolean;
|
|
repositoryIds?: string[];
|
|
repositoryDirectory?: string;
|
|
push?: boolean;
|
|
};
|
|
|
|
export type FakeLspEvent = {
|
|
event: string;
|
|
pid: number;
|
|
timestamp: number;
|
|
id?: number;
|
|
method?: string;
|
|
params?: Record<string, unknown>;
|
|
result?: unknown;
|
|
cwd?: string;
|
|
argv?: string[];
|
|
signal?: string;
|
|
reason?: string;
|
|
};
|
|
|
|
const FAKE_SERVER_SOURCE = path.resolve(__dirname, "../../fixtures/fake-lsp-server.mjs");
|
|
|
|
export const LONG_LSP_PROGRESS_MESSAGE =
|
|
"Metadata of https://artifactory.tools.gspcloud.com/artifactory/maven/io/fabric8/kubernetes-model-resource/6.13.5/kubernetes-model-resource-6.13.5-sources.jar started with checksum " +
|
|
"0123456789abcdef".repeat(12);
|
|
|
|
function fakeServerPath(backend: BackendContext): string {
|
|
return path.join(backend.tmpDir, "bin", "kotlin-lsp");
|
|
}
|
|
|
|
function fakeServerLogPath(backend: BackendContext): string {
|
|
return path.join(backend.tmpDir, "lsp-e2e-events.jsonl");
|
|
}
|
|
|
|
function crashModePath(backend: BackendContext): string {
|
|
return path.join(backend.tmpDir, "lsp-e2e-crash-on-open");
|
|
}
|
|
|
|
function initializeModePath(backend: BackendContext): string {
|
|
return path.join(backend.tmpDir, "lsp-e2e-initialize-mode.json");
|
|
}
|
|
|
|
function initializeReleasePath(backend: BackendContext): string {
|
|
return path.join(backend.tmpDir, "lsp-e2e-initialize-release");
|
|
}
|
|
|
|
export async function createKotlinTask(
|
|
page: Page,
|
|
apiClient: ApiClient,
|
|
seedData: KotlinSeedData,
|
|
backend: BackendContext,
|
|
options: CreateKotlinTaskOptions,
|
|
): Promise<KotlinTask> {
|
|
const fileCount = options.filePaths?.length ?? options.fileCount ?? 1;
|
|
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const extensions =
|
|
options.extensions ??
|
|
Array.from({ length: fileCount }, (_, index) => (index === 0 ? "kt" : "kts"));
|
|
const filePaths =
|
|
options.filePaths ??
|
|
extensions.map((extension, index) =>
|
|
index === 0 ? `Main-${suffix}.${extension}` : `Secondary-${suffix}-${index}.${extension}`,
|
|
);
|
|
const git = new GitHelper(
|
|
path.join(backend.tmpDir, "repos", options.repositoryDirectory ?? "e2e-repo"),
|
|
makeGitEnv(backend.tmpDir),
|
|
);
|
|
// Task repositories use main unless a test explicitly supplies another
|
|
// branch. A preceding Git test can leave the shared seed repository checked
|
|
// out on a feature branch; committing the LSP fixture there makes the task's
|
|
// main checkout legitimately omit the file.
|
|
git.exec("git checkout -f main");
|
|
for (const [index, filePath] of filePaths.entries()) {
|
|
const isTypeScript = filePath.endsWith(".ts");
|
|
const defaultContent = isTypeScript
|
|
? [
|
|
`export function greeting${index}(name: string): string {`,
|
|
" return `Hello, ${name}`;",
|
|
"}",
|
|
"",
|
|
].join("\n")
|
|
: [
|
|
`package e2e${index}`,
|
|
"",
|
|
`fun greeting${index}(name: String): String {`,
|
|
' return "Hello, $name"',
|
|
"}",
|
|
"",
|
|
].join("\n");
|
|
git.createFile(filePath, options.fileContents?.[index] ?? defaultContent);
|
|
}
|
|
git.stageAll();
|
|
git.commit(`add Kotlin LSP fixture ${suffix}`);
|
|
if (options.push) git.exec("git push origin main");
|
|
|
|
const task = await apiClient.createTaskWithAgent(
|
|
seedData.workspaceId,
|
|
options.title,
|
|
seedData.agentProfileId,
|
|
{
|
|
description: "/e2e:simple-message",
|
|
workflow_id: seedData.workflowId,
|
|
workflow_step_id: seedData.startStepId,
|
|
repository_ids: options.repositoryIds ?? [seedData.repositoryId],
|
|
executor_profile_id: options.executorProfileId,
|
|
},
|
|
);
|
|
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
|
|
|
|
await expect
|
|
.poll(async () => (await apiClient.getTaskEnvironment(task.id))?.status ?? null, {
|
|
timeout: 45_000,
|
|
message: "Kotlin task environment did not become ready",
|
|
})
|
|
.toBe("ready");
|
|
|
|
const session = new SessionPage(page);
|
|
if (options.navigate !== false) {
|
|
await page.goto(`/t/${task.id}`);
|
|
// Local Docker and SSH tasks can spend longer than the default 15 seconds
|
|
// preparing their runtime before the chat panel mounts.
|
|
await session.waitForLoad(45_000);
|
|
await session.waitForChatIdle({ timeout: 45_000 });
|
|
}
|
|
|
|
return {
|
|
taskId: task.id,
|
|
sessionId: task.session_id,
|
|
session,
|
|
filePaths,
|
|
};
|
|
}
|
|
|
|
export function expectedMonacoModelUri(documentUri: string, sessionId: string): string {
|
|
const document = new URL(documentUri);
|
|
if (document.protocol !== "file:" || document.search || document.hash || !sessionId) {
|
|
throw new Error(`expected a clean file URI, received: ${documentUri}`);
|
|
}
|
|
const encodeToken = (value: string) => Buffer.from(value, "utf8").toString("hex");
|
|
const sessionToken = `s-${encodeToken(sessionId)}`;
|
|
let hostToken = "l";
|
|
if (document.host) hostToken = `h-${encodeToken(document.host)}`;
|
|
else if (/^\/[A-Za-z]:\//.test(document.pathname)) hostToken = "d";
|
|
const strictEncode = (segment: string) =>
|
|
encodeURIComponent(decodeURIComponent(segment)).replace(
|
|
/[!'()*]/g,
|
|
(character) => `%${character.codePointAt(0)!.toString(16).toUpperCase()}`,
|
|
);
|
|
const path = document.pathname.split("/").map(strictEncode).join("/");
|
|
return `file:///__kandev_session_model__/${sessionToken}/${hostToken}${path}`;
|
|
}
|
|
|
|
export type LspDidOpenFrame = {
|
|
sessionId: string;
|
|
uri: string;
|
|
text: string;
|
|
};
|
|
|
|
/** Capture clean protocol-level didOpen notifications sent over browser LSP sockets. */
|
|
export function attachLspDidOpenCapture(page: Page): LspDidOpenFrame[] {
|
|
const frames: LspDidOpenFrame[] = [];
|
|
page.on("websocket", (socket) => {
|
|
const sessionMatch = /\/lsp\/([^?]+)/.exec(socket.url());
|
|
if (!sessionMatch) return;
|
|
const sessionId = decodeURIComponent(sessionMatch[1]);
|
|
socket.on("framesent", (event) => {
|
|
if (typeof event.payload !== "string" || !event.payload.includes("textDocument/didOpen")) {
|
|
return;
|
|
}
|
|
try {
|
|
const message = JSON.parse(event.payload) as {
|
|
method?: string;
|
|
params?: { textDocument?: { uri?: string; text?: string } };
|
|
};
|
|
const document = message.params?.textDocument;
|
|
if (message.method === "textDocument/didOpen" && document?.uri && document.text) {
|
|
frames.push({ sessionId, uri: document.uri, text: document.text });
|
|
}
|
|
} catch {
|
|
// Ignore non-JSON frames and unrelated bridge traffic.
|
|
}
|
|
});
|
|
});
|
|
return frames;
|
|
}
|
|
|
|
export type MonacoModelSnapshot = {
|
|
uri: string;
|
|
pathname: string;
|
|
search: string;
|
|
hash: string;
|
|
content: string;
|
|
};
|
|
|
|
/** Read browser-only, session-namespaced Monaco models for one file name. */
|
|
export async function readSessionModelSnapshots(
|
|
page: Page,
|
|
fileName: string,
|
|
): Promise<MonacoModelSnapshot[]> {
|
|
return page.evaluate((targetFileName) => {
|
|
const monaco = (
|
|
window as typeof window & {
|
|
monaco?: {
|
|
editor: {
|
|
getModels: () => Array<{
|
|
getValue: () => string;
|
|
uri: { toString: () => string };
|
|
}>;
|
|
};
|
|
};
|
|
}
|
|
).monaco;
|
|
return (monaco?.editor.getModels() ?? []).flatMap((model) => {
|
|
try {
|
|
const uri = new URL(model.uri.toString());
|
|
if (
|
|
uri.protocol !== "file:" ||
|
|
!decodeURIComponent(uri.pathname).endsWith(`/${targetFileName}`) ||
|
|
!uri.pathname.startsWith("/__kandev_session_model__/s-")
|
|
) {
|
|
return [];
|
|
}
|
|
return [
|
|
{
|
|
uri: uri.toString(),
|
|
pathname: uri.pathname,
|
|
search: uri.search,
|
|
hash: uri.hash,
|
|
content: model.getValue(),
|
|
},
|
|
];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}, fileName);
|
|
}
|
|
|
|
export async function openDesktopFile(
|
|
page: Page,
|
|
session: SessionPage,
|
|
filePath: string,
|
|
): Promise<void> {
|
|
const pathSegments = filePath.split("/");
|
|
const fileNode = session.fileTreeNode(filePath);
|
|
// Git status updates can auto-activate the Changes panel just after the
|
|
// file tree renders. Re-select Files and click only while the row is still
|
|
// visible so a late panel switch cannot turn a successful visibility check
|
|
// into a 180-second click timeout. Virtualized trees can also omit a valid
|
|
// file row from the DOM, so use the exact file search in that case.
|
|
await session.clickTab("Files", { force: true });
|
|
await expect(session.files).toBeVisible({ timeout: 30_000 });
|
|
const existingSearch = session.fileSearchInput();
|
|
if (await existingSearch.isVisible()) {
|
|
await existingSearch.press("Escape");
|
|
}
|
|
|
|
let openMode: "tree" | "search" | false = false;
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
try {
|
|
await session.clickTab("Files", { force: true });
|
|
if (!(await session.files.isVisible())) return false;
|
|
if (await session.fileSearchInput().isVisible()) {
|
|
await session.fileSearchInput().press("Escape");
|
|
return false;
|
|
}
|
|
for (let index = 1; index < pathSegments.length; index++) {
|
|
const ancestor = session.fileTreeNode(pathSegments.slice(0, index).join("/"));
|
|
if (!(await ancestor.isVisible())) {
|
|
openMode = (await session.fileSearchButton().isVisible()) ? "search" : false;
|
|
return openMode;
|
|
}
|
|
if ((await ancestor.locator(".tabler-icon-chevron-right").count()) > 0) {
|
|
await ancestor.click();
|
|
}
|
|
}
|
|
if (await fileNode.isVisible()) {
|
|
await fileNode.click({ timeout: 2_000 });
|
|
openMode = "tree";
|
|
return openMode;
|
|
}
|
|
openMode = (await session.fileSearchButton().isVisible()) ? "search" : false;
|
|
return openMode;
|
|
} catch {
|
|
openMode = false;
|
|
return false;
|
|
}
|
|
},
|
|
{ timeout: 30_000, intervals: [500, 1000, 2000] },
|
|
)
|
|
.toBeTruthy();
|
|
|
|
if (openMode === "search") {
|
|
await session.fileSearchButton().click();
|
|
const searchInput = session.fileSearchInput();
|
|
await expect(searchInput).toBeVisible({ timeout: 5_000 });
|
|
// Search matches the repository-relative path, while the task tree path
|
|
// may include a repository prefix. Query the exact filename so paths with
|
|
// spaces or punctuation remain searchable, then select by the exact path.
|
|
await searchInput.fill(path.posix.basename(filePath));
|
|
const searchResult = session.fileSearchResult(filePath);
|
|
await expect(searchResult).toBeVisible({ timeout: 15_000 });
|
|
await searchResult.click();
|
|
await searchInput.press("Escape");
|
|
}
|
|
await expect(page.locator(".dv-default-tab", { hasText: path.basename(filePath) })).toBeVisible({
|
|
timeout: 10_000,
|
|
});
|
|
await expect(page.locator(".monaco-editor:visible")).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
export async function openLspStatus(page: Page): Promise<Locator> {
|
|
const trigger = page
|
|
.locator('[data-testid="lsp-status-button"]:visible, [data-testid="app-status-lsp"]:visible')
|
|
.first();
|
|
if ((await trigger.getAttribute("aria-expanded")) !== "true") {
|
|
await trigger.click();
|
|
}
|
|
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
|
|
const surface = page.locator(
|
|
'[data-testid="lsp-status-popover"]:visible, [data-testid="lsp-status-drawer"]:visible',
|
|
);
|
|
await expect(surface).toBeVisible();
|
|
return surface;
|
|
}
|
|
|
|
export async function performLspAction(
|
|
page: Page,
|
|
action: "start" | "stop" | "retry",
|
|
): Promise<void> {
|
|
const surface = await openLspStatus(page);
|
|
const actionButton = surface.locator(
|
|
`[data-testid="lsp-lifecycle-action"][data-lsp-action="${action}"]`,
|
|
);
|
|
await expect(actionButton).toBeVisible();
|
|
await expect(actionButton).toBeEnabled();
|
|
await actionButton.click();
|
|
}
|
|
|
|
export function removeFakeKotlinLsp(backend: BackendContext): void {
|
|
const binary = fakeServerPath(backend);
|
|
try {
|
|
// The binary is test-owned and may not exist yet.
|
|
fs.unlinkSync(binary);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
export function installFakeKotlinLsp(
|
|
backend: BackendContext,
|
|
options: {
|
|
crashOnOpen?: boolean;
|
|
holdInitialize?: boolean;
|
|
keepProgress?: boolean;
|
|
progress?: {
|
|
title: string;
|
|
beginPercentage?: number;
|
|
message?: string;
|
|
percentage?: number;
|
|
endMessage?: string;
|
|
};
|
|
} = {},
|
|
): void {
|
|
fs.mkdirSync(path.dirname(fakeServerPath(backend)), { recursive: true });
|
|
fs.copyFileSync(FAKE_SERVER_SOURCE, fakeServerPath(backend));
|
|
fs.chmodSync(fakeServerPath(backend), 0o755);
|
|
fs.rmSync(fakeServerLogPath(backend), { force: true });
|
|
fs.rmSync(crashModePath(backend), { force: true });
|
|
fs.rmSync(initializeModePath(backend), { force: true });
|
|
fs.rmSync(initializeReleasePath(backend), { force: true });
|
|
if (options.crashOnOpen) fs.writeFileSync(crashModePath(backend), "1\n");
|
|
if (
|
|
options.holdInitialize ||
|
|
(options.progress && !options.keepProgress) ||
|
|
options.keepProgress
|
|
) {
|
|
fs.writeFileSync(
|
|
initializeModePath(backend),
|
|
JSON.stringify({ progress: options.progress ?? null, keepProgress: options.keepProgress }),
|
|
);
|
|
}
|
|
}
|
|
|
|
export function installAdditionalFakeLspBinary(backend: BackendContext, binaryName: string): void {
|
|
const destination = path.join(backend.tmpDir, "bin", binaryName);
|
|
fs.copyFileSync(FAKE_SERVER_SOURCE, destination);
|
|
fs.chmodSync(destination, 0o755);
|
|
}
|
|
|
|
export function clearFakeKotlinLspModes(backend: BackendContext): void {
|
|
fs.rmSync(crashModePath(backend), { force: true });
|
|
fs.rmSync(initializeModePath(backend), { force: true });
|
|
fs.rmSync(initializeReleasePath(backend), { force: true });
|
|
}
|
|
|
|
export function releaseFakeLspInitialization(backend: BackendContext): void {
|
|
fs.writeFileSync(initializeReleasePath(backend), "release\n");
|
|
}
|
|
|
|
export function readFakeLspEvents(backend: BackendContext): FakeLspEvent[] {
|
|
try {
|
|
return fs
|
|
.readFileSync(fakeServerLogPath(backend), "utf8")
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.map((line) => JSON.parse(line) as FakeLspEvent);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function expectFakeLspEvent(
|
|
backend: BackendContext,
|
|
predicate: (event: FakeLspEvent) => boolean,
|
|
description: string,
|
|
timeout = 15_000,
|
|
): Promise<FakeLspEvent> {
|
|
let matched: FakeLspEvent | undefined;
|
|
await expect
|
|
.poll(
|
|
() => {
|
|
matched = readFakeLspEvents(backend).find(predicate);
|
|
return matched !== undefined;
|
|
},
|
|
{ message: `waiting for fake LSP event: ${description}`, timeout },
|
|
)
|
|
.toBe(true);
|
|
return matched!;
|
|
}
|
|
|
|
export async function expectFakeLspMarkerCount(
|
|
page: Page,
|
|
expectedCount: number,
|
|
timeout = 15_000,
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(() => {
|
|
const monaco = (
|
|
window as typeof window & {
|
|
monaco?: {
|
|
editor: {
|
|
getModelMarkers: (filter: Record<string, never>) => Array<{ source?: string }>;
|
|
};
|
|
};
|
|
}
|
|
).monaco;
|
|
return (
|
|
monaco?.editor.getModelMarkers({}).filter((marker) => marker.source === "kandev-e2e")
|
|
.length ?? 0
|
|
);
|
|
}),
|
|
{ message: `waiting for ${expectedCount} fake LSP Monaco marker(s)`, timeout },
|
|
)
|
|
.toBe(expectedCount);
|
|
}
|
|
|
|
export async function expectFakeLspMarkerMessages(
|
|
page: Page,
|
|
modelUri: string,
|
|
expectedMessages: string[],
|
|
timeout = 15_000,
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate((targetModelUri) => {
|
|
const monaco = (
|
|
window as typeof window & {
|
|
monaco?: {
|
|
editor: {
|
|
getModelMarkers: (filter: Record<string, never>) => Array<{
|
|
message: string;
|
|
resource: { toString: () => string };
|
|
source?: string;
|
|
}>;
|
|
};
|
|
};
|
|
}
|
|
).monaco;
|
|
return (monaco?.editor.getModelMarkers({}) ?? [])
|
|
.filter(
|
|
(marker) =>
|
|
marker.source === "kandev-e2e" && marker.resource.toString() === targetModelUri,
|
|
)
|
|
.map((marker) => marker.message);
|
|
}, modelUri),
|
|
{
|
|
message: `waiting for fake LSP Monaco markers on ${modelUri}`,
|
|
timeout,
|
|
},
|
|
)
|
|
.toEqual(expectedMessages);
|
|
}
|