Files
openidea/e2e/linkedGradle.spec.ts
T
yoo hyeong chan 0dfdbbc323 feat(gradle): link and unlink independent Gradle builds (#1413)
Opening a folder of independent Gradle builds selected only the first build. Link Gradle Projects… (pick a build's settings or build file) and Unlink on a build's root let an ordinary project window manage several independent builds without changing their scripts. The build model holds a list of Gradle builds; each keeps its own wrapper, working directory, environment, task routing and sync cache, keyed by the owning build directory. The links persist in the project's Gradle settings (linkedProjects); unlinking leaves files and saved run configurations alone. Existing includeBuild support is unchanged, and an explicitly linked build takes precedence over a composite that also includes it. Workspace windows keep Link/Unlink disabled.

Co-authored-by: gudcks0305 <84506708+gudcks0305@users.noreply.github.com>
2026-11-05 11:23:39 +08:00

121 lines
5.4 KiB
TypeScript

/** Linked Gradle builds use isolated native stubs; no real builds or files are touched. */
import type { GradleModel, RunConfig } from "../src/platform/ipc";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const build = (dir: string): GradleModel => ({
dir,
command: `/dev/acme-api/${dir}/gradlew`,
env: { JAVA_HOME: `/${dir}/jdk` },
javaVersion: 21,
syncedAt: 1,
stale: false,
syncError: null,
projects: [
{ path: ":", name: dir, dir, buildFile: `${dir}/build.gradle`, tasks: [] },
{
path: ":app",
name: "app",
dir: `${dir}/app`,
buildFile: `${dir}/app/build.gradle`,
tasks: [{ name: "assemble", group: "build", description: "Assembles this project" }],
},
],
});
test("links independent Gradle roots, runs identical task paths in their own builds and unlinks without deletion", async ({
workbench: page,
}, info) => {
await stubDesktop(page);
await page.evaluate(
async ({ first, second }) => {
let linked = [first];
let runId = 0;
const model = () => ({ maven: null, gradle: linked });
const h = window.__ipc.handlers;
h.build_model = model;
h.build_sync = model;
h.run_configs = () => [];
h.run_apply_settings = (args) => args.configs;
h.gradle_link_projects = () => {
if (!linked.some((g) => g.dir === second.dir)) linked = [...linked, second];
return model();
};
h.gradle_unlink_projects = (args) => {
linked = linked.filter((g) => !(args.paths as string[]).includes(g.dir));
return model();
};
h["plugin:dialog|open"] = () => ["/dev/acme-api/second/settings.gradle"];
h.run_start = (args) => {
const config = args.config as RunConfig;
const id = ++runId;
setTimeout(() => window.__ipc.emit({ type: "runExited", run: id, code: 0 }), 50);
return { id, configId: config.id, name: config.name, pid: 4242 + id, running: true, pty: false };
};
const storeModule = "/src/features/build-tools/store.ts";
const { loadModel } = await import(/* @vite-ignore */ storeModule);
await loadModel();
const contributionsModule = "/src/workbench/contributions.ts";
const { toolWindowService } = await import(/* @vite-ignore */ contributionsModule);
toolWindowService().show("gradle", true);
},
{ first: build("first"), second: build("second") },
);
const tree = page.getByRole("tree", { name: "Gradle projects" });
await expect(tree.getByRole("treeitem", { name: "first", exact: true })).toBeVisible();
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath("gradle-one-root.png"), animations: "disabled" });
await page.getByRole("button", { name: "Link Gradle Projects", exact: true }).click();
await expect
.poll(() => ipcCalls(page, "gradle_link_projects"))
.toEqual([{ paths: ["/dev/acme-api/second/settings.gradle"] }]);
await expect(tree.getByRole("treeitem", { name: "second", exact: true })).toBeVisible();
// Reloading the model reads the persisted linkage again.
await page.evaluate(async () => {
const storeModule = "/src/features/build-tools/store.ts";
const { loadModel } = await import(/* @vite-ignore */ storeModule);
await loadModel();
const actionModule = "/src/platform/actions.ts";
const { executeAction } = await import(/* @vite-ignore */ actionModule);
await executeAction("gradle.expandAll");
await executeAction("gradle.toggleOffline");
});
const tasks = tree.getByRole("treeitem", { name: /^assemble\b/ });
await expect(tasks).toHaveCount(2);
await page.screenshot({ path: info.outputPath("gradle-two-roots.png"), animations: "disabled" });
await tasks.nth(0).dblclick();
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(1);
await tasks.nth(1).dblclick();
await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(2);
const runs = (await ipcCalls(page, "run_start")).map((args) => args.config as RunConfig);
expect(runs[0]!.id).not.toBe(runs[1]!.id);
for (const [index, dir] of ["first", "second"].entries()) {
expect(runs[index]).toMatchObject({
command: `/dev/acme-api/${dir}/gradlew`,
cwd: dir,
args: [":app:assemble", "--offline"],
env: { JAVA_HOME: `/${dir}/jdk` },
});
}
for (const dir of ["second", "first"]) {
await tree.getByRole("treeitem", { name: dir, exact: true }).click({ button: "right" });
await page.getByRole("menuitem", { name: "Unlink Gradle Project", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Unlink Gradle project?" });
await expect(dialog).toContainText("Files will remain");
await dialog.getByRole("button", { name: "Unlink", exact: true }).click();
await expect(tree.getByRole("treeitem", { name: dir, exact: true })).toHaveCount(0);
}
await expect
.poll(() => ipcCalls(page, "gradle_unlink_projects"))
.toEqual([{ paths: ["second"] }, { paths: ["first"] }]);
await expect(page.getByText("No Gradle projects", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Link Gradle Projects", exact: true })).toBeVisible();
expect(
await page.evaluate(() => window.__ipc.calls.filter((c) => /^fs_(?:delete|remove|trash)/.test(c.cmd))),
).toEqual([]);
await page.screenshot({ path: info.outputPath("gradle-unlinked.png"), animations: "disabled" });
});