Files
openidea/src/features/run/applicationUpdate.test.ts
T
l0ng-aiandClaude Opus 5.5 f665beb17e fix(run): rerun with the configuration as edited, and drop deleted ones from Services (#1579)
Rerunning a run tab launched the configuration snapshot taken when the tab first ran, so edits from Edit Configurations were ignored until the tab was closed. Every rerun path (Run tool window, Services, the exit notification, Debug from a run tab, Rerun Failed Tests / selected tests, Update Application's restart) now uses the saved configuration with the same id, falling back to the snapshot when it was deleted or never saved. Services also drops a configuration deleted after it ran once its run has ended; ad-hoc runs (build tasks, external tools) still show.

Fixes #1497

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 23:12:13 +08:00

215 lines
9.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RunSession } from "./store";
const mocks = vi.hoisted(() => ({
action: vi.fn(async () => true),
build: vi.fn(async () => {}),
sync: vi.fn(async () => false),
update: vi.fn(async () => ({ status: "ok", data: null })),
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
wait: vi.fn(async () => {}),
debug: true,
notify: vi.fn(),
menu: vi.fn(),
springBuild: vi.fn(async () => ({ status: "ok", data: { id: "build" } })),
publish: vi.fn(async () => ({ status: "ok", data: null })),
resolve: vi.fn(async () => ({
status: "ok",
data: { build: "succeeded", classPaths: ["/p/app/bin/main"], pathListSeparator: ":" },
})),
runBuild: vi.fn(async () => {}),
state: { configs: [] as RunSession["config"][], sessions: [] as RunSession[], activeSession: null as string | null },
}));
vi.mock("@/platform/actions", () => ({ executeAction: mocks.action, getAction: () => ({}) }));
vi.mock("@/platform/ipc", () => ({
call: async (p: Promise<{ data: unknown }>) => (await p).data,
isTauri: () => false,
nextRequestId: () => 1,
commands: {
runTomcatUpdate: mocks.update,
runApplicationBuild: mocks.springBuild,
runApplicationPublish: mocks.publish,
javaResolveLaunch: mocks.resolve,
},
}));
vi.mock("@/workbench/contributions", () => ({
maybeEditorService: () => ({ saveAll: async () => {} }),
notifications: () => ({ notify: mocks.notify }),
useDebugRunSessions: {
getState: () => ({
sessions: mocks.debug ? [{ configId: "tomcat:one" }, { configId: "boot" }] : [],
serviceSessions: [],
}),
},
}));
vi.mock("@/ui", () => ({ showContextMenu: mocks.menu }));
vi.mock("@/platform/javaLaunch", async (original) => ({
...(await original<typeof import("@/platform/javaLaunch")>()),
resolveJavaModule: async () => "app",
}));
vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild }));
vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync }));
vi.mock("./store", () => ({
rerunConfig: (s: Pick<RunSession, "config">) => mocks.state.configs.find((c) => c.id === s.config.id) ?? s.config,
startRun: mocks.start,
stopSession: mocks.stop,
waitForExit: mocks.wait,
useRun: { setState: vi.fn(), getState: () => mocks.state },
}));
import {
updateApplication as updateTomcat,
showApplicationUpdate,
updateOnDeactivation,
updatableSessions,
useApplicationUpdates,
} from "./applicationUpdate";
const session = { id: "run1", config: { id: "tomcat:one", kind: "tomcat" } } as RunSession;
beforeEach(() => {
vi.clearAllMocks();
mocks.debug = true;
mocks.action.mockResolvedValue(true);
mocks.sync.mockResolvedValue(false);
mocks.state.configs = [];
mocks.state.sessions = [];
mocks.state.activeSession = null;
useApplicationUpdates.setState({ ids: new Set() });
});
describe("Tomcat application updates", () => {
it("builds and copies the deployment before requesting scoped HotSwap", async () => {
await updateTomcat(session, "classes");
expect(mocks.build).toHaveBeenCalledWith(session.config);
expect(mocks.update).toHaveBeenCalledWith(session.config, "classes");
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "tomcat:one" });
expect(mocks.build.mock.invocationCallOrder[0]).toBeLessThan(mocks.update.mock.invocationCallOrder[0]!);
expect(mocks.update.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
});
it("copies the compiled outputs into the deployment without rebuilding the WAR when it can", async () => {
mocks.sync.mockResolvedValue(true);
await updateTomcat(session, "classes");
expect(mocks.sync).toHaveBeenCalledWith(session.config, false);
expect(mocks.build).not.toHaveBeenCalled();
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "tomcat:one" });
await updateTomcat(session, "redeploy");
expect(mocks.build).toHaveBeenCalledWith(session.config);
expect(mocks.update).toHaveBeenCalledWith(session.config, "redeploy");
});
it("does not replace classes when updating only resources", async () => {
await updateTomcat(session, "resources");
expect(mocks.update).toHaveBeenCalledWith(session.config, "resources");
expect(mocks.action).not.toHaveBeenCalled();
});
it("waits for clean shutdown before restarting a debug server", async () => {
await updateTomcat(session, "restart");
expect(mocks.stop).toHaveBeenCalledWith("run1");
expect(mocks.wait).toHaveBeenCalledWith(["run1"]);
expect(mocks.wait.mock.invocationCallOrder[0]).toBeLessThan(mocks.action.mock.invocationCallOrder[0]!);
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", session.config);
});
it("restarts with the configuration as edited since the server started", async () => {
const edited = { ...session.config, name: "Tomcat (edited)" };
mocks.state.configs = [edited];
try {
await updateTomcat(session, "restart");
expect(mocks.action).toHaveBeenCalledWith("run.debugConfig", edited);
} finally {
mocks.state.configs = [];
}
});
it("redeploys when the debugger cannot perform HotSwap", async () => {
mocks.action.mockResolvedValue(false);
await updateTomcat(session, "classes");
expect(mocks.update).toHaveBeenLastCalledWith(session.config, "redeploy");
});
});
const boot = {
id: "boot1",
status: "running",
config: { id: "boot", kind: "spring-boot", args: ["-cp", "${classpath}", "a.Main"] },
} as RunSession;
describe("Spring Boot application updates", () => {
it("processes resources and publishes them to the resolved runtime output", async () => {
await updateTomcat(boot, "resources");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, false, 1);
expect(mocks.runBuild).toHaveBeenCalledWith({ id: "build" });
expect(mocks.publish).toHaveBeenCalledWith(boot.config, ["/p/app/bin/main"], "a.Main");
expect(mocks.update).not.toHaveBeenCalled();
expect(mocks.action).not.toHaveBeenCalled();
});
it("compiles and invokes the existing scoped HotSwap action in debug", async () => {
await updateTomcat(boot, "classes");
expect(mocks.resolve).toHaveBeenCalledWith("app", false, true, 1);
expect(mocks.action).toHaveBeenCalledWith("debug.reloadChangedClasses", { configId: "boot" });
});
it("explains compiled classes require restart without a debugger", async () => {
mocks.debug = false;
await updateTomcat(boot, "classes");
expect(mocks.action).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.stringContaining("Restart the application") }),
);
});
it("explains the restart requirement if the attached debugger cannot dispatch HotSwap", async () => {
mocks.action.mockResolvedValueOnce(false);
await updateTomcat(boot, "classes");
expect(mocks.notify).toHaveBeenCalledWith(
expect.objectContaining({ body: expect.stringContaining("Restart the application") }),
);
});
it("never publishes resources after a failed build", async () => {
mocks.runBuild.mockRejectedValueOnce(new Error("failed"));
await updateTomcat(boot, "resources");
expect(mocks.publish).not.toHaveBeenCalled();
expect(mocks.notify).toHaveBeenCalledWith(expect.objectContaining({ level: "error" }));
});
it("does not overlap updates of a session", async () => {
let finish!: () => void;
mocks.runBuild.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finish = resolve;
}),
);
const first = updateTomcat(boot, "resources");
await vi.waitFor(() => expect(mocks.runBuild).toHaveBeenCalled());
await updateTomcat(boot, "resources");
expect(mocks.runBuild).toHaveBeenCalledTimes(1);
finish();
await first;
expect(useApplicationUpdates.getState().ids.size).toBe(0);
});
it("retains the chooser by default and never offers redeploy", () => {
mocks.state.sessions = [boot];
showApplicationUpdate();
const items = mocks.menu.mock.calls[0]![1];
expect(items.map((i: { label: string }) => i.label)).toEqual([
"Update Application",
"Update resources",
"Update classes and resources",
"Restart",
]);
});
it("uses current saved policies and defaults deactivation to doing nothing", async () => {
mocks.state.sessions = [boot];
updateOnDeactivation();
expect(mocks.resolve).not.toHaveBeenCalled();
mocks.state.configs = [{ ...boot.config, params: { onUpdate: "resources", onDeactivation: "resources" } }];
showApplicationUpdate(boot.id);
expect(mocks.menu).not.toHaveBeenCalled();
await vi.waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(useApplicationUpdates.getState().ids.size).toBe(0));
updateOnDeactivation();
await vi.waitFor(() => expect(mocks.publish).toHaveBeenCalledTimes(2));
});
it("enables only running, local application sessions", () => {
mocks.state.sessions = [
boot,
{ ...boot, id: "done", status: "finished" },
{ ...boot, id: "goal", config: { ...boot.config, kind: "maven" } },
{ ...boot, id: "remote", config: { ...boot.config, target: "ssh" } },
];
expect(updatableSessions().map((s) => s.id)).toEqual([boot.id]);
});
});