import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { MenuItem } from "@/ui"; import { pickServices } from "./serviceImport"; import type { RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; const ipc = vi.hoisted(() => ({ runConfigs: vi.fn() })); vi.mock("@/platform/ipc", async (importOriginal) => ({ ...(await importOriginal()), isTauri: () => true, commands: ipc, })); import { addType, cleanupRemovedProject, categorizedTypes, runServiceProvider, registerRunServiceActions, DEFAULT_TYPES, readHidden, readRan, readTypes, runServiceItems, sessionDetail, sessionStatus, } from "./services"; import { KIND_OPTIONS, categoryOf } from "./configs"; import { startedPort } from "@/platform/serverPort"; import { registerRunConfigType, useDebugRunSessions, type DebugServiceSession } from "@/workbench/contributions"; import { useRun, type RunSession } from "./store"; const { startRun, stopServiceSessions, openUrl, showContextMenu } = vi.hoisted(() => ({ startRun: vi.fn(), openUrl: vi.fn(), showContextMenu: vi.fn(), stopServiceSessions: vi.fn(), })); vi.mock("./store", async (importOriginal) => ({ ...(await importOriginal()), startRun })); vi.mock("./serviceSessions", async (importOriginal) => ({ ...(await importOriginal()), stopServiceSessions, })); vi.mock("./serviceImport", () => ({ pickServices: vi.fn() })); vi.mock("./navigate", () => ({ openUrl })); vi.mock("@/ui", async (importOriginal) => ({ ...(await importOriginal()), showContextMenu })); let disposeActions: () => void; beforeEach(() => { disposeActions = registerRunServiceActions(); useDebugRunSessions.setState({ serviceSessions: [] }); openUrl.mockReset(); showContextMenu.mockReset(); }); afterEach(() => disposeActions()); const config = (id: string, kind: string): RunConfig => ({ id, name: id, kind, command: kind, args: [], cwd: null, env: {}, detected: true, isTest: false, debugAdapter: null, }); const session = ( id: string, c: RunConfig, status: RunSession["status"], startedAt: number, extra: Partial = {}, ): RunSession => ({ id, config: c, runId: null, status, exitCode: null, startedAt, endedAt: null, stdinClosed: false, ...extra, }); describe("run configurations in Services", () => { const build = config("build", "cargo"); const test = config("test", "cargo"); const dev = config("dev", "npm"); const scratch = config("context:file", "python"); it.each(["spring-boot", "node", "python", "go", "cargo"])( "shows configured %s debug roots without a Run session", (kind) => { const c = config("debug-app", kind); const d: DebugServiceSession = { id: 1, config: c, status: "running", processId: null, startedAt: 2, stop: async () => {}, rerun: null, show: () => {}, }; const build = (status: DebugServiceSession["status"]) => runServiceItems([c], [], [], [], {}, [{ ...d, status }]); expect(build("running")[0]?.children?.[0]).toMatchObject({ key: "run:config:debug-app", detail: "Debugging", icon: "debug", dim: false, }); expect(build("paused")[0]?.children?.[0]?.detail).toBe("Suspended"); expect(build("terminated")[0]?.children?.[0]).toMatchObject({ detail: "Finished", dim: true }); }, ); it("does not reuse an old Run port for a new direct debug launch or double-count an attachment", () => { const c = config("app", "spring-boot"); const r = session("run", c, "finished", 1, { runId: 7, httpPort: 8080 }); const d: DebugServiceSession = { id: 1, config: c, status: "running", processId: null, startedAt: 2, stop: async () => {}, rerun: null, show: () => {}, }; const direct = runServiceItems([c], [r], DEFAULT_TYPES, [], {}, [d]); expect(direct[0]?.detail).toBe("1 running"); expect(direct[0]?.children?.[0]?.detail).toBe("Debugging"); const attached = runServiceItems([c], [{ ...r, status: "running" }], DEFAULT_TYPES, [], {}, [ { ...d, processId: 7 }, ]); expect(attached[0]?.detail).toBe("1 running"); expect(attached[0]?.children).toHaveLength(1); expect(attached[0]?.children?.[0]?.detail).toBe(":8080"); }); it("shows the app port of a direct debug session like for Run", () => { const c = config("app", "spring-boot"); const d: DebugServiceSession = { id: 1, config: c, status: "running", processId: null, startedAt: 2, httpPort: 8080, stop: async () => {}, rerun: null, show: () => {}, }; expect(runServiceItems([c], [], DEFAULT_TYPES, [], {}, [d])[0]?.children?.[0]).toMatchObject({ detail: ":8080", icon: "debug", dim: false, }); // A paused debugger still wins over the announced port. expect( runServiceItems([c], [], DEFAULT_TYPES, [], {}, [{ ...d, status: "paused" }])[0]?.children?.[0]?.detail, ).toBe("Suspended"); }); it("applies display order to detected and saved configurations of every language", () => { const configs = [ config("java-a", "spring-boot"), config("java-b", "spring-boot"), config("node-a", "npm"), { ...config("node-b", "npm"), detected: false }, config("py-a", "python"), config("py-b", "python"), ]; const items = runServiceItems(configs, [], ["spring-boot", "npm", "python"], [], { "spring-boot": ["java-b", "java-a"], npm: ["node-b", "node-a"], python: ["py-b", "py-a"], }); expect(items.map((g) => g.children!.map((c) => c.label))).toEqual([ ["java-b", "java-a"], ["node-b", "node-a"], ["py-b", "py-a"], ]); expect(configs.map((c) => c.id)).toEqual(["java-a", "java-b", "node-a", "node-b", "py-a", "py-b"]); }); it.each(["tomcat", "application", "compound"])("orders %s configurations across workspace members", (kind) => { const first = { ...config("workspace:one:app", kind), cwd: "/workspace/one", module: "one" }; const second = { ...config("workspace:two:app", kind), cwd: "/workspace/two", module: "two" }; const fresh = config("workspace:two:new", kind); const configs = [first, second, fresh]; const items = runServiceItems(configs, [], [kind], [], { [kind]: [second.id, first.id] }); expect(items[0]!.children!.map((c) => c.key)).toEqual([second, first, fresh].map((c) => `run:config:${c.id}`)); expect(configs).toEqual([first, second, fresh]); }); it("orders remembered configurations when their type was not explicitly added", () => { const items = runServiceItems([build, test], [], [], ["build", "test"], { cargo: ["test", "build"] }); expect(items[0]!.children!.map((c) => c.key)).toEqual(["run:config:test", "run:config:build"]); }); it("keeps stable service identity and session details after refresh and rename", () => { const renamed = { ...build, name: "Renamed build" }; const items = runServiceItems( [renamed, test], [session("s1", renamed, "running", 1, { httpPort: 8080 })], ["cargo"], [], { cargo: ["test", "build"] }, ); expect(items[0]!.children!.map((c) => [c.key, c.label, c.detail])).toEqual([ ["run:config:test", "test", undefined], ["run:config:build", "Renamed build", ":8080"], ]); }); it("lists added types fully and other types only with their runs", () => { const sessions = [ session("s1", dev, "finished", 1), session("s2", dev, "running", 2), session("s3", scratch, "failed", 3, { exitCode: 2 }), ]; const items = runServiceItems([build, test, dev], sessions, ["cargo"]); expect(items.map((i) => [i.key, i.label, i.detail])).toEqual([ ["run:type:cargo", "Cargo", undefined], ["run:type:npm", "npm", "1 running"], ["run:type:python", "Python", undefined], ]); expect(items[0]!.children!.map((c) => [c.key, c.detail, c.dim])).toEqual([ ["run:config:build", undefined, true], ["run:config:test", undefined, true], ]); // The newest session of a configuration decides its state. expect(items[1]!.children!.map((c) => [c.label, c.detail, c.dim, c.icon])).toEqual([ ["dev", "Running", false, "run"], ]); expect(items[2]!.children!.map((c) => [c.key, c.detail])).toEqual([["run:config:context:file", "Failed (exit 2)"]]); }); it("has no groups without added types or sessions", () => { expect(runServiceItems([build], [], [])).toEqual([]); }); it("describes session states", () => { expect(sessionStatus(undefined)).toBeUndefined(); expect(sessionStatus(session("a", build, "starting", 0))).toBe("Starting"); expect(sessionStatus(session("a", build, "failed", 0, { stopped: true }))).toBe("Stopped"); expect(sessionStatus(session("a", build, "finished", 0))).toBe("Finished"); }); it("shows Spring Boot by default, hidden while the project has none", () => { const app = config("spring:app", "spring-boot"); expect(runServiceItems([build], [], DEFAULT_TYPES)).toEqual([]); const items = runServiceItems([build, app], [], DEFAULT_TYPES); expect(items.map((i) => [i.key, i.label, i.expanded])).toEqual([["run:type:spring-boot", "Spring Boot", true]]); expect(items[0]!.children!.map((c) => [c.label, c.detail, c.dim])).toEqual([["spring:app", undefined, true]]); }); it("shows the HTTP port of a running application instead of its state", () => { const app = config("spring:app", "spring-boot"); const running = session("s1", app, "running", 1, { httpPort: 8080 }); expect(sessionDetail(running)).toBe(":8080"); expect(sessionDetail(session("s1", app, "running", 1))).toBe("Running"); expect(sessionDetail(session("s1", app, "finished", 1, { httpPort: 8080, stopped: true }))).toBe("Stopped"); expect(runServiceItems([app], [running], DEFAULT_TYPES)[0]!.children![0]!.detail).toBe(":8080"); }); }); describe("types added to Services", () => { beforeEach(() => localStorage.clear()); it("defaults to Spring Boot and persists the user's list per project", () => { expect(readTypes("/p")).toEqual(["spring-boot"]); localStorage.setItem("openidea.services.runTypes:/p", JSON.stringify(["npm"])); expect(readTypes("/p")).toEqual(["npm"]); localStorage.setItem("openidea.services.runTypes:/p", "[]"); expect(readTypes("/p")).toEqual([]); expect(readTypes("/q")).toEqual(["spring-boot"]); }); }); describe("configurations that ran", () => { const dev = config("dev", "npm"); const scratch = config("context:file", "python"); beforeEach(() => localStorage.clear()); it("persists the ran configuration ids per project", () => { expect(readRan("/p")).toEqual([]); localStorage.setItem("openidea.services.ran:/p", JSON.stringify(["dev"])); expect(readRan("/p")).toEqual(["dev"]); expect(readRan("/q")).toEqual([]); }); it("lists previously run configurations without a session", () => { const items = runServiceItems([dev, scratch], [], [], ["dev", "context:file"]); expect(items.map((i) => [i.key, i.label])).toEqual([ ["run:type:npm", "npm"], ["run:type:python", "Python"], ]); expect(items[0]!.children!.map((c) => [c.label, c.dim])).toEqual([["dev", true]]); expect(items[1]!.children!.map((c) => c.key)).toEqual(["run:config:context:file"]); }); it("ignores ran ids whose configuration is gone and merges sessions", () => { const gone = runServiceItems([dev], [], [], ["missing"]); expect(gone).toEqual([]); const running = session("s1", dev, "running", 1); const items = runServiceItems([dev], [running], [], ["dev"]); 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", () => { it("reads the port from the started line", () => { expect(startedPort("TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'")).toBe(8080); expect(startedPort("Tomcat started on port(s): 8081 (http) with context path ''")).toBe(8081); expect(startedPort("o.s.b.web.embedded.netty.NettyWebServer : Netty started on port 9090")).toBe(9090); expect(startedPort("Netty started on port(s): 9091")).toBe(9091); expect(startedPort("Undertow started on port(s) 8082 (http)")).toBe(8082); expect(startedPort("Jetty started on port 9083 (http/1.1) with context path '/'")).toBe(9083); expect(startedPort("Tomcat initialized with port 8080 (http)")).toBeNull(); expect(startedPort("Started DemoApplication in 1.2 seconds")).toBeNull(); expect(startedPort("Tomcat started on port 99999")).toBeNull(); }); }); describe("hidden configurations", () => { const a = config("a", "spring-boot"); const b = config("b", "spring-boot"); it("hides a configuration unless it runs", () => { const hidden = { "spring-boot": ["a"] }; const stopped = runServiceItems([a, b], [], DEFAULT_TYPES, [], {}, [], () => false, hidden); expect(stopped[0]!.children!.map((c) => c.key)).toEqual(["run:config:b"]); const running = runServiceItems( [a, b], [session("s1", a, "running", 1)], DEFAULT_TYPES, [], {}, [], () => false, hidden, ); expect(running[0]!.children!.map((c) => c.key)).toEqual(["run:config:a", "run:config:b"]); }); it("keeps an empty default group whose configurations are all hidden", () => { const items = runServiceItems([a], [], DEFAULT_TYPES, [], {}, [], () => false, { "spring-boot": ["a"] }); expect(items).toHaveLength(1); expect(items[0]!.children).toEqual([]); }); it("persists hidden ids per project", () => { localStorage.clear(); expect(readHidden("/p")).toEqual({}); localStorage.setItem("openidea.services.runHidden:/p", JSON.stringify({ "spring-boot": ["a"] })); expect(readHidden("/p")).toEqual({ "spring-boot": ["a"] }); expect(readHidden("/q")).toEqual({}); expect(readHidden(null)).toEqual({}); }); }); describe("add service categories", () => { it("categorizes every built-in kind; update the table when adding kinds", () => { const expected: Record = { npm: "JavaScript", vue: "JavaScript", node: "JavaScript", bun: "JavaScript", deno: "JavaScript", cargo: "Rust", rustc: "Rust", gradle: "Build Tools", maven: "Build Tools", cmake: "Build Tools", make: "Build Tools", just: "Build Tools", go: "Go", python: "Python", pytest: "Python", phpunit: "Others", composer: "Others", cc: "C/C++", "cmake-application": "C/C++", shell: "Shell", program: "Shell", compound: "Others", application: "Java", "spring-boot": "Java", native: "Java", "java-file": "Java", kotlin: "Java", kotlinc: "Java", "scala-cli": "Java", ruby: "Others", php: "Others", perl: "Others", lua: "Others", r: "Others", swift: "Others", dart: "Others", elixir: "Others", }; for (const { value } of KIND_OPTIONS) expect(categoryOf(value), value).toBe(expected[value]); }); it("orders buckets: fixed order first, unlisted plugin categories after, Others last", () => { const dispose = registerRunConfigType({ id: "fake-cloud", label: "Cloud Run", icon: "cloud", group: "Clouds", fields: [], commandLine: () => null, }); const buckets = categorizedTypes(["ruby", "spring-boot", "npm", "fake-cloud", "gradle"]); expect(buckets.map((b) => b.category)).toEqual(["Java", "Build Tools", "JavaScript", "Clouds", "Others"]); dispose(); }); it("keeps a single-member category as its own bucket", () => { expect(categorizedTypes(["go", "ruby"])).toEqual([ { category: "Go", kinds: ["go"] }, { category: "Others", kinds: ["ruby"] }, ]); }); it("sorts each bucket's kinds by their label", () => { const buckets = categorizedTypes(["spring-boot", "application", "java-file", "native"]); expect(buckets[0]).toEqual({ category: "Java", kinds: ["application", "java-file", "native", "spring-boot"] }); }); it("groups nothing for an empty list", () => { expect(categorizedTypes([])).toEqual([]); }); }); describe("open in browser", () => { const item = () => ({ key: "run:config:a", label: "a", icon: "" }); const hasBrowser = () => !runServiceProvider.actions!(item()).find((a) => a.id === "open-browser")?.disabled; beforeEach(() => { useProject.setState({ project: null }); useRun.setState({ configs: [], sessions: [] }); }); it("offers Open in Browser while a session runs with an HTTP port", () => { useRun.setState({ configs: [config("a", "spring-boot")], sessions: [session("s1", config("a", "spring-boot"), "running", 1, { httpPort: 8080 })], }); expect(hasBrowser()).toBe(true); }); it("disables Open in Browser without a port or a running session", () => { useRun.setState({ configs: [config("a", "spring-boot")], sessions: [session("s1", config("a", "spring-boot"), "running", 1)], }); expect(hasBrowser()).toBe(false); useRun.setState({ sessions: [session("s1", config("a", "spring-boot"), "finished", 1, { httpPort: 8080 })] }); expect(hasBrowser()).toBe(false); }); }); describe("group batch actions", () => { const group = () => ({ key: "run:type:spring-boot", label: "Spring Boot", icon: "" }); const action = (id: string) => runServiceProvider.actions!(group() as never).find((a: { id: string }) => a.id === id)!; beforeEach(() => { startRun.mockReset().mockResolvedValue(null); stopServiceSessions.mockReset().mockImplementation(async (sessions) => { const ids = sessions.flatMap((s: { run?: RunSession }) => (s.run ? [s.run.id] : [])); useRun.setState((state) => ({ sessions: state.sessions.map((s) => (ids.includes(s.id) ? { ...s, status: "finished" } : s)), })); }); useProject.setState({ project: null }); useRun.setState({ configs: [], sessions: [] }); addType("spring-boot"); }); it("enables Run All and Restart All with stopped configurations, Stop All only with running ones", () => { useRun.setState({ configs: [config("a", "spring-boot"), config("b", "spring-boot")], sessions: [] }); expect(action("run-all").disabled).toBe(false); expect(action("restart-all").disabled).toBe(false); expect(action("stop-all").disabled).toBe(true); }); it("enables Stop All when one runs and Run All while others are stopped", () => { useRun.setState({ configs: [config("a", "spring-boot"), config("b", "spring-boot")], sessions: [session("s1", config("a", "spring-boot"), "running", 1)], }); expect(action("stop-all").disabled).toBe(false); expect(action("run-all").disabled).toBe(false); }); it("disables Run All when everything runs", () => { useRun.setState({ configs: [config("a", "spring-boot")], sessions: [session("s1", config("a", "spring-boot"), "running", 1)], }); expect(action("run-all").disabled).toBe(true); }); it("starts stopped configurations in display order, skipping running ones", async () => { const a = config("a", "spring-boot"); const b = config("b", "spring-boot"); const c = config("c", "spring-boot"); useRun.setState({ configs: [a, b, c], sessions: [session("s1", a, "running", 1)] }); await action("run-all").run(); expect(startRun.mock.calls).toEqual([ [b, undefined, undefined, { show: false }], [c, undefined, undefined, { show: false }], ]); }); it("restarts: stops the running ones, then starts every configuration in order", async () => { const a = config("a", "spring-boot"); const b = config("b", "spring-boot"); useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1)] }); await action("restart-all").run(); expect(stopServiceSessions).toHaveBeenCalledOnce(); expect(stopServiceSessions.mock.calls[0]![0].map((s: { config: RunConfig }) => s.config.id)).toEqual(["a"]); expect(startRun.mock.calls).toEqual([ [a, undefined, undefined, { show: false }], [b, undefined, undefined, { show: false }], ]); }); it("waits for process exit before restarting any configuration and continues after a launch failure", async () => { const a = config("a", "spring-boot"); const b = config("b", "spring-boot"); useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1, { runId: 1 })] }); stopServiceSessions.mockResolvedValue(undefined); startRun.mockRejectedValueOnce(new Error("launch failed")); const pending = action("restart-all").run(); await vi.waitFor(() => expect(stopServiceSessions).toHaveBeenCalledOnce()); expect(startRun).not.toHaveBeenCalled(); useRun.setState({ sessions: [session("s1", a, "finished", 1, { runId: 1 })] }); await pending; expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["a", "b"]); }); it("uses the saved display order and leaves other types alone", async () => { useProject.setState({ project: { root: "/group-order" } as never }); localStorage.setItem("openidea.services.runOrder:/group-order", JSON.stringify({ "spring-boot": ["c", "a", "b"] })); const configs = [ config("a", "spring-boot"), config("b", "spring-boot"), config("c", "spring-boot"), config("d", "npm"), ]; useRun.setState({ configs, sessions: [session("other", configs[3]!, "running", 1)] }); await action("run-all").run(); expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["c", "a", "b"]); expect(action("stop-all").disabled).toBe(true); await action("stop-all").run(); expect(stopServiceSessions).not.toHaveBeenCalled(); }); it("does not resurrect a deleted configuration retained only as finished output", async () => { const a = config("removed", "spring-boot"); useRun.setState({ configs: [a], sessions: [] }); useRun.setState({ configs: [], sessions: [session("old", a, "finished", 1)] }); expect(action("run-all").disabled).toBe(true); expect(action("restart-all").disabled).toBe(true); }); it("rechecks enabled state when a registered action runs", async () => { const a = config("a", "spring-boot"); useRun.setState({ configs: [a], sessions: [] }); const run = action("run-all"); useRun.setState({ sessions: [session("s1", a, "starting", 1)] }); await run.run(); expect(startRun).not.toHaveBeenCalled(); }); it("opens the known group URL and offers a named choice for several web apps", async () => { const a = config("a", "spring-boot"); const b = config("b", "spring-boot"); useRun.setState({ configs: [a, b], sessions: [session("s1", a, "running", 1, { httpPort: 8123 })] }); await action("open-browser").run(); expect(openUrl).toHaveBeenCalledWith("http://localhost:8123"); openUrl.mockClear(); useDebugRunSessions.setState({ serviceSessions: [ { id: 1, config: b, status: "running", processId: null, startedAt: 2, httpPort: 8456, stop: async () => {}, rerun: null, show: () => {}, }, ], }); await action("open-browser").run(); expect(openUrl).not.toHaveBeenCalled(); const choices = showContextMenu.mock.calls[0]![1]; expect(choices.map((c: { label: string }) => c.label)).toEqual([ "a — http://localhost:8123", "b — http://localhost:8456", ]); choices[1].run(); expect(openUrl).toHaveBeenCalledWith("http://localhost:8456"); }); }); describe("removing a workspace member from Services", () => { const main = { ...config("app", "spring-boot"), cwd: "/w/main" }; const vue = { ...config("dev", "npm"), cwd: "/w/vue" }; beforeEach(() => { localStorage.clear(); useProject.setState({ project: { root: "/w/main" } as never }); ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: [main] })); }); afterEach(() => { useProject.setState({ project: null }); useRun.setState({ configs: [], sessions: [], temporary: [], selectedId: null }); }); it("stops and forgets the member's sessions and drops its empty type groups", async () => { useRun.setState({ configs: [main, vue], temporary: ["dev"], sessions: [session("s1", main, "finished", 1), session("s2", vue, "running", 2, { runId: 7 })], }); localStorage.setItem("openidea.services.runTypes:/w/main", JSON.stringify(["spring-boot", "npm"])); localStorage.setItem("openidea.services.ran:/w/main", JSON.stringify(["app", "dev"])); await cleanupRemovedProject("/w/vue"); const s = useRun.getState(); expect(s.sessions.map((x) => x.id)).toEqual(["s1"]); expect(s.configs.map((c) => c.id)).toEqual(["app"]); expect(s.temporary).toEqual([]); expect(readTypes("/w/main")).toEqual(["spring-boot"]); expect(readRan("/w/main")).toEqual(["app"]); expect(ipc.runConfigs).toHaveBeenCalled(); }); it("keeps a type another project still uses, and a sibling with a shared prefix", async () => { const other = { ...config("serve", "npm"), cwd: "/w/main" }; const sibling = { ...config("dev2", "npm"), cwd: "/w/vue2" }; useRun.setState({ configs: [main, other, vue, sibling], sessions: [] }); localStorage.setItem("openidea.services.runTypes:/w/main", JSON.stringify(["npm"])); // What the backend lists once /w/vue is no longer a workspace member. ipc.runConfigs.mockImplementation(async () => ({ status: "ok", data: [main, other, sibling] })); await cleanupRemovedProject("/w/vue"); expect(useRun.getState().configs.map((c) => c.id)).toEqual(["app", "serve", "dev2"]); expect(readTypes("/w/main")).toEqual(["npm"]); }); }); describe("service selection", () => { const group = { key: "run:type:npm", label: "npm", icon: "" }; const item = (id: string) => ({ key: `run:config:${id}`, label: id, icon: "" }); const leaf = (items: MenuItem[], label: string): Extract => { for (const entry of items) { if (entry.kind === "separator" || entry.kind === "header" || entry.kind === "action") continue; if (entry.label === label) return entry; if (entry.submenu) { const found = leaf(entry.submenu, label); if (found) return found; } } return undefined!; }; beforeEach(() => { localStorage.clear(); useProject.setState({ project: { root: "/selection" } as never }); useRun.setState({ configs: [config("dev", "npm")], sessions: [] }); vi.mocked(pickServices).mockReset(); startRun.mockReset().mockResolvedValue(null); }); it("restores a hidden single configuration through its group's Add Service", async () => { leaf(runServiceProvider.menu!(item("dev")), "Remove from Services").run!(); expect(readHidden("/selection")).toEqual({ npm: ["dev"] }); await leaf(runServiceProvider.menu!(group), "Add Service").run!(); expect(readHidden("/selection")).toEqual({ npm: [] }); expect(pickServices).not.toHaveBeenCalled(); }); it("keeps Run All and Restart All scoped to visible configurations", async () => { addType("npm"); useRun.setState({ configs: [config("dev", "npm"), config("test", "npm")] }); leaf(runServiceProvider.menu!(item("dev")), "Remove from Services").run!(); for (const id of ["run-all", "restart-all"]) { startRun.mockClear(); await runServiceProvider.actions!(group) .find((a) => a.id === id)! .run(); expect(startRun.mock.calls.map(([c]) => c.id)).toEqual(["test"]); } }); it("does not apply a picker result after switching projects", async () => { useRun.setState({ configs: [config("dev", "npm"), config("test", "npm")] }); let finish!: (value: Set) => void; vi.mocked(pickServices).mockImplementation( () => new Promise((resolve) => { finish = resolve; }), ); const pending = leaf(runServiceProvider.menu!(group), "Add Service").run!(); useProject.setState({ project: { root: "/other-selection" } as never }); finish(new Set(["dev"])); await pending; await Promise.resolve(); expect(readHidden("/other-selection")).toEqual({}); expect(readTypes("/other-selection")).toEqual(DEFAULT_TYPES); }); });