Files
openidea/e2e/hierarchyRefactor.spec.ts
T
l0ng-ai 014f51ae54 chore: move the JDT LS components out of plugins/ (#1292)
java-usages, kotlin-bridge and the patched Lombok agent are JDT LS bundles and a Java agent embedded into the app, not OpenIDEA plugins. Move them to java/ so plugins/ holds only plugins, and update the paths that embed or mention them.

Also fix the stale build path in the hello fixture and the contributor guide's layout (there is no templates/ directory; starter templates live in the openidea-plugin CLI).
2026-11-04 22:11:38 +08:00

173 lines
8.1 KiB
TypeScript

/**
* Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the
* Java side is covered by java/java-usages/tests/hierarchy.py against a real JDT LS.
*/
import type { Page } from "@playwright/test";
import { expect, openFromTree, test } from "./fixtures";
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
/** Opens the dialog as the action does after the server answered, for the editor's view. */
async function openDialog(page: Page, kind: "pullUp" | "pushDown") {
await page.evaluate(async (kind) => {
const module = "/src/editor/refactor/hierarchy.ts";
const { useHierarchyDialog } = await import(/* @vite-ignore */ module);
const view = (document.querySelector(".cm-content") as unknown as { cmTile: { view: { state: { doc: unknown } } } })
.cmTile.view;
const pullUp = kind === "pullUp";
useHierarchyDialog.setState({
request: {
seq: 1,
view,
kind,
doc: 1,
pos: { line: 0, col: 0 },
text: view.state.doc,
info: {
className: pullUp ? "Dog" : "Animal",
targets: pullUp
? [
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
]
: [
{ id: "dog", name: "Dog", qualifiedName: "zoo.Dog", interface: false },
{ id: "cat", name: "Cat", qualifiedName: "zoo.Cat", interface: false },
],
members: pullUp
? [
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
{ id: "wag", label: "wag() : int", kind: "method", selected: false, abstractable: true },
]
: [
{ id: "name", label: "name : String", kind: "field", selected: false, abstractable: false },
{ id: "describe", label: "describe() : String", kind: "method", selected: true, abstractable: true },
],
},
},
});
}, kind);
}
/** Stubs the server's check and the edit application; returns what reached them. */
async function stubServer(page: Page, problems: { severity: string; message: string }[]) {
await page.evaluate(async (problems) => {
const module = "/src/platform/ipc.ts";
const { commands } = await import(/* @vite-ignore */ module);
// Only the stubbed commands answer; window lookups by other features keep working.
Object.defineProperty(window, "__TAURI_INTERNALS__", {
configurable: true,
value: {
transformCallback: () => 1,
invoke: async () => null,
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
},
});
const seen = ((window as unknown as { hierarchySeen: unknown[] }).hierarchySeen = [] as unknown[]);
commands.lspHierarchyRefactor = async (...args: unknown[]) => {
seen.push({ refactor: args.slice(3) });
return {
status: "ok",
data: {
problems,
edit: {
files: [
{
kind: "edit",
path: "/dev/acme-api/README.md",
edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "" }],
},
],
},
},
};
};
commands.applyWorkspaceEdit = async (edit: unknown) => {
seen.push({ apply: edit });
return { status: "ok", data: null };
};
}, problems);
}
const seen = (page: Page) => page.evaluate(() => (window as unknown as { hierarchySeen: unknown[] }).hierarchySeen);
test("the editor's Refactor menu offers Pull Members Up and Push Members Down", async ({ workbench: page }) => {
const editor = await openFromTree(page, "README.md");
await editor.locator(".cm-content").click({ button: "right" });
await page.getByRole("menuitem", { name: "Refactor", exact: true }).hover();
await expect(page.getByRole("menuitem", { name: /^Pull Members Up…/ })).toBeVisible();
await expect(page.getByRole("menuitem", { name: /^Push Members Down…/ })).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/01-refactor-menu.png` });
await page.keyboard.press("Escape");
});
test("Pull Members Up: members, interface destination, conflicts, then the edit", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await openDialog(page, "pullUp");
const dialog = page.getByRole("dialog", { name: "Pull Members Up" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("Pull up members of Dog to:")).toBeVisible();
const member = (label: string) => dialog.getByRole("row").filter({ hasText: label });
// The member at the caret starts checked; fields can't be made abstract.
await expect(member("bark() : String").getByRole("checkbox").first()).toBeChecked();
await expect(member("wag() : int").getByRole("checkbox").first()).not.toBeChecked();
await member("tail : int").getByRole("checkbox").first().check();
await expect(member("tail : int").getByRole("checkbox", { name: "Make abstract" })).toBeDisabled();
if (captureDir) await page.screenshot({ path: `${captureDir}/02-pull-up-dialog.png` });
// To an interface, methods are declared abstract there.
await dialog.getByRole("button", { name: "Destination class" }).click();
await page.getByRole("option", { name: /zoo\.Speaker/ }).click();
const abstractBark = member("bark() : String").getByRole("checkbox", { name: "Make abstract" });
await expect(abstractBark).toBeChecked();
await expect(abstractBark).toBeDisabled();
await expect(dialog.getByText("Methods pulled up to an interface are declared abstract there.")).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/03-pull-up-to-interface.png` });
await stubServer(page, [
{ severity: "error", message: "Field 'zoo.Dog.tail' is not accessible from type 'zoo.Speaker'" },
]);
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
const problems = page.getByRole("alertdialog").or(page.getByRole("dialog", { name: "Problems Detected" }));
await expect(problems.getByText(/is not accessible from type/)).toBeVisible();
if (captureDir) await page.screenshot({ path: `${captureDir}/04-problems-detected.png` });
// Cancel: nothing applied, the dialog stays.
await problems.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(dialog).toBeVisible();
expect(await seen(page)).toEqual([
{
refactor: [
"pullUp",
"speaker",
[
{ id: "tail", makeAbstract: false },
{ id: "bark", makeAbstract: true },
],
],
},
]);
// Continue: the edit is applied and the dialog closes.
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
await problems.getByRole("button", { name: "Continue", exact: true }).click();
await expect(dialog).toBeHidden();
await expect.poll(async () => (await seen(page)).some((s) => "apply" in (s as object))).toBe(true);
});
test("Push Members Down keeps an abstract declaration when asked", async ({ workbench: page }) => {
await openFromTree(page, "README.md");
await openDialog(page, "pushDown");
const dialog = page.getByRole("dialog", { name: "Push Members Down" });
await expect(dialog.getByText("Push members of Animal down to:")).toBeVisible();
await expect(dialog.getByText("Dog, Cat")).toBeVisible();
const describe = dialog.getByRole("row").filter({ hasText: "describe() : String" });
await describe.getByRole("checkbox", { name: "Keep abstract" }).check();
if (captureDir) await page.screenshot({ path: `${captureDir}/05-push-down-dialog.png` });
await stubServer(page, []);
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
await expect(dialog).toBeHidden();
const calls = await seen(page);
expect(calls[0]).toEqual({ refactor: ["pushDown", null, [{ id: "describe", makeAbstract: true }]] });
expect(calls.some((s) => "apply" in (s as object))).toBe(true);
});