Files
openidea/e2e/hierarchyRefactor.spec.ts
Park Jong Hyun ed145e3046 feat(refactor): Java Pull Members Up / Push Members Down (#1183)
Refactor | Pull Members Up… and Push Members Down… for Java, from the Refactor menu, the editor context menu and Refactor This. A dialog picks the destination and the members (Make abstract / Keep abstract); problems are confirmed before anything changes; the edit is applied as one change or previewed first.

JDT LS offers neither as a code action, so the app-owned JDT LS bundle (plugins/java-usages 1.3.0) adds two delegate commands that run JDT's PullUpRefactoringProcessor / PushDownRefactoringProcessor and return the problems and the edit without applying it, exposed as lsp_hierarchy_members / lsp_hierarchy_refactor (also over the remote host dispatcher). The bundled jar was rebuilt from source with JDT LS 1.63.0 and matches the recorded SHA-256.

Co-authored-by: fhrmrk <81476595+fhrmrk@users.noreply.github.com>
2026-11-04 14:55:42 +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 plugins/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);
});