diff --git a/src/features/run/applicationUpdate.test.ts b/src/features/run/applicationUpdate.test.ts index 58616bc9..35ded3f8 100644 --- a/src/features/run/applicationUpdate.test.ts +++ b/src/features/run/applicationUpdate.test.ts @@ -50,6 +50,7 @@ vi.mock("@/platform/javaLaunch", async (original) => ({ vi.mock("./buildUpdate", () => ({ runUpdateBuild: mocks.runBuild })); vi.mock("./tomcat", () => ({ buildTomcatArtifact: mocks.build, syncTomcatOutputs: mocks.sync })); vi.mock("./store", () => ({ + rerunConfig: (s: Pick) => mocks.state.configs.find((c) => c.id === s.config.id) ?? s.config, startRun: mocks.start, stopSession: mocks.stop, waitForExit: mocks.wait, @@ -105,6 +106,16 @@ describe("Tomcat application updates", () => { 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"); diff --git a/src/features/run/applicationUpdate.ts b/src/features/run/applicationUpdate.ts index 280b9720..02c52a01 100644 --- a/src/features/run/applicationUpdate.ts +++ b/src/features/run/applicationUpdate.ts @@ -13,7 +13,7 @@ import { import { showContextMenu } from "@/ui"; import { buildTomcatArtifact, syncTomcatOutputs } from "./tomcat"; import { runUpdateBuild } from "./buildUpdate"; -import { startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; +import { rerunConfig, startRun, stopSession, useRun, waitForExit, type RunSession } from "./store"; import { isUpdatable, updateAllowed, type UpdateMode } from "./updatePolicies"; export type UpdateSession = Pick & { debug?: DebugServiceSession }; @@ -72,14 +72,15 @@ export async function updateApplication(session: UpdateSession, mode: string): P const debugging = !!session.debug || useDebugRunSessions.getState().sessions.some((s) => s.configId === session.config.id); if (mode === "restart") { + const config = rerunConfig(session); if (session.debug) { await session.debug.stop(); - await executeAction("run.debugConfig", session.config); + await executeAction("run.debugConfig", config); } else if (debugging) { await stopSession(session.id); await waitForExit([session.id]); - await executeAction("run.debugConfig", session.config); - } else await startRun(session.config, session.id); + await executeAction("run.debugConfig", config); + } else await startRun(config, session.id); return; } await maybeEditorService()?.saveAll(); diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 1a02731b..1d515d92 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -15,7 +15,7 @@ import { } from "./configs"; import { contextConfig, fileRole } from "./context"; import { envFromRows } from "./EnvDialog"; -import { defaultSelection } from "./store"; +import { configRemoved, defaultSelection, rerunConfig, useRun, type RunSession } from "./store"; const cfg = (over: Partial & { id: string }): RunConfig => ({ name: over.id, @@ -344,3 +344,38 @@ describe("context configuration", () => { expect(contextConfig("tools/gen.py", [])).toMatchObject({ command: "python3", args: ["tools/gen.py"] }); }); }); + +describe("rerunning a session after its configuration changed (#1497)", () => { + const session = (config: RunConfig, baseConfig?: RunConfig): RunSession => ({ + id: "s", + config, + baseConfig, + runId: null, + status: "finished", + exitCode: 0, + startedAt: 1, + endedAt: 2, + stdinClosed: true, + }); + + it("launches the configuration as saved now, or the one it ran with once deleted", () => { + const ran = cfg({ id: "app", command: "python", args: ["init.py"] }); + const edited = { ...ran, args: ["main.py"] }; + useRun.setState({ configs: [edited] }); + expect(rerunConfig(session(ran))).toBe(edited); + expect(configRemoved("app")).toBe(false); + useRun.setState({ configs: [] }); + expect(rerunConfig(session(ran))).toBe(ran); + expect(configRemoved("app")).toBe(true); + // A run that was never a listed configuration isn't "removed". + expect(configRemoved("build:task")).toBe(false); + }); + + it("keeps the tests of a derived run (Rerun Failed Tests)", () => { + const base = cfg({ id: "tests", isTest: true }); + const derived = { ...base, args: ["-k", "failing"] }; + useRun.setState({ configs: [base] }); + expect(rerunConfig(session(derived, base))).toBe(derived); + useRun.setState({ configs: [] }); + }); +}); diff --git a/src/features/run/services.test.ts b/src/features/run/services.test.ts index 2072ecf8..8d4c3f61 100644 --- a/src/features/run/services.test.ts +++ b/src/features/run/services.test.ts @@ -256,6 +256,17 @@ describe("configurations that ran", () => { expect(items).toHaveLength(1); expect(items[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]); }); + + it("drops a deleted configuration once its run has ended (#1497)", () => { + const removed = (id: string) => id === "dev"; + const ended = runServiceItems([], [session("s1", dev, "finished", 1)], [], ["dev"], {}, [], removed); + expect(ended).toEqual([]); + const live = runServiceItems([], [session("s1", dev, "running", 1)], [], ["dev"], {}, [], removed); + expect(live[0]!.children!.map((c) => [c.label, c.detail])).toEqual([["dev", "Running"]]); + // Never a saved configuration (a build task, an external tool): its finished run stays. + const adHoc = runServiceItems([], [session("s2", scratch, "finished", 1)], [], [], {}, [], removed); + expect(adHoc[0]!.children!.map((c) => c.key)).toEqual(["run:config:context:file"]); + }); }); describe("embedded web server port", () => { diff --git a/src/features/run/services.tsx b/src/features/run/services.tsx index 41af0d17..7f16de10 100644 --- a/src/features/run/services.tsx +++ b/src/features/run/services.tsx @@ -29,7 +29,16 @@ import { commandLine, configIcon, KIND_OPTIONS, kindLabel } from "./configs"; import { SessionPanel } from "./RunView"; import { showApplicationUpdate } from "./applicationUpdate"; import { isUpdatable } from "./updatePolicies"; -import { loadConfigs, persistTemporary, startRun, stopSession, useRun, type RunSession } from "./store"; +import { + configRemoved, + loadConfigs, + persistTemporary, + rerunConfig, + startRun, + stopSession, + useRun, + type RunSession, +} from "./store"; import { currentServiceSession, currentServiceSessions, @@ -230,6 +239,7 @@ function executionDetail(s: ServiceSession | undefined): string | undefined { /** * Groups by configuration type: added types list all their configurations, other * types only those that ran. A default type (Spring Boot) without configurations is hidden. + * A configuration deleted since it ran (`removed`) stays only while its run is live. */ export function runServiceItems( configs: readonly RunConfig[], @@ -238,11 +248,13 @@ export function runServiceItems( ran: readonly string[] = [], order: ServiceOrder = {}, debug: readonly DebugServiceSession[] = [], + removed: (id: string) => boolean = () => false, ): ServiceTreeItem[] { const executions = serviceSessions(sessions, debug); const latest = currentServiceSessions(executions); const kinds = [...added]; - for (const s of executions) if (!kinds.includes(s.config.kind)) kinds.push(s.config.kind); + const shown = (s: ServiceSession) => liveServiceSession(s) || !removed(s.config.id); + for (const s of executions) if (shown(s) && !kinds.includes(s.config.kind)) kinds.push(s.config.kind); for (const id of ran) { const kind = configs.find((c) => c.id === id)?.kind; if (kind && !kinds.includes(kind)) kinds.push(kind); @@ -252,7 +264,7 @@ export function runServiceItems( ? configs.filter((c) => c.kind === kind) : ran.map((id) => configs.find((c) => c.id === id)).filter((c): c is RunConfig => !!c && c.kind === kind); for (const s of latest.values()) - if (s.config.kind === kind && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); + if (s.config.kind === kind && shown(s) && !entries.some((c) => c.id === s.config.id)) entries.push(s.config); if (!entries.length && DEFAULT_TYPES.includes(kind)) return []; const children = orderServices(entries, order[kind]).map((c): ServiceTreeItem => { const s = latest.get(c.id); @@ -298,7 +310,15 @@ function useRoots(): ServiceTreeItem[] { }, [root, typesRoot, ranRoot]); return useMemo( () => - runServiceItems(configs, sessions, typesRoot === root ? types : [], ranRoot === root ? ran : [], order, debug), + runServiceItems( + configs, + sessions, + typesRoot === root ? types : [], + ranRoot === root ? ran : [], + order, + debug, + configRemoved, + ), [configs, sessions, types, typesRoot, ran, ranRoot, root, order, debug], ); } @@ -335,7 +355,7 @@ function run(r: Resolved) { if (debug) debug.rerun?.(); else if (r.session?.run) { const s = r.session.run; - void startRun(s.config, s.id, s.baseConfig, { show: false }); + void startRun(rerunConfig(s), s.id, s.baseConfig, { show: false }); } else if (r.config) void startRun(r.config, undefined, undefined, { show: false }); } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 5ed72249..e2f75430 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -1010,7 +1010,7 @@ function notifyExit(sessionId: string, code: number | null) { toolWindowService().show("run", true); }, }, - { label: "Rerun", run: () => void startRun(session.config, sessionId) }, + { label: "Rerun", run: () => void startRun(rerunConfig(session), sessionId, session.baseConfig) }, ], }); } @@ -1070,9 +1070,35 @@ export function waitForExit(sessionIds: readonly string[], timeoutMs = 10_000): }); } +/** + * What rerunning a session launches: its configuration as saved now (edited since, as IDEA), or + * the one it ran with when that was deleted or never saved. A derived run (Rerun Failed Tests) keeps its tests. + */ +export function rerunConfig(session: Pick): RunConfig { + if (session.baseConfig) return session.config; + return useRun.getState().configs.find((c) => c.id === session.config.id) ?? session.config; +} + +/** The session's original configuration, as saved now (see {@link rerunConfig}). */ +function baseConfigOf(session: RunSession): RunConfig { + const base = session.baseConfig ?? session.config; + return useRun.getState().configs.find((c) => c.id === base.id) ?? base; +} + +/** Ids of every configuration listed in this window, so a run of one deleted since can be told apart. */ +const listedConfigIds = new Set(); +useRun.subscribe((s, prev) => { + if (s.configs !== prev.configs) for (const c of s.configs) listedConfigIds.add(c.id); +}); + +/** The configuration was saved or temporary in this window and has been removed since. */ +export function configRemoved(id: string): boolean { + return listedConfigIds.has(id) && !useRun.getState().configs.some((c) => c.id === id); +} + export function rerunSession(sessionId: string) { const session = getSession(sessionId); - if (session) void startRun(session.config, sessionId, session.baseConfig); + if (session) void startRun(rerunConfig(session), sessionId, session.baseConfig && baseConfigOf(session)); } /** @@ -1082,7 +1108,7 @@ export function rerunSession(sessionId: string) { */ export function debugSession(sessionId: string) { const session = getSession(sessionId); - if (session) void executeAction("run.debugConfig", session.baseConfig ?? session.config); + if (session) void executeAction("run.debugConfig", baseConfigOf(session)); } /** Paths of the failed tests of a session's last run. */ @@ -1095,7 +1121,7 @@ export function failedTests(sessionId: string): string[][] { export function rerunFailed(sessionId: string): boolean { const session = getSession(sessionId); if (!session || session.status === "running" || session.status === "starting") return false; - const base = session.baseConfig ?? session.config; + const base = baseConfigOf(session); const derived = rerunFailedConfig(base, failedTests(sessionId)); if (!derived) return false; void startRun(derived, sessionId, base); @@ -1116,7 +1142,7 @@ export async function runSelectedTests( if (!session || !tests) return false; const nodes = nodeIds.map((id) => tests.get(id)).filter((n) => !!n); const paths = leafTestPaths(nodes); - const base = session.baseConfig ?? session.config; + const base = baseConfigOf(session); const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; const derived = testsConfig(base, paths, name); if (!derived) return false;