diff --git a/e2e/codeInsight.spec.ts b/e2e/codeInsight.spec.ts new file mode 100644 index 00000000..ed4e4c7f --- /dev/null +++ b/e2e/codeInsight.spec.ts @@ -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'" } }); +}); diff --git a/e2e/commit.spec.ts b/e2e/commit.spec.ts new file mode 100644 index 00000000..6d0c1852 --- /dev/null +++ b/e2e/commit.spec.ts @@ -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(); +}); diff --git a/e2e/debug.spec.ts b/e2e/debug.spec.ts new file mode 100644 index 00000000..1af77663 --- /dev/null +++ b/e2e/debug.spec.ts @@ -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) => 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_`). + 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(); +}); diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4f8163df..4f609c9a 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -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 }).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); +}); diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 05369faf..71cda429 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -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) => unknown>; + calls: { cmd: string; args: Record }[]; + /** 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): 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 void>(); + const channelIndex = new Map(); + 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 = {}) { + 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[]> { + 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 { return page diff --git a/e2e/openProject.spec.ts b/e2e/openProject.spec.ts new file mode 100644 index 00000000..12445bd1 --- /dev/null +++ b/e2e/openProject.spec.ts @@ -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(); +}); diff --git a/e2e/run.spec.ts b/e2e/run.spec.ts new file mode 100644 index 00000000..75596434 --- /dev/null +++ b/e2e/run.spec.ts @@ -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$/); +}); diff --git a/e2e/search.spec.ts b/e2e/search.spec.ts index cdf4fefc..e27aedc9 100644 --- a/e2e/search.spec.ts +++ b/e2e/search.spec.ts @@ -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"); }); diff --git a/e2e/toolwindows.spec.ts b/e2e/toolwindows.spec.ts index e8eb67d9..2c1beaf8 100644 --- a/e2e/toolwindows.spec.ts +++ b/e2e/toolwindows.spec.ts @@ -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"); +});