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:
l0ng-ai
2026-11-04 22:52:48 +08:00
committed by GitHub
parent b26318a32c
commit aa33d2eeab
9 changed files with 823 additions and 7 deletions
+219
View File
@@ -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'" } });
});
+75
View File
@@ -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();
});
+115
View File
@@ -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();
});
+42
View File
@@ -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);
});
+91
View File
@@ -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
+71
View File
@@ -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();
});
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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");
});