mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
test(e2e): cover the core editing, code intelligence, VCS, run and debug flows (#1277)
Adds a `stubDesktop` fixture that switches the browser-mode workbench to desktop mode over a stubbed IPC layer: tests answer the commands they need, push Channel messages and IdeEvents, and assert on the commands invoked. Unstubbed commands fail as unavailable; documents keep the in-memory backend. New flows covered: - editing: modified marker and ⌘S save, ⌘R Replace All with one undo step - code intelligence: completion, ⌘B to another file, Find Usages, in-place rename, diagnostics with an ⌥⏎ quick fix - VCS: ⌘K commit with message validation and default inclusion - run: ⌃R output streaming and exit, ⌘F2 stop - debug: breakpoint, stop with frames and variables, step over, resume - search: Find in Files results opening at the match line; the Search Everywhere file case no longer needs fixme - terminal: typed command reaches the shell and its output renders - project: Close Project to Welcome, trust prompt, open a recent project
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Code intelligence through the editor: completion, navigation, usages, rename, quick fixes and
|
||||
* diagnostics. The language server is stubbed at the IPC layer; the servers themselves are covered
|
||||
* by `crates/ide-lsp/tests`.
|
||||
*/
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const FILE = `${ROOT}/build.gradle.kts`;
|
||||
|
||||
const at = (line: number, col: number) => ({ line, col });
|
||||
const range = (line: number, from: number, to: number) => ({ start: at(line, from), end: at(line, to) });
|
||||
|
||||
/** Puts the caret just before `word` on the line containing `text`. */
|
||||
async function caretAt(editor: Locator, page: Page, text: string, word: string) {
|
||||
const line = editor.locator(".cm-line", { hasText: text });
|
||||
await line.click();
|
||||
await page.keyboard.press("Home");
|
||||
const content = (await line.textContent()) ?? "";
|
||||
const indent = content.length - content.trimStart().length;
|
||||
for (let i = indent; i < content.indexOf(word); i++) await page.keyboard.press("ArrowRight");
|
||||
}
|
||||
|
||||
async function openSample(page: Page) {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await stubDesktop(page);
|
||||
return editor;
|
||||
}
|
||||
|
||||
test("completion lists the server's items and inserts the chosen one", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(() => {
|
||||
const item = (label: string, kind: string, detail: string, insertText: string) => ({
|
||||
label,
|
||||
labelDetail: null,
|
||||
detail,
|
||||
kind,
|
||||
documentation: null,
|
||||
sortText: null,
|
||||
filterText: null,
|
||||
preselect: false,
|
||||
deprecated: false,
|
||||
insertText,
|
||||
isSnippet: false,
|
||||
replaceRange: null,
|
||||
replaceRangeTab: null,
|
||||
additionalEdits: [],
|
||||
commitCharacters: [],
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
});
|
||||
window.__ipc.handlers.lsp_completion = () => ({
|
||||
isIncomplete: false,
|
||||
items: [item("toDto", "method", "OrderDto", "toDto()"), item("total", "property", "BigDecimal", "total")],
|
||||
});
|
||||
});
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("order.");
|
||||
await page.keyboard.press("Control+Space");
|
||||
const popup = page.locator(".cm-tooltip-autocomplete");
|
||||
await expect(popup.getByText("total", { exact: true })).toBeVisible();
|
||||
await expect(popup.getByText("toDto", { exact: true })).toBeVisible();
|
||||
await page.keyboard.type("toD");
|
||||
await expect(popup.getByText("total", { exact: true })).toBeHidden();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(popup).toBeHidden();
|
||||
await expect(editor.locator(".cm-activeLine")).toHaveText(/^\s*order\.toDto\(\)$/);
|
||||
expect((await ipcCalls(page, "lsp_completion")).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("⌘B goes to the declaration in another file", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ path, target }) => {
|
||||
window.__ipc.handlers.lsp_goto = () => [{ path, range: target }];
|
||||
},
|
||||
{ path: `${ROOT}/settings.gradle.kts`, target: range(20, 12, 17) },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "service");
|
||||
await page.keyboard.press("Meta+b");
|
||||
const tabs = page.getByRole("tablist", { name: "Editor tabs" });
|
||||
await expect(tabs.getByRole("tab", { name: "settings.gradle.kts" })).toHaveAttribute("aria-selected", "true");
|
||||
const target = page.locator(".cm-editor").filter({ has: page.locator(".cm-activeLine") });
|
||||
await expect(target.locator(".cm-activeLine")).toContainText("val order: Order = service.create(req)");
|
||||
const [args] = await ipcCalls(page, "lsp_goto");
|
||||
expect(args).toMatchObject({ pos: at(20, 27), kind: "definition" });
|
||||
});
|
||||
|
||||
test("Find Usages lists every usage by file and opens one", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, other }) => {
|
||||
const loc = (path: string, line: number, from: number, to: number) => ({
|
||||
path,
|
||||
range: { start: { line, col: from }, end: { line, col: to } },
|
||||
});
|
||||
window.__ipc.handlers.lsp_references = () => ({
|
||||
locations: [loc(file, 16, 8, 15), loc(file, 20, 27, 34), loc(other, 16, 8, 15)],
|
||||
incomplete: null,
|
||||
});
|
||||
},
|
||||
{ file: FILE, other: `${ROOT}/settings.gradle.kts` },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "service");
|
||||
await page.keyboard.press("Alt+F7");
|
||||
const usages = page.getByRole("tree", { name: /usages/i });
|
||||
await expect(usages.getByRole("treeitem", { name: "Found usages 3 results" })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: "build.gradle.kts 2 usages" })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: /^17 service\.find\(status/ })).toBeVisible();
|
||||
await expect(usages.getByRole("treeitem", { name: /^21 val order: Order = service\.create/ })).toBeVisible();
|
||||
const other = usages.getByRole("treeitem", { name: "settings.gradle.kts 1 usage" });
|
||||
await expect(other).toBeVisible();
|
||||
// Double-clicking a usage in another file opens that file.
|
||||
await usages.getByRole("treeitem", { name: /^17/ }).last().dblclick();
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Editor tabs" }).getByRole("tab", { name: "settings.gradle.kts" }),
|
||||
).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
test("⇧F6 renames in place and sends the server's edit to be applied", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, prepared, uses }) => {
|
||||
window.__ipc.handlers.lsp_prepare_rename = () => prepared;
|
||||
window.__ipc.handlers.lsp_rename = (args) => ({
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: file,
|
||||
edits: uses.map((range) => ({ range, new_text: args.newName })),
|
||||
},
|
||||
],
|
||||
},
|
||||
incomplete: null,
|
||||
});
|
||||
},
|
||||
{ file: FILE, prepared: range(20, 12, 17), uses: [range(20, 12, 17), range(21, 15, 20)] },
|
||||
);
|
||||
await caretAt(editor, page, "val order: Order", "order");
|
||||
await page.keyboard.press("Shift+F6");
|
||||
await expect(page.getByText(/Press ⇧F6 to show dialog/)).toBeVisible();
|
||||
// The name starts selected; occurrences follow as it's typed.
|
||||
await page.keyboard.type("placed");
|
||||
await expect(editor.locator(".cm-line", { hasText: "val placed: Order" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect.poll(() => ipcCalls(page, "apply_workspace_edit")).toHaveLength(1);
|
||||
const [rename] = await ipcCalls(page, "lsp_rename");
|
||||
expect(rename).toMatchObject({ pos: at(20, 12), newName: "placed" });
|
||||
const [apply] = await ipcCalls(page, "apply_workspace_edit");
|
||||
expect(JSON.stringify(apply)).toContain('"new_text":"placed"');
|
||||
});
|
||||
|
||||
test("diagnostics underline the range and ⌥⏎ applies the preferred quick fix", async ({ workbench: page }) => {
|
||||
const editor = await openSample(page);
|
||||
await page.evaluate(
|
||||
({ file, bad }) => {
|
||||
window.__ipc.handlers.lsp_code_actions = () => [
|
||||
{
|
||||
title: "Import 'com.acme.orders.model.Order'",
|
||||
kind: "quickfix",
|
||||
isPreferred: true,
|
||||
disabledReason: null,
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: file,
|
||||
edits: [
|
||||
{
|
||||
range: { start: { line: 4, col: 0 }, end: { line: 4, col: 0 } },
|
||||
new_text: "import com.acme.orders.model.Order\n",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
},
|
||||
{
|
||||
title: "Create class 'Order'",
|
||||
kind: "quickfix",
|
||||
isPreferred: false,
|
||||
disabledReason: null,
|
||||
edit: null,
|
||||
command: null,
|
||||
data: null,
|
||||
server: "kotlin",
|
||||
},
|
||||
];
|
||||
window.__ipc.emit({
|
||||
type: "diagnostics",
|
||||
path: file,
|
||||
source: "kotlin",
|
||||
items: [
|
||||
{ range: bad, severity: "error", message: "Unresolved reference: Order", source: "kotlin", code: null },
|
||||
],
|
||||
});
|
||||
},
|
||||
{ file: FILE, bad: range(20, 19, 24) },
|
||||
);
|
||||
const squiggle = editor.locator(".cm-lintRange-error");
|
||||
await expect(squiggle).toHaveText("Order");
|
||||
await caretAt(editor, page, "val order: Order", "Order ");
|
||||
await page.keyboard.press("Alt+Enter");
|
||||
const fix = page.getByRole("option", { name: /Import 'com\.acme\.orders\.model\.Order'/ });
|
||||
await expect(fix).toBeVisible();
|
||||
await expect(page.getByRole("option", { name: /Create class 'Order'/ })).toBeVisible();
|
||||
await fix.click();
|
||||
await expect.poll(() => ipcCalls(page, "lsp_execute_code_action")).toHaveLength(1);
|
||||
const [applied] = await ipcCalls(page, "lsp_execute_code_action");
|
||||
expect(applied).toMatchObject({ action: { title: "Import 'com.acme.orders.model.Order'" } });
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Committing from the Commit tool window. Git is stubbed at the IPC layer; the repository side is
|
||||
* covered by `crates/ide-vcs` against real repositories.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
|
||||
/** A repository with a modified tracked file and an untracked one; `vcs_commit` clears both. */
|
||||
async function stubRepository(page: Page) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(async (root) => {
|
||||
let committed = false;
|
||||
const changes = [
|
||||
{
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
staged: null,
|
||||
unstaged: "modified",
|
||||
renamedFrom: null,
|
||||
},
|
||||
{ path: `${root}/notes.txt`, relPath: "notes.txt", staged: null, unstaged: "untracked", renamedFrom: null },
|
||||
];
|
||||
const h = window.__ipc.handlers;
|
||||
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
|
||||
h.vcs_branch_info = () => ({
|
||||
head: "main",
|
||||
headCommit: "1111111",
|
||||
upstream: null,
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
operation: null,
|
||||
});
|
||||
h.vcs_status = () => (committed ? [] : changes);
|
||||
h.vcs_stage = () => null;
|
||||
h.vcs_unstage = () => null;
|
||||
h.vcs_commit = () => {
|
||||
committed = true;
|
||||
return "2222222";
|
||||
};
|
||||
const vcsModule = "/src/features/vcs/store.ts";
|
||||
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
|
||||
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
|
||||
await refreshNow();
|
||||
}, ROOT);
|
||||
}
|
||||
|
||||
test("⌘K commits the checked changes with the typed message", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "build.gradle.kts");
|
||||
await stubRepository(page);
|
||||
await page.keyboard.press("Meta+k");
|
||||
const panel = page.getByRole("region", { name: "Commit", exact: true });
|
||||
const commit = panel.getByRole("button", { name: "Commit", exact: true });
|
||||
const changes = panel.getByRole("tree", { name: "Changes" });
|
||||
// Tracked changes start included; unversioned files start excluded.
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeChecked();
|
||||
await expect(changes.getByRole("checkbox", { name: "Include Unversioned Files in commit" })).not.toBeChecked();
|
||||
const message = panel.getByRole("textbox", { name: "Commit message" });
|
||||
await expect(message).toBeFocused();
|
||||
|
||||
// Committing without a message is refused.
|
||||
await commit.click();
|
||||
await expect(panel.getByRole("alert")).toHaveText("Specify commit message");
|
||||
expect(await ipcCalls(page, "vcs_commit")).toHaveLength(0);
|
||||
|
||||
await message.fill("Return order DTOs from create");
|
||||
await commit.click();
|
||||
await expect(page.getByText(/1 file committed: Return order DTOs from create/)).toBeVisible();
|
||||
const [committed] = await ipcCalls(page, "vcs_commit");
|
||||
expect(committed).toMatchObject({ repo: ROOT, message: "Return order DTOs from create" });
|
||||
const staged = (await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]);
|
||||
expect(staged).toEqual([`${ROOT}/build.gradle.kts`]);
|
||||
await expect(changes.getByRole("checkbox", { name: "Include build.gradle.kts in commit" })).toBeHidden();
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* A debugging session: a breakpoint set in the editor reaches the adapter, the session stops there
|
||||
* with its frames and variables, and stepping and resuming go back to the adapter. The adapter is
|
||||
* stubbed at the IPC layer; `crates/ide-dap/tests` drives real ones.
|
||||
*/
|
||||
import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const FILE = `${ROOT}/build.gradle.kts`;
|
||||
const SESSION = 3;
|
||||
|
||||
test("stops at a breakpoint, shows frames and variables, steps and resumes", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ file, root, session }) => {
|
||||
const config = {
|
||||
id: "python:app",
|
||||
name: "app",
|
||||
kind: "python",
|
||||
command: "python3",
|
||||
args: ["app.py"],
|
||||
cwd: root,
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: "debugpy",
|
||||
};
|
||||
const h = window.__ipc.handlers;
|
||||
const debug = (event: Record<string, unknown>) => window.__ipc.emit({ type: "debug", session, event });
|
||||
const stopAt = (line: number) => {
|
||||
h.debug_stack_trace = () => [
|
||||
{ id: 11, name: "create", path: file, line, column: 0, presentationHint: null },
|
||||
{ id: 12, name: "handle", path: file, line: 16, column: 0, presentationHint: null },
|
||||
];
|
||||
debug({ kind: "stopped", threadId: 1, reason: "breakpoint", description: null, allThreadsStopped: true });
|
||||
};
|
||||
h.run_configs = () => [config];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.debug_set_breakpoints = (args) =>
|
||||
(args.breakpoints as { line: number }[]).map((b, i) => ({
|
||||
id: i + 1,
|
||||
verified: true,
|
||||
line: b.line,
|
||||
message: null,
|
||||
}));
|
||||
h.debug_start = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "started", name: config.name, adapter: "debugpy", parent: null });
|
||||
debug({ kind: "initialized" });
|
||||
stopAt(20);
|
||||
}, 50);
|
||||
return session;
|
||||
};
|
||||
h.debug_threads = () => [{ id: 1, name: "MainThread" }];
|
||||
h.debug_scopes = () => [{ name: "Locals", variablesReference: 100, expensive: false }];
|
||||
h.debug_variables = () => [
|
||||
{
|
||||
name: "req",
|
||||
value: "CreateOrderRequest(sku='A-1', qty=2)",
|
||||
typeName: "CreateOrderRequest",
|
||||
variablesReference: 0,
|
||||
evaluateName: "req",
|
||||
},
|
||||
{ name: "qty", value: "2", typeName: "int", variablesReference: 0, evaluateName: "qty" },
|
||||
];
|
||||
h.debug_step = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "continued", threadId: 1, allThreadsContinued: true });
|
||||
stopAt(21);
|
||||
}, 50);
|
||||
return null;
|
||||
};
|
||||
h.debug_resume = () => {
|
||||
setTimeout(() => {
|
||||
debug({ kind: "continued", threadId: 1, allThreadsContinued: true });
|
||||
debug({ kind: "exited", code: 0 });
|
||||
debug({ kind: "terminated" });
|
||||
}, 50);
|
||||
return null;
|
||||
};
|
||||
const runModule = "/src/features/run/store.ts";
|
||||
const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ runModule);
|
||||
await loadConfigs();
|
||||
selectConfig(config.id);
|
||||
},
|
||||
{ file: FILE, root: ROOT, session: SESSION },
|
||||
);
|
||||
|
||||
// Breakpoint and execution lines carry CSS-module classes (`_bpLine_<hash>`).
|
||||
const line = (text: string) => editor.locator(".cm-line", { hasText: text });
|
||||
await line("val order: Order").click();
|
||||
await page.keyboard.press("Meta+F8");
|
||||
await expect(line("val order: Order")).toHaveClass(/_bpLine_/);
|
||||
|
||||
await page.keyboard.press("Control+d");
|
||||
await expect.poll(() => ipcCalls(page, "debug_start")).toHaveLength(1);
|
||||
const set = (await ipcCalls(page, "debug_set_breakpoints")).filter((a) => a.path === FILE).at(-1);
|
||||
expect(set?.breakpoints).toEqual([expect.objectContaining({ line: 20 })]);
|
||||
|
||||
const debug = page.getByRole("region", { name: "Debug", exact: true });
|
||||
await expect(debug.getByText("create", { exact: true })).toBeVisible();
|
||||
await expect(debug.getByText("handle", { exact: true })).toBeVisible();
|
||||
await expect(debug.getByText("CreateOrderRequest(sku='A-1', qty=2)")).toBeVisible();
|
||||
await expect(line("val order: Order")).toHaveClass(/_execLine_/);
|
||||
|
||||
await page.keyboard.press("F8");
|
||||
await expect.poll(() => ipcCalls(page, "debug_step")).toEqual([{ session: SESSION, threadId: 1, kind: "over" }]);
|
||||
await expect(line("return order.toDto()")).toHaveClass(/_execLine_/);
|
||||
await expect(line("val order: Order")).not.toHaveClass(/_execLine_/);
|
||||
|
||||
await page.keyboard.press("F9");
|
||||
await expect.poll(() => ipcCalls(page, "debug_resume")).toHaveLength(1);
|
||||
await expect(debug.getByText("CreateOrderRequest(sku='A-1', qty=2)")).toBeHidden();
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
test("opens a file from the project tree and types into it", async ({ workbench: page }) => {
|
||||
@@ -59,3 +60,44 @@ test("breadcrumbs follow the caret into the symbol under it", async ({ workbench
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(bar).toBeHidden();
|
||||
});
|
||||
|
||||
/** What the in-memory backend has saved for `path`. */
|
||||
const saved = (page: Page, path: string) =>
|
||||
page.evaluate(async (path) => {
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const { docBackend } = await import(/* @vite-ignore */ backendModule);
|
||||
return (docBackend() as { files: Map<string, string> }).files.get(path) ?? null;
|
||||
}, path);
|
||||
|
||||
test("an edit marks the tab modified and ⌘S saves it", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
// The only tab; its modified dot sits next to its close button.
|
||||
const tabs = page.getByRole("tablist", { name: "Editor tabs" });
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(0);
|
||||
await editor.locator(".cm-line", { hasText: "return order.toDto()" }).click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.type("// saved by e2e");
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(1);
|
||||
expect(await saved(page, "/dev/acme-api/build.gradle.kts")).toBeNull();
|
||||
await page.keyboard.press("Meta+s");
|
||||
await expect(tabs.getByLabel("Modified")).toHaveCount(0);
|
||||
expect(await saved(page, "/dev/acme-api/build.gradle.kts")).toContain("// saved by e2e");
|
||||
});
|
||||
|
||||
test("⌘R replaces every match in the file", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await editor.locator(".cm-content").click();
|
||||
await page.keyboard.press("Meta+r");
|
||||
const bar = page.getByRole("search", { name: "Replace in file" });
|
||||
await bar.getByRole("textbox", { name: "Find" }).fill("OrderDto");
|
||||
await bar.getByRole("textbox", { name: "Replace" }).fill("OrderView");
|
||||
await bar.getByRole("button", { name: "Replace All" }).click();
|
||||
const content = editor.locator(".cm-content");
|
||||
await expect(content).not.toContainText("OrderDto");
|
||||
await expect(editor.locator(".cm-line", { hasText: "OrderView" })).toHaveCount(2);
|
||||
// One undo step takes the whole replacement back.
|
||||
await content.click();
|
||||
await page.keyboard.press("Meta+z");
|
||||
await expect(editor.locator(".cm-line", { hasText: "OrderDto" })).toHaveCount(2);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,97 @@ export const test = base.extend<{ workbench: Page }>({
|
||||
|
||||
export { expect };
|
||||
|
||||
/** What `stubDesktop` installs on `window.__ipc`; handlers are defined inside `page.evaluate`. */
|
||||
export interface IpcStub {
|
||||
/** Command name as the backend registers it (`lsp_goto`) → its answer; throw `{ kind, message }` for an `IdeError`. */
|
||||
handlers: Record<string, (args: Record<string, unknown>) => unknown>;
|
||||
calls: { cmd: string; args: Record<string, unknown> }[];
|
||||
/** Sends one message down a `Channel` passed to a command. */
|
||||
send(channel: unknown, message: unknown): void;
|
||||
/** Emits an `IdeEvent` the way the backend does. */
|
||||
emit(event: { type: string } & Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ipc: IpcStub;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the page into desktop mode over a stubbed native layer: commands with a handler answer,
|
||||
* every other command fails as unavailable, the way a backend without that feature does. Documents
|
||||
* keep their in-memory backend; the project tree keeps its sample only while it isn't reloaded, so
|
||||
* open files from it before stubbing.
|
||||
*/
|
||||
export async function stubDesktop(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
// Documents stay in memory: the editor's backend is chosen once, on first use.
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const { docBackend } = await import(/* @vite-ignore */ backendModule);
|
||||
docBackend();
|
||||
const callbacks = new Map<number, (message: unknown) => void>();
|
||||
const channelIndex = new Map<number, number>();
|
||||
const listeners: { event: string; handler: number }[] = [];
|
||||
let nextCallback = 1;
|
||||
const ipc: IpcStub = {
|
||||
// This window is the only one.
|
||||
handlers: { "plugin:window|get_all_windows": () => [] },
|
||||
calls: [],
|
||||
send(channel, message) {
|
||||
const id = (channel as { id: number }).id;
|
||||
const index = channelIndex.get(id) ?? 0;
|
||||
channelIndex.set(id, index + 1);
|
||||
callbacks.get(id)?.({ index, message });
|
||||
},
|
||||
emit(event) {
|
||||
for (const l of listeners)
|
||||
if (l.event === "ide://event") callbacks.get(l.handler)?.({ event: l.event, id: 0, payload: event });
|
||||
},
|
||||
};
|
||||
window.__ipc = ipc;
|
||||
Object.defineProperty(window, "__TAURI_EVENT_PLUGIN_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: { unregisterListener: () => {} },
|
||||
});
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
transformCallback(cb: (message: unknown) => void) {
|
||||
const id = nextCallback++;
|
||||
callbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
unregisterCallback(id: number) {
|
||||
callbacks.delete(id);
|
||||
},
|
||||
convertFileSrc: (path: string) => path,
|
||||
async invoke(cmd: string, args: Record<string, unknown> = {}) {
|
||||
ipc.calls.push({ cmd, args });
|
||||
if (cmd === "plugin:event|listen") {
|
||||
listeners.push({ event: args.event as string, handler: args.handler as number });
|
||||
return listeners.length;
|
||||
}
|
||||
const handler = ipc.handlers[cmd];
|
||||
// Tauri plugin calls (windows, events) succeed quietly; app commands are what tests stub.
|
||||
if (!handler && cmd.startsWith("plugin:")) return null;
|
||||
if (!handler) throw { kind: "unavailable", message: `${cmd} is not stubbed`, detail: null };
|
||||
return handler(args);
|
||||
},
|
||||
},
|
||||
});
|
||||
const ipcModule = "/src/platform/ipc.ts";
|
||||
const { ideEventsReady } = await import(/* @vite-ignore */ ipcModule);
|
||||
await ideEventsReady();
|
||||
});
|
||||
}
|
||||
|
||||
/** The commands the page has invoked so far under `stubDesktop`, by name. */
|
||||
export async function ipcCalls(page: Page, cmd: string): Promise<Record<string, unknown>[]> {
|
||||
return page.evaluate((cmd) => window.__ipc.calls.filter((c) => c.cmd === cmd).map((c) => c.args), cmd);
|
||||
}
|
||||
|
||||
/** Tool windows currently laid out (visible) in the workbench. */
|
||||
export async function visibleToolWindows(page: Page): Promise<string[]> {
|
||||
return page
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Closing a project returns to the Welcome screen, and a recent project opens from there into the
|
||||
* workbench. The backend's project handling is stubbed at the IPC layer; `crates/ide-host/tests`
|
||||
* covers opening and closing real projects.
|
||||
*/
|
||||
import { doubleShift, expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const BILLING = "/dev/billing";
|
||||
|
||||
test("Close Project shows the Welcome screen; a recent project opens from it once trusted", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate((root) => {
|
||||
const entry = (name: string, kind: string) => ({
|
||||
name,
|
||||
path: `${root}/${name}`,
|
||||
kind,
|
||||
size: 0,
|
||||
mtimeMs: null,
|
||||
ignored: false,
|
||||
excluded: false,
|
||||
});
|
||||
const h = window.__ipc.handlers;
|
||||
h.close_project = () => null;
|
||||
h.app_info = () => ({ version: "1.0.0", platform: "macos", configDir: "/Users/dev/.openidea", wayland: false });
|
||||
h.recent_projects = () => [
|
||||
{ path: root, name: "billing", openedAtMs: Date.now() - 3_600_000, branch: "main" },
|
||||
{ path: "/dev/acme-api", name: "acme-api", openedAtMs: Date.now(), branch: "main" },
|
||||
];
|
||||
let trusted = false;
|
||||
h.project_trust_status = (args) => ({ root: args.path, trusted, decided: trusted });
|
||||
h.project_trust_set = (args) => {
|
||||
trusted = Boolean(args.trusted);
|
||||
return null;
|
||||
};
|
||||
h.open_project = (args) => ({
|
||||
trusted: true,
|
||||
root: args.path,
|
||||
name: String(args.path).split("/").pop(),
|
||||
isGit: false,
|
||||
settings: {},
|
||||
});
|
||||
h.fs_read_dir = (args) =>
|
||||
args.path === root ? [entry("src", "dir"), entry("package.json", "file"), entry("README.md", "file")] : [];
|
||||
}, BILLING);
|
||||
|
||||
await doubleShift(page);
|
||||
const search = page.getByRole("dialog", { name: "Search Everywhere" });
|
||||
await search.getByRole("combobox").fill("Close Project");
|
||||
await expect(search.getByRole("option", { name: /^Close Project/ }).first()).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
const projects = page.getByRole("region", { name: "Projects" });
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toBeHidden();
|
||||
await expect(projects.getByRole("option", { name: /^billing/ })).toBeVisible();
|
||||
expect(await ipcCalls(page, "close_project")).toHaveLength(1);
|
||||
|
||||
await projects.getByRole("option", { name: /^billing/ }).click();
|
||||
// A project not yet trusted asks first.
|
||||
const trust = page.getByRole("dialog", { name: "Trust and Open Project?" });
|
||||
await expect(trust).toBeVisible();
|
||||
expect(await ipcCalls(page, "open_project")).toHaveLength(0);
|
||||
await trust.getByRole("button", { name: "Trust Project" }).click();
|
||||
await expect(trust).toBeHidden();
|
||||
expect(await ipcCalls(page, "project_trust_set")).toEqual([{ path: BILLING, trusted: true, folder: false }]);
|
||||
await expect.poll(() => ipcCalls(page, "open_project")).toEqual([{ path: BILLING }]);
|
||||
const tree = page.getByRole("tree", { name: "Project" });
|
||||
await expect(tree.getByRole("treeitem", { name: /^billing/ })).toBeVisible();
|
||||
await expect(tree.getByRole("treeitem", { name: "package.json" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Running a configuration: output streams into the Run tool window, the exit shows, and Stop ends
|
||||
* the process. Processes are stubbed at the IPC layer; `crates/ide-run` covers spawning them.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
const RUN_ID = 7;
|
||||
|
||||
/** One saved `npm run dev` configuration; `run_start` prints `output` and exits with `exit` unless null. */
|
||||
async function stubRun(page: Page, output: { kind: string; text: string }[], exit: number | null) {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
async ({ output, exit, runId }) => {
|
||||
const config = {
|
||||
id: "npm:dev",
|
||||
name: "dev",
|
||||
kind: "npm",
|
||||
command: "npm",
|
||||
args: ["run", "dev"],
|
||||
cwd: "/dev/acme-api",
|
||||
env: {},
|
||||
detected: false,
|
||||
isTest: false,
|
||||
debugAdapter: null,
|
||||
};
|
||||
const h = window.__ipc.handlers;
|
||||
h.run_configs = () => [config];
|
||||
h.run_apply_settings = (args) => args.configs;
|
||||
h.run_start = (args) => {
|
||||
setTimeout(() => {
|
||||
for (const chunk of output) window.__ipc.send(args.onOutput, chunk);
|
||||
if (exit !== null) setTimeout(() => window.__ipc.emit({ type: "runExited", run: runId, code: exit }), 50);
|
||||
}, 50);
|
||||
return { id: runId, configId: config.id, name: config.name, pid: 4242, running: true, pty: false };
|
||||
};
|
||||
h.run_stop = () => {
|
||||
setTimeout(() => window.__ipc.emit({ type: "runExited", run: runId, code: 143 }), 50);
|
||||
return null;
|
||||
};
|
||||
const runModule = "/src/features/run/store.ts";
|
||||
const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ runModule);
|
||||
await loadConfigs();
|
||||
selectConfig(config.id);
|
||||
},
|
||||
{ output, exit, runId: RUN_ID },
|
||||
);
|
||||
}
|
||||
|
||||
test("⌃R runs the selected configuration and shows its output and exit", async ({ workbench: page }) => {
|
||||
await stubRun(
|
||||
page,
|
||||
[
|
||||
{ kind: "stdout", text: "> acme-api@1.0.0 dev\n" },
|
||||
{ kind: "stdout", text: "Server listening on http://localhost:8080\n" },
|
||||
{ kind: "stderr", text: "warning: config/dev.json not found, using defaults\n" },
|
||||
],
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press("Control+r");
|
||||
const run = page.getByRole("region", { name: "Run", exact: true });
|
||||
await expect(run).toBeVisible();
|
||||
await expect(run.getByText("Server listening on http://localhost:8080")).toBeVisible();
|
||||
await expect(run.getByText("warning: config/dev.json not found, using defaults")).toBeVisible();
|
||||
// Once the process exits, Stop is off and the session's tab tells the exit code.
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeDisabled();
|
||||
await expect(run.getByRole("tab", { name: "dev" })).toHaveAttribute("data-tooltip", /exit code 0$/);
|
||||
const [started] = await ipcCalls(page, "run_start");
|
||||
expect(started).toMatchObject({ config: { id: "npm:dev", command: "npm", args: ["run", "dev"] } });
|
||||
});
|
||||
|
||||
test("⌘F2 stops a running process", async ({ workbench: page }) => {
|
||||
await stubRun(page, [{ kind: "stdout", text: "Watching for changes…\n" }], null);
|
||||
await page.keyboard.press("Control+r");
|
||||
const run = page.getByRole("region", { name: "Run", exact: true });
|
||||
await expect(run.getByText("Watching for changes…")).toBeVisible();
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeEnabled();
|
||||
await page.keyboard.press("Meta+F2");
|
||||
await expect.poll(() => ipcCalls(page, "run_stop")).toEqual([{ id: RUN_ID }]);
|
||||
await expect(run.getByRole("button", { name: "Stop" })).toBeDisabled();
|
||||
await expect(run.getByRole("tab", { name: "dev" })).toHaveAttribute("data-tooltip", /exit code 143$/);
|
||||
});
|
||||
+83
-6
@@ -1,4 +1,4 @@
|
||||
import { doubleShift, expect, test } from "./fixtures";
|
||||
import { doubleShift, expect, ipcCalls, stubDesktop, test } from "./fixtures";
|
||||
|
||||
test("double Shift opens Search Everywhere with its tabs; Escape closes it", async ({ workbench: page }) => {
|
||||
await doubleShift(page);
|
||||
@@ -23,15 +23,92 @@ test("Search Everywhere finds and runs an action", async ({ workbench: page }) =
|
||||
await expect(page.getByRole("list", { name: /language servers/i })).toBeVisible();
|
||||
});
|
||||
|
||||
const ROOT = "/dev/acme-api";
|
||||
const SERVICE = `${ROOT}/src/main/kotlin/com/acme/orders/service/OrderService.kt`;
|
||||
|
||||
test("Search Everywhere finds a file and opens it", async ({ workbench: page }) => {
|
||||
// TODO(search): plain-browser mode has no in-memory `findFiles`, so the Files
|
||||
// source returns nothing. Un-fixme once search/everywhere gets browser deps
|
||||
// (e.g. backed by the project-tree sample file system).
|
||||
test.fixme(true, "Search Everywhere has no in-memory file source in browser mode");
|
||||
// The project's file index is stubbed at the IPC layer.
|
||||
await stubDesktop(page);
|
||||
await page.evaluate((path) => {
|
||||
window.__ipc.handlers.find_files = (args) =>
|
||||
String(args.query).startsWith("OrderSer")
|
||||
? [{ path, relPath: path.slice("/dev/acme-api/".length), score: 100, indices: [] }]
|
||||
: [];
|
||||
}, SERVICE);
|
||||
await doubleShift(page);
|
||||
const dialog = page.getByRole("dialog", { name: "Search Everywhere" });
|
||||
await dialog.getByRole("combobox").fill("OrderService");
|
||||
await expect(dialog.getByRole("option", { name: /OrderService\.kt/ })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.getByRole("tab", { name: "OrderService.kt" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "OrderService.kt" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("textbox", { name: `Editor for ${SERVICE}` })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Find in Files streams matches by file and opens the chosen one at its line", async ({ workbench: page }) => {
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(
|
||||
({ root, service }) => {
|
||||
const match = (line: number, text: string, word: string) => ({
|
||||
line,
|
||||
text,
|
||||
ranges: [[text.indexOf(word), text.indexOf(word) + word.length]],
|
||||
textOffset: 0,
|
||||
});
|
||||
window.__ipc.handlers.search_validate = () => null;
|
||||
window.__ipc.handlers.search_start = (args) => {
|
||||
const send = (event: unknown) => window.__ipc.send(args.onEvent, event);
|
||||
setTimeout(() => {
|
||||
send({
|
||||
type: "file",
|
||||
file: {
|
||||
path: `${root}/build.gradle.kts`,
|
||||
relPath: "build.gradle.kts",
|
||||
matches: [
|
||||
match(7, "class OrderController(private val service: OrderService) {", "OrderService"),
|
||||
match(3, "import com.acme.orders.service.OrderService", "OrderService"),
|
||||
],
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: "file",
|
||||
file: {
|
||||
path: service,
|
||||
relPath: service.slice(root.length + 1),
|
||||
matches: [match(0, "// OrderService", "OrderService")],
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: "done",
|
||||
summary: { files: 2, matches: 3, limitHit: false, cancelled: false, elapsedMs: 4 },
|
||||
});
|
||||
}, 20);
|
||||
return null;
|
||||
};
|
||||
},
|
||||
{ root: ROOT, service: SERVICE },
|
||||
);
|
||||
await page.keyboard.press("Meta+Shift+f");
|
||||
const dialog = page.getByRole("dialog", { name: "Find in Files" });
|
||||
await dialog.getByRole("textbox", { name: "Text to find" }).fill("OrderService");
|
||||
const matches = dialog.getByRole("listbox", { name: "Matches" });
|
||||
await expect(matches.getByRole("option")).toHaveCount(3);
|
||||
await expect
|
||||
.poll(async () => (await ipcCalls(page, "search_start")).at(-1)?.query)
|
||||
.toMatchObject({
|
||||
pattern: "OrderService",
|
||||
});
|
||||
await matches.getByRole("option", { name: /import com\.acme\.orders\.service\.OrderService/ }).dblclick();
|
||||
await expect(page.getByRole("tab", { name: "build.gradle.kts" })).toHaveAttribute("aria-selected", "true");
|
||||
const caretLine = () =>
|
||||
page.evaluate((label) => {
|
||||
const content = document.querySelector(`.cm-content[aria-label="${label}"]`) as unknown as {
|
||||
cmTile: {
|
||||
view: { state: { doc: { lineAt(pos: number): { text: string } }; selection: { main: { head: number } } } };
|
||||
};
|
||||
} | null;
|
||||
if (!content) return null;
|
||||
const { state } = content.cmTile.view;
|
||||
return state.doc.lineAt(state.selection.main.head).text;
|
||||
}, `Editor for ${ROOT}/build.gradle.kts`);
|
||||
await expect.poll(caretLine).toBe("import com.acme.orders.service.OrderService");
|
||||
});
|
||||
|
||||
+45
-1
@@ -1,4 +1,4 @@
|
||||
import { expect, test, visibleToolWindows } from "./fixtures";
|
||||
import { expect, ipcCalls, stubDesktop, test, visibleToolWindows } from "./fixtures";
|
||||
|
||||
test("⌘1 focuses, hides and re-shows the Project tool window", async ({ workbench: page }) => {
|
||||
const project = page.getByRole("region", { name: "Project" });
|
||||
@@ -80,3 +80,47 @@ test("⌘⇧F12 hides all tool windows and restores them", async ({ workbench: p
|
||||
await expect(page.getByRole("region", { name: "Project" })).toBeVisible();
|
||||
await expect(page.getByRole("region", { name: "Problems" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the Terminal sends typed commands to the shell and shows its output", async ({ workbench: page }) => {
|
||||
// The shell is stubbed at the IPC layer: it echoes input and answers `echo`.
|
||||
await stubDesktop(page);
|
||||
await page.evaluate(() => {
|
||||
let output: unknown = null;
|
||||
let line = "";
|
||||
const out = (text: string) => window.__ipc.send(output, text);
|
||||
const h = window.__ipc.handlers;
|
||||
h.term_default_shell = () => "/bin/zsh";
|
||||
h.term_available_shells = () => ["/bin/zsh"];
|
||||
h.term_spawn = (args) => {
|
||||
output = args.onOutput;
|
||||
setTimeout(() => out("acme-api % "), 20);
|
||||
return { id: 5, title: "zsh", pid: 99 };
|
||||
};
|
||||
h.term_resize = () => null;
|
||||
h.term_is_busy = () => false;
|
||||
h.term_write = (args) => {
|
||||
for (const ch of String(args.data)) {
|
||||
if (ch === "\r") {
|
||||
const said = /^echo (.*)$/.exec(line);
|
||||
out(`\r\n${said ? `${said[1]}\r\n` : ""}acme-api % `);
|
||||
line = "";
|
||||
} else {
|
||||
line += ch;
|
||||
out(ch);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
await page.keyboard.press("Alt+F12");
|
||||
const terminal = page.getByRole("region", { name: "Terminal", exact: true });
|
||||
const rows = terminal.locator(".xterm-rows");
|
||||
await expect(rows).toContainText("acme-api %");
|
||||
await terminal.locator(".xterm-screen").click();
|
||||
await expect(terminal.getByRole("textbox", { name: "Terminal input" })).toBeFocused();
|
||||
await page.keyboard.type("echo hello from e2e");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(rows.locator("div", { hasText: /^hello from e2e\s*$/ })).toHaveCount(1);
|
||||
const written = (await ipcCalls(page, "term_write")).map((a) => a.data).join("");
|
||||
expect(written).toBe("echo hello from e2e\r");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user