From cad33ef1194d818d605f0c0ab09a40410cb130b3 Mon Sep 17 00:00:00 2001 From: misswell Date: Mon, 9 Nov 2026 01:05:45 +0800 Subject: [PATCH] feat(run): add Build Module and Build Artifacts before launch (#2155) Adds Build Module and Build Artifacts to the Before launch tasks, next to Build Project. A launch now waits for the fresh build result and stops on a compile error, a cancelled or busy build, or a removed module; duplicate tasks are rejected and saved tasks are validated; the Build window keeps its visibility. Co-authored-by: misswell <28730815+misswell@users.noreply.github.com> --- e2e/boot.spec.ts | 2 + e2e/diff.spec.ts | 13 ++-- e2e/httpHighlight.spec.ts | 1 + src/features/build/build.test.tsx | 36 +++++++++++ src/features/build/index.ts | 14 +++- src/features/build/store.ts | 2 + src/features/run/ConfigForm.tsx | 7 ++ src/features/run/beforeLaunch.ts | 39 +++++++++-- src/features/run/buildTask.test.ts | 59 +++++++++++++++++ src/features/run/buildTask.ts | 20 ++++++ src/features/run/cmake.ts | 7 +- src/features/run/configs.ts | 17 ++++- src/features/run/runConfigTypes.test.tsx | 82 ++++++++++++++++++++++++ src/platform/locales/text/en.json | 8 +++ src/platform/locales/text/zh.json | 6 ++ 15 files changed, 291 insertions(+), 22 deletions(-) create mode 100644 src/features/run/buildTask.test.ts create mode 100644 src/features/run/buildTask.ts diff --git a/e2e/boot.spec.ts b/e2e/boot.spec.ts index a7018b2d2..b550e328f 100644 --- a/e2e/boot.spec.ts +++ b/e2e/boot.spec.ts @@ -73,7 +73,9 @@ test("a slow module load succeeds before the deadline and cancels the startup ti await gate; await route.fulfill({ response }); }); + const moduleRequest = page.waitForRequest("**/src/main.tsx"); await page.goto("/", { waitUntil: "commit" }); + await moduleRequest; await page.locator("#splash").waitFor({ state: "attached" }); await page.clock.runFor(25_000); await expect(page.locator("#boot-error")).toHaveCount(0); diff --git a/e2e/diff.spec.ts b/e2e/diff.spec.ts index 724b72900..4878eb7f5 100644 --- a/e2e/diff.spec.ts +++ b/e2e/diff.spec.ts @@ -294,18 +294,19 @@ test.describe("10,000-line files", () => { await page.keyboard.press("Meta+ArrowUp"); await page.keyboard.press("F7"); await expect(counter(page)).toHaveText("1 of 42 differences"); - const first = await rendered(page); if (opts.mode !== "unified") { - expect(first.a.words).toContainEqual(["compute", "modified"]); - expect(first.b.words).toContainEqual(["computeFast", "modified"]); + await expect.poll(async () => (await rendered(page)).a.words).toContainEqual(["compute", "modified"]); + await expect.poll(async () => (await rendered(page)).b.words).toContainEqual(["computeFast", "modified"]); } // The last difference is reachable and still highlighted. await page.keyboard.press("Meta+ArrowDown"); await page.keyboard.press("Shift+F7"); await expect(counter(page)).toHaveText("42 of 42 differences"); - const last = await rendered(page); - if (opts.mode === "unified") expect(last.b.lines).toContain("inserted"); - else expect(last.b.words).toContainEqual(["computeFast", "modified"]); + if (opts.mode === "unified") { + await expect.poll(async () => (await rendered(page)).b.lines).toContain("inserted"); + } else { + await expect.poll(async () => (await rendered(page)).b.words).toContainEqual(["computeFast", "modified"]); + } await scrollToEnd(page); if (opts.mode !== "unified") { const [a, b] = await topLines(page); diff --git a/e2e/httpHighlight.spec.ts b/e2e/httpHighlight.spec.ts index 3ae6e7678..cde126a2c 100644 --- a/e2e/httpHighlight.spec.ts +++ b/e2e/httpHighlight.spec.ts @@ -6,6 +6,7 @@ for (const theme of ["light", "dark"] as const) { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); await page.goto(`/e2e/httpHighlight.html?theme=${theme}${extension === "rest" ? "&rest" : ""}`); + await expect(page.locator("#json .cm-line").first()).toBeVisible(); const token = (text: string) => page .locator("#http .cm-line span") diff --git a/src/features/build/build.test.tsx b/src/features/build/build.test.tsx index 28a7a9aec..cfcd1ffcd 100644 --- a/src/features/build/build.test.tsx +++ b/src/features/build/build.test.tsx @@ -288,6 +288,42 @@ describe("build feature", () => { ); }); + it.each([ + ["build.buildModule", { target: "/p/core", activate: false, wait: true }, "build", "/p/core"], + ["build.buildArtifacts", { activate: false, wait: true }, "artifacts", null], + ])("awaits %s only when requested and keeps its successful build hidden", async (id, arg, kind, file) => { + setContext("build.lastStatus", "success"); + let finished = false; + const run = executeAction(id, arg).then(() => { + finished = true; + }); + await act(async () => {}); + expect(fake.calls.at(-1)).toMatchObject({ kind, file }); + expect(finished).toBe(false); + expect(getContext()["build.lastStatus"]).toBeNull(); + expect(show).not.toHaveBeenCalled(); + await act(async () => { + fake.resolve(result([], true)); + await run; + }); + expect(finished).toBe(true); + expect(getContext()["build.lastStatus"]).toBe("success"); + expect(show).not.toHaveBeenCalled(); + }); + + it.each([ + ["build.buildModule", "/p/core"], + ["build.buildModule", undefined], + ["build.buildArtifacts", undefined], + ])("keeps ordinary %s calls nonblocking (%s)", async (id, arg) => { + await executeAction(id, arg); + expect(useBuildRun.getState().status).toBe("running"); + expect(fake.calls.at(-1)?.file).toBe(arg ?? null); + await act(async () => { + fake.resolve(result([], true)); + }); + }); + it("starts a build without a workbench", async () => { show.mockImplementation(() => { throw new Error("No workbench"); diff --git a/src/features/build/index.ts b/src/features/build/index.ts index 0e6421c46..ff1aaa3cc 100644 --- a/src/features/build/index.ts +++ b/src/features/build/index.ts @@ -21,6 +21,7 @@ const WHEN = `projectOpen && !${BUILD_RUNNING}`; /** The module to build: the invoker's path (project view) or the active editor's file. */ function moduleTarget(arg: unknown): string | null { if (typeof arg === "string" && arg) return arg; + if (typeof arg === "object" && arg !== null && "target" in arg && typeof arg.target === "string") return arg.target; return maybeEditorService()?.activePath() ?? null; } @@ -79,8 +80,10 @@ export function activate(): () => void { when: WHEN, keywords: ["recompile", "compile file", "make module"], run: ({ arg }) => { - const target = moduleTarget(arg); - void startBuild("build", target); + const options = typeof arg === "object" && arg !== null ? arg : {}; + const activate = !("activate" in options && options.activate === false); + const build = startBuild("build", moduleTarget(arg), activate); + if ("wait" in options && options.wait === true) return build.then(() => undefined); }, }, { @@ -103,7 +106,12 @@ export function activate(): () => void { category: "Build", when: WHEN, keywords: ["package", "assemble", "jar", "release"], - run: () => void startBuild("artifacts"), + run: ({ arg }) => { + const options = typeof arg === "object" && arg !== null ? arg : {}; + const activate = !("activate" in options && options.activate === false); + const build = startBuild("artifacts", null, activate); + if ("wait" in options && options.wait === true) return build.then(() => undefined); + }, }, { id: "build.stop", diff --git a/src/features/build/store.ts b/src/features/build/store.ts index 1b6fe02fc..c2decc3a5 100644 --- a/src/features/build/store.ts +++ b/src/features/build/store.ts @@ -139,6 +139,7 @@ export async function startBuild( const cancel = () => backend.cancel(request); const previous = useBuildRun.getState().files; useBuildRun.setState({ ...INITIAL, request, kind, file, title: taskTitle, status: "running", files: previous }); + setContext(BUILD_LAST_STATUS, null); setContext(BUILD_RUNNING, true); if (activate) { try { @@ -280,5 +281,6 @@ export function resetBuildRun() { stopBuild(); removeTask(BUILD_TASK_ID); setContext(BUILD_RUNNING, false); + setContext(BUILD_LAST_STATUS, null); useBuildRun.setState(INITIAL); } diff --git a/src/features/run/ConfigForm.tsx b/src/features/run/ConfigForm.tsx index 1c4fb15d9..f1886e501 100644 --- a/src/features/run/ConfigForm.tsx +++ b/src/features/run/ConfigForm.tsx @@ -300,6 +300,13 @@ export function ConfigForm({ config, all, type, errors, template, onChange, modi })), }, { label: TASK_LABELS.build, icon: "build-project", run: () => addTask("build") }, + { + label: TASK_LABELS.buildModule, + icon: "build-project", + disabled: config.module == null, + run: () => addTask("buildModule", config.module ?? ""), + }, + { label: TASK_LABELS.buildArtifacts, icon: "build-rebuild", run: () => addTask("buildArtifacts") }, ...(config.kind === "dotnet" ? [{ label: TASK_LABELS.dotnetBuild, icon: "build-project", run: () => addTask("dotnetBuild") }] : []), diff --git a/src/features/run/beforeLaunch.ts b/src/features/run/beforeLaunch.ts index 5c3e128a1..686403f82 100644 --- a/src/features/run/beforeLaunch.ts +++ b/src/features/run/beforeLaunch.ts @@ -4,8 +4,10 @@ */ import { buildCMake, isCMakeBuild } from "./cmake"; import { dotnetBuildConfig } from "./dotnetTypes"; -import { executeAction, getAction } from "@/platform/actions"; -import { getContext } from "@/platform/context"; +import { useProject } from "@/platform/project"; +import { useBuildPresentation } from "@/platform/projectPresentation"; +import { buildTask } from "./buildTask"; +import { moduleTaskError } from "./configs"; import { tx, txTemplate } from "@/platform/i18n"; import type { BeforeLaunchTask, RunConfig } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; @@ -14,13 +16,22 @@ import { externalToolConfig } from "./externalTools"; import { startRun, useRun } from "./store"; export const BUILD_ACTION = "build.buildProject"; -const BUILD_STATUS = "build.lastStatus"; -export type BeforeLaunchKind = "run" | "build" | "tool" | "command" | "toolWindow" | "dotnetBuild"; +export type BeforeLaunchKind = + | "run" + | "build" + | "buildModule" + | "buildArtifacts" + | "tool" + | "command" + | "toolWindow" + | "dotnetBuild"; export const TASK_LABELS: Record = { run: "Run Another Configuration", build: "Build Project", + buildModule: "Build Module", + buildArtifacts: "Build Artifacts", dotnetBuild: "Build .NET project", tool: "Run External Tool", command: "Run Shell Command", @@ -35,6 +46,10 @@ export function taskLabel(task: BeforeLaunchTask, configs: readonly RunConfig[] return `Run '${configs.find((c) => c.id === target)?.name ?? target}'`; case "build": return configs.find((c) => c.id === target)?.name ?? "Build Project"; + case "buildModule": + return target ? `Build Module '${target}'` : "Build Module"; + case "buildArtifacts": + return "Build Artifacts"; case "dotnetBuild": return "Build .NET project"; case "tool": @@ -73,10 +88,20 @@ async function runTask( return (await runToEnd(dotnetBuildConfig(config), chain, show)) ? null : "Build failed"; case "build": { if (target) return buildCMake(target, show === true); - if (!getAction(BUILD_ACTION)) return "Building is not available"; - if (!(await executeAction(BUILD_ACTION, { activate: false }))) return "A build is already running"; - return getContext()[BUILD_STATUS] === "success" ? null : "Build failed"; + return buildTask(BUILD_ACTION, { activate: false }); } + case "buildModule": { + const error = moduleTaskError(config, target); + if (error) return error; + const root = useProject.getState().project?.root; + if (!root) return "Build did not start"; + const relative = target.startsWith(root + "/") ? target.slice(root.length + 1) : target; + if (!useBuildPresentation.getState().modules.has(relative)) return `Module '${target}' no longer exists`; + const path = /^(?:[A-Za-z]:[\\/]|\/)/.test(target) ? target : `${root}/${target}`; + return buildTask("build.buildModule", { target: path, activate: false, wait: true }); + } + case "buildArtifacts": + return buildTask("build.buildArtifacts", { activate: false, wait: true }); case "run": { if (chain.includes(target)) return "The before launch tasks run in a cycle"; const other = useRun.getState().configs.find((c) => c.id === target); diff --git a/src/features/run/buildTask.test.ts b/src/features/run/buildTask.test.ts new file mode 100644 index 000000000..10284e400 --- /dev/null +++ b/src/features/run/buildTask.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerAction } from "@/platform/actions"; +import { getContext, setContext } from "@/platform/context"; +import { buildTask } from "./buildTask"; + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + setContext("build.running", false); + setContext("build.lastStatus", null); +}); + +describe("before-launch build results", () => { + it.each(["build.buildProject", "build.buildModule", "build.buildArtifacts"])( + "awaits fresh results from %s", + async (id) => { + for (const [status, error] of [ + ["success", null], + ["failed", "Build failed"], + ["cancelled", "Build cancelled"], + [null, "Build did not start"], + ] as const) { + setContext("build.lastStatus", "success"); + let finish!: () => void; + dispose = registerAction({ + id, + title: "Build", + run: () => + new Promise((resolve) => { + finish = () => { + setContext("build.lastStatus", status); + resolve(); + }; + }), + }); + let finished = false; + const task = buildTask(id, { activate: false, wait: true }).then((value) => { + finished = true; + return value; + }); + expect(getContext()["build.lastStatus"]).toBeNull(); + expect(finished).toBe(false); + finish(); + expect(await task).toBe(error); + dispose(); + } + }, + ); + + it("reports an existing build without changing its result", async () => { + const run = vi.fn(); + dispose = registerAction({ id: "build.buildModule", title: "Build", run }); + setContext("build.running", true); + setContext("build.lastStatus", "failed"); + expect(await buildTask("build.buildModule", {})).toBe("A build is already running"); + expect(run).not.toHaveBeenCalled(); + expect(getContext()["build.lastStatus"]).toBe("failed"); + }); +}); diff --git a/src/features/run/buildTask.ts b/src/features/run/buildTask.ts new file mode 100644 index 000000000..5026ad6cb --- /dev/null +++ b/src/features/run/buildTask.ts @@ -0,0 +1,20 @@ +import { executeAction, getAction } from "@/platform/actions"; +import { getContext, setContext } from "@/platform/context"; + +/** Dispatches an awaitable build and consumes only its fresh completion status. */ +export async function buildTask(action: string, arg: unknown): Promise { + if (!getAction(action)) return "Building is not available"; + if (getContext()["build.running"]) return "A build is already running"; + setContext("build.lastStatus", null); + if (!(await executeAction(action, arg))) return "A build is already running"; + switch (getContext()["build.lastStatus"]) { + case "success": + return null; + case "cancelled": + return "Build cancelled"; + case "failed": + return "Build failed"; + default: + return "Build did not start"; + } +} diff --git a/src/features/run/cmake.ts b/src/features/run/cmake.ts index 24cb8012e..f00d93201 100644 --- a/src/features/run/cmake.ts +++ b/src/features/run/cmake.ts @@ -1,5 +1,4 @@ -import { executeAction, getAction } from "@/platform/actions"; -import { getContext } from "@/platform/context"; +import { buildTask } from "./buildTask"; import type { RunConfig } from "@/platform/ipc"; export function isCMakeBuild(config: RunConfig): boolean { @@ -23,7 +22,5 @@ export function cmakeBuildId(config: RunConfig | undefined, configs: readonly Ru } export async function buildCMake(id: string, activate = false): Promise { - if (!getAction("build.buildProject")) return "Building is not available"; - if (!(await executeAction("build.buildProject", { cmake: id, activate }))) return "A build is already running"; - return getContext()["build.lastStatus"] === "success" ? null : "Build failed"; + return buildTask("build.buildProject", { cmake: id, activate }); } diff --git a/src/features/run/configs.ts b/src/features/run/configs.ts index 7f759a216..fa4c39a96 100644 --- a/src/features/run/configs.ts +++ b/src/features/run/configs.ts @@ -216,6 +216,12 @@ export function duplicateConfig(config: RunConfig, configs: readonly RunConfig[] /** Field errors: `name`, `command`, `env`, `cwd`, `compound`, `beforeLaunch` or a type param key. */ export type ConfigErrors = Partial>; +export function moduleTaskError(config: RunConfig, target: string): string | null { + if (config.module == null) return "Select a module for the Build Module task"; + if (config.module !== target) return "The Build Module task uses a different module; remove it and add it again"; + return null; +} + export function validateConfig(config: RunConfig, all: readonly RunConfig[] = []): ConfigErrors { const errors: ConfigErrors = {}; if (!config.name.trim()) errors.name = "Name is required"; @@ -235,7 +241,16 @@ export function validateConfig(config: RunConfig, all: readonly RunConfig[] = [] (t.target === config.id || !all.some((c) => c.id === t.target)) ) errors.beforeLaunch = "A before launch task runs a missing configuration or this one"; - else if (t.kind !== "build" && t.kind !== "dotnetBuild" && t.kind !== "run" && !(t.target ?? "").trim()) + else if (t.kind === "buildModule") { + const error = moduleTaskError(config, t.target ?? ""); + if (error) errors.beforeLaunch = error; + } else if ( + t.kind !== "build" && + t.kind !== "buildArtifacts" && + t.kind !== "dotnetBuild" && + t.kind !== "run" && + !(t.target ?? "").trim() + ) errors.beforeLaunch = "A before launch task is incomplete"; } for (const key of Object.keys(config.env)) { diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index c95221a0d..4cfe6ac65 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -1,3 +1,4 @@ +import { useBuildPresentation } from "@/platform/projectPresentation"; import { useProject } from "@/platform/project"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; @@ -618,6 +619,87 @@ describe("before launch tasks", () => { setBeforeLaunchRunner(null); } }); + + it.each([undefined, "", "core"])("validates saved build tasks and module selection (%s)", (module) => { + const app = cfg("app", { + module, + beforeLaunch: [ + { kind: "buildArtifacts", target: "" }, + { kind: "buildModule", target: module ?? "" }, + ], + }); + expect(validateConfig(app).beforeLaunch).toBe( + module == null ? "Select a module for the Build Module task" : undefined, + ); + if (module != null) { + expect(validateConfig({ ...app, module: "other" }).beforeLaunch).toContain("different module"); + } + }); + + it.each([undefined, "", "core"])("offers Build Module only with a selected module (%s)", (module) => { + const config = cfg("app", { module, beforeLaunch: [{ kind: "buildArtifacts", target: "" }] }); + const changed = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Add task" })); + const item = screen.getByRole("menuitem", { name: "Build Module" }); + expect(item.getAttribute("aria-disabled")).toBe(module == null ? "true" : null); + if (module != null) { + fireEvent.click(item); + expect(changed).toHaveBeenCalledWith( + expect.objectContaining({ + beforeLaunch: [...config.beforeLaunch!, { kind: "buildModule", target: module }], + }), + ); + } + }); + + it("refuses a removed module before dispatching its build", async () => { + const run = vi.fn(); + const dispose = registerActions([{ id: "build.buildModule", title: "Build Module", run }]); + useProject.setState({ project: { root: "/p" } as never }); + useBuildPresentation.setState({ modules: new Map() }); + try { + const app = cfg("app", { module: "removed", beforeLaunch: [{ kind: "buildModule", target: "removed" }] }); + expect(await runBeforeLaunch(app, [app.id])).toBe(false); + expect(run).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: expect.stringContaining("no longer exists") }), + ); + } finally { + dispose(); + useProject.setState({ project: null }); + } + }); + + it("runs Build Module and Build Artifacts before launch", async () => { + const buildModule = vi.fn(async () => setContext("build.lastStatus", "success")); + const artifacts = vi.fn(async () => setContext("build.lastStatus", "success")); + const disposeBuild = registerActions([ + { id: "build.buildModule", title: "Build Module", run: buildModule }, + { id: "build.buildArtifacts", title: "Build Artifacts", run: artifacts }, + ]); + useProject.setState({ project: { root: "/p" } as never }); + useBuildPresentation.setState({ modules: new Map([["core", "maven"]]) }); + const app = cfg("app", { + module: "core", + beforeLaunch: [ + { kind: "buildModule", target: "core" }, + { kind: "buildArtifacts", target: "" }, + ], + }); + expect(await runBeforeLaunch(app, [app.id])).toBe(true); + expect(buildModule).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ arg: { target: "/p/core", activate: false, wait: true } }), + ); + expect(artifacts).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ arg: { activate: false, wait: true } }), + ); + useProject.setState({ project: null }); + useBuildPresentation.setState({ modules: new Map() }); + disposeBuild(); + }); }); describe("environment variables", () => { diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 9de3d5185..16588b41b 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -494,6 +494,14 @@ "Could not copy. Select the details below and copy them.": "Could not copy. Select the details below and copy them.", "Startup error details": "Startup error details", "Startup did not finish within 30 seconds.": "Startup did not finish within 30 seconds.", + "Build Module": "Build Module", + "Build Artifacts": "Build Artifacts", + "Build Module '{target}'": "Build Module '{target}'", + "Build cancelled": "Build cancelled", + "Build did not start": "Build did not start", + "Select a module for the Build Module task": "Select a module for the Build Module task", + "The Build Module task uses a different module; remove it and add it again": "The Build Module task uses a different module; remove it and add it again", + "Module '{target}' no longer exists": "Module '{target}' no longer exists", "Choose Projects": "Choose Projects", "Add Selected": "Add Selected", "Projects found": "Projects found", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 0d756f499..4447871be 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -6836,6 +6836,12 @@ "Could not copy. Select the details below and copy them.": "无法复制。请选择下方详情并手动复制。", "Startup error details": "启动错误详情", "Startup did not finish within 30 seconds.": "启动未在 30 秒内完成。", + "Build Module '{target}'": "构建模块“{target}”", + "Build cancelled": "构建已取消", + "Build did not start": "构建未启动", + "Select a module for the Build Module task": "请为构建模块任务选择模块", + "The Build Module task uses a different module; remove it and add it again": "构建模块任务使用了不同的模块,请移除后重新添加", + "Module '{target}' no longer exists": "模块“{target}”已不存在", "Choose Projects": "选择项目", "Add Selected": "添加所选项目", "Projects found": "找到的项目",