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 <[email protected]>
Цей коміт міститься в:
misswellтаmisswell authored and GitHub committed 2026-11-09 01:05:45 +08:00
1 джерело 5c3feea2a8
коміт cad33ef119
15 files changed
+291 -22

No files matched your search

+2
Переглянути файл
@@ -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);
+7 -6
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -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")
+36
Переглянути файл
@@ -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");
+11 -3
Переглянути файл
@@ -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",
+2
Переглянути файл
@@ -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);
}
+7
Переглянути файл
@@ -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") }]
: []),
+32 -7
Переглянути файл
@@ -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<BeforeLaunchKind, string> = {
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);
+59
Переглянути файл
@@ -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<void>((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");
});
});
+20
Переглянути файл
@@ -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<string | null> {
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";
}
}
+2 -5
Переглянути файл
@@ -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<string | null> {
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 });
}
+16 -1
Переглянути файл
@@ -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<Record<string, string>>;
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)) {
+82
Переглянути файл
@@ -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(
<ConfigForm config={config} all={[config]} type={undefined} errors={{}} onChange={changed} modifyRequest={0} />,
);
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", () => {
+8
Переглянути файл
@@ -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",
+6
Переглянути файл
@@ -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": "找到的项目",