diff --git a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= index 6298053..4f3b3b8 100755 --- a/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= +++ b/.aspect/rules/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= @@ -3,6 +3,6 @@ # This file should be checked into version control along with the pnpm-lock.yaml file. package.json=1645056029 packages/core/package.json=-1407934132 -packages/vscode/package.json=-460142765 +packages/vscode/package.json=988212829 pnpm-lock.yaml=1115513514 pnpm-workspace.yaml=-1673009026 diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore index 69ea2f5..c4ea678 100644 --- a/packages/vscode/.vscodeignore +++ b/packages/vscode/.vscodeignore @@ -1,5 +1,6 @@ ** !dist/extension.js +!dist/taskRunner.js !package.json !README.md !CHANGELOG.md diff --git a/packages/vscode/BUILD.bazel b/packages/vscode/BUILD.bazel index 04322af..db5a89b 100644 --- a/packages/vscode/BUILD.bazel +++ b/packages/vscode/BUILD.bazel @@ -64,6 +64,9 @@ ts_project( "src/output.ts", "src/projects.ts", "src/targetShortcuts.ts", + "src/taskProgress.ts", + "src/taskProgressView.ts", + "src/taskRunner.ts", "src/tasks.ts", "src/testing.ts", "src/workspaceMembers.ts", @@ -124,6 +127,22 @@ esbuild( ], ) +# A native process task keeps stdin/stdout attached to VS Code; this wrapper streams structured stderr to the host. +esbuild( + name = "task_runner", + srcs = glob(["src/**/*.ts"]) + [ + "tsconfig.json", + "//:tsconfig_base", + ], + entry_point = "src/taskRunner.ts", + format = "cjs", + output = "dist/taskRunner.js", + platform = "node", + target = "node22", + tsconfig = "tsconfig.json", + deps = [":node_modules/@elide/ide-core"], +) + copy_to_bin( name = "assets", srcs = [ @@ -159,6 +178,7 @@ filegroup( srcs = [ ":assets", ":bundle", + ":task_runner", ":typescript_plugin", ":vscode_typecheck", ], diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 5d7025e..ba5529b 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,13 +4,21 @@ All notable changes to the Elide extension are documented here. The format follo [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [semantic versioning](https://semver.org/spec/v2.0.0.html). -## Unreleased +## [Unreleased] ### Added - Workspace member strings in `elide.pkl` link to the member's manifest and support Go to Definition. Hover shows the project's name and description when the member is already loaded and synced. Navigation follows unsaved edits without triggering project loading or synchronization. +- Structured task progress beside normal task output inside VS Code's existing Terminal container. Inspect live + build steps, progress, durations and skipped tasks; cancel a running task or open its step log. Task history is + also available in Activity. Native stdin/stdout and task reruns are preserved, as are subsequent user layout changes. + +### Fixed + +- Enable colorized process output for Elide task terminals, including tasks launched from sidebar targets + and Android builds. Preserve color opt-outs and keep failure messages free of ANSI escapes. ## [0.8.0](https://github.com/elide-dev/vscode/compare/v0.7.0...v0.8.0) (2026-11-03) diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 77d7e68..36b023b 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -32,6 +32,12 @@ test, and debug commands are available from the editor. - **Mixed-editor checkouts.** A repository can carry both an IntelliJ `.idea` directory and the generated `workspace.json`; each editor uses its own. - **Tasks.** `elide build`, `elide test`, `elide install`, and one run task per manifest entrypoint. + Task terminals keep colorized process output, stdin and native terminal behavior, including Android build failures. + **Task Progress** sits beside the terminal inside the existing **Terminal** tab: live build steps, progress, + durations, skipped-task reasons and cancellation, using the same timeline as syncs. The extension places it there + once, on first use; later moves and resizing are yours. **Elide: Show Task Progress** reopens it in its current location. + Step logs record build progress and stderr; program stdout remains in the terminal. Older Elide releases fall back + to their text progress. Explicit CLI color options and color opt-outs (`NO_COLOR`, `FORCE_COLOR=0`) are preserved. - **Test Explorer.** JUnit tests of the project's test source roots in the **Testing** view, run and debugged through `elide test --reporter=tap` with live results, failure messages and stack-trace locations. **Run with Coverage** shows the run's LCOV and JaCoCo XML reports in the editor's coverage gutter and Test Coverage view. @@ -42,7 +48,7 @@ test, and debug commands are available from the editor. task's, build target's or Android artifact's context menu) keeps a target in a **Pinned** section above the projects, and a **Frequently used** section lists the five targets run most often from the sidebar itself — runs from the CLI, code lenses or the palette are not counted. Both are remembered per workspace. -- **Activity.** The sidebar's **Activity** view lists every sync and Compose preview build of the window, live +- **Activity.** The sidebar's **Activity** view lists every sync, task and Compose preview build of the window, live while it runs: each step (reading the manifest, installing dependencies, resolving classpaths, generating resources, writing `workspace.json`), the `elide` commands it ran, and the build tasks those reported, each with its duration and share of the whole; a task Elide skipped is listed with the reason, such as `Up to date`, since its @@ -111,7 +117,8 @@ accept); add it to `.gitignore` instead to cover every clone. | `Elide: Add Workspace Member…` | Create a project inside an existing one and declare it in `workspace.members`. | | `Elide: Open Generated Kotlin LSP Workspace` | Open the generated `workspace.json`. | | `Elide: Show Output` | Open the `Elide` output channel. | -| `Elide: Show Activity` | Reveal the **Activity** view: syncs and preview builds, step by step, with durations. | +| `Elide: Show Activity` | Reveal the **Activity** view: syncs, tasks and preview builds, step by step, with durations. | +| `Elide: Show Task Progress` | Reveal structured task progress beside the terminal in the existing **Terminal** container. | | `Elide: Open Activity Log` | Open the latest operation's output, with timestamps, in an editor. | | `Elide: Select Android Deployment Target…` | Choose the remembered project, APK and device shared by Run and Debug. | | `Elide: Check Android Environment` | Inspect local tools and device readiness, including before sync succeeds; select a finding for fixes. | diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 1bed114..f2a4f8e 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -66,6 +66,7 @@ "onTaskType:elide", "onView:elide.projects", "onView:elide.activity", + "onView:elide.taskProgress", "onWebviewPanel:elide.composePreview" ], "contributes": { @@ -489,6 +490,12 @@ "title": "Show Activity", "icon": "$(pulse)" }, + { + "command": "elide.showTaskProgress", + "category": "Elide", + "title": "Show Task Progress", + "icon": "$(tasklist)" + }, { "command": "elide.openActivityLog", "category": "Elide", @@ -910,7 +917,7 @@ }, { "command": "elide.clearActivity", - "when": "view == elide.activity", + "when": "view == elide.activity || view == elide.taskProgress", "group": "navigation@1" } ], @@ -1075,12 +1082,12 @@ }, { "command": "elide.cancelActivity", - "when": "view == elide.activity && viewItem =~ /Cancellable$/", + "when": "(view == elide.activity || view == elide.taskProgress) && viewItem =~ /Cancellable$/", "group": "inline@1" }, { "command": "elide.openActivityLog", - "when": "view == elide.activity && viewItem =~ /^activity/", + "when": "(view == elide.activity || view == elide.taskProgress) && viewItem =~ /^activity/", "group": "inline@2" } ] @@ -1107,6 +1114,13 @@ "name": "Activity", "contextualTitle": "Elide", "icon": "media/elide.svg" + }, + { + "id": "elide.taskProgress", + "name": "Task Progress", + "visibility": "hidden", + "contextualTitle": "Elide", + "icon": "media/elide.svg" } ] }, @@ -1118,7 +1132,11 @@ }, { "view": "elide.activity", - "contents": "Nothing has run yet. Syncs and Compose preview builds appear here while they run, step by step, with the time each step took and its output.\n[Sync Project(s)](command:elide.sync)" + "contents": "Nothing has run yet. Syncs, tasks and Compose preview builds appear here while they run, step by step, with the time each step took and its output.\n[Sync Project(s)](command:elide.sync)" + }, + { + "view": "elide.taskProgress", + "contents": "Elide tasks appear here while they run, step by step, alongside their terminal output. Open a step to read its log.\n[Run Task…](command:elide.runTask)" } ], "problemMatchers": [ diff --git a/packages/vscode/src/activity.ts b/packages/vscode/src/activity.ts index f8ea33b..ad41f75 100644 --- a/packages/vscode/src/activity.ts +++ b/packages/vscode/src/activity.ts @@ -24,7 +24,7 @@ export interface ElideActivityApi { } /** - * What Elide is doing and has done in this window: syncs and preview builds, each a tree of the steps it went + * What Elide is doing and has done in this window: syncs, tasks and preview builds, each a tree of the steps it went * through, every step with its duration and share of the whole, and the output it produced, readable as a log. * * The status bar and notifications only ever show the step in progress; this view is where a slow or failed diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 08464e3..022baa2 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -58,6 +58,8 @@ export async function activate(context: vscode.ExtensionContext): Promise(); + private taskProgressInstance: ElideTaskProgress | undefined; constructor( private readonly ui: ElideUi, @@ -154,6 +156,11 @@ export class ElideWorkspace implements vscode.Disposable { this.komodoServer = new KomodoLanguageServer(cacheRoot, (message) => this.ui.log(message)); } + /** Shared native task progress and its Terminal-container view; sync-only consumers do not initialize it. */ + get taskProgress(): ElideTaskProgress { + return this.taskProgressInstance ??= new ElideTaskProgress(this.ui.activity, this.state, path.join(__dirname, "taskRunner.js")); + } + get projects(): ElideProject[] { return [...this.folders.values()].flatMap((f) => [...f.projects.values()]); } @@ -1159,6 +1166,7 @@ export class ElideWorkspace implements vscode.Disposable { } dispose(): void { + this.taskProgressInstance?.dispose(); this.kotlinLspEnvironment.dispose(); this.komodoServer.dispose(); this.manifestDiagnostics.dispose(); diff --git a/packages/vscode/src/taskProgress.ts b/packages/vscode/src/taskProgress.ts new file mode 100644 index 0000000..641adad --- /dev/null +++ b/packages/vscode/src/taskProgress.ts @@ -0,0 +1,189 @@ +import { randomBytes } from "node:crypto"; +import { createServer, type Server, type Socket } from "node:net"; +import { stripVTControlCharacters } from "node:util"; +import { + ElideFailureReport, + LineSplitter, + observeCommands, + type CommandObserver, + type ElideFailure, + type ElideRecord, + type TimelineNode, +} from "@elide/ide-core"; +import * as vscode from "vscode"; +import type { ElideActivity } from "./activity.js"; +import { TaskProgressView } from "./taskProgressView.js"; + +interface TaskCommand { + bin: string; + cwd: string; + argv: string[]; + env?: Record; +} + +interface TaskFailure { + exitCode: number | undefined; + failure: ElideFailure; +} + +interface Launch { + name: string; + onFailure?: (failure: TaskFailure) => void; +} + +type RunnerMessage = + | { type: "start"; secret: string; label: string; argv: string[]; cwd: string; pid: number } + | { type: "record"; record: ElideRecord } + | { type: "line"; text: string; stderr: boolean } + | { type: "end"; exitCode: number | null; durationMs: number; aborted: boolean }; + +/** Structured stderr from native process tasks; stdin/stdout stay attached to VS Code's actual task terminal. */ +export class ElideTaskProgress implements vscode.Disposable { + private readonly view: TaskProgressView; + private readonly launches = new Map(); + private readonly executions = new Map(); + private readonly cancelled = new Set(); + private readonly sockets = new Set(); + private readonly subscriptions: vscode.Disposable[]; + private server: Server | undefined; + private listening: Promise | undefined; + private disposed = false; + + constructor(activity: ElideActivity, state: vscode.Memento, private readonly runnerPath: string) { + this.view = new TaskProgressView(activity, state); + this.subscriptions = [ + vscode.tasks.onDidStartTaskProcess(({ processId, execution }) => { + const process = execution.task.execution; + if (!(process instanceof vscode.ProcessExecution) || typeof process.args[2] !== "string" || !this.launches.has(process.args[2])) return; + this.executions.set(processId, execution); + if (this.cancelled.delete(processId)) execution.terminate(); + }), + vscode.tasks.onDidEndTaskProcess(({ execution }) => { + for (const [pid, candidate] of this.executions) { + if (candidate === execution) { + this.executions.delete(pid); + this.cancelled.delete(pid); + } + } + }), + ]; + } + + /** A fresh connection represents each run, including VS Code's Rerun Last Task action. */ + async execution(name: string, command: TaskCommand, onFailure?: (failure: TaskFailure) => void): Promise { + const port = await this.listen(); + const secret = randomBytes(32).toString("hex"); + // Keep launch metadata while tasks can be rerun; it contains no process output or timeline history. + this.launches.set(secret, { name, onFailure }); + const env = { ...command.env }; + const electronRunAsNode = env.ELECTRON_RUN_AS_NODE ?? process.env.ELECTRON_RUN_AS_NODE ?? null; + return new vscode.ProcessExecution(process.execPath, [ + this.runnerPath, String(port), secret, name, command.bin, ...command.argv, + ], { + cwd: command.cwd, + env: { ...env, ELECTRON_RUN_AS_NODE: "1", ELIDE_TASK_RUNNER_ELECTRON_RUN_AS_NODE: JSON.stringify(electronRunAsNode) }, + }); + } + + private listen(): Promise { + if (this.disposed) return Promise.reject(new Error("Elide task progress has been disposed.")); + if (this.listening) return this.listening; + const { promise, resolve, reject } = Promise.withResolvers(); + this.listening = promise; + const server = this.server = createServer((socket) => this.accept(socket)); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("Elide task progress could not open a local socket.")); + return; + } + resolve(address.port); + }); + return promise; + } + + private accept(socket: Socket): void { + this.sockets.add(socket); + let launch: Launch | undefined; + let operation: TimelineNode | undefined; + let observer: CommandObserver | undefined; + let pid: number | undefined; + let ended = false; + const report = new ElideFailureReport(); + const started = performance.now(); + const handshake = setTimeout(() => socket.destroy(), 5_000); + const cancel = () => { + if (pid === undefined || ended) return; + const execution = this.executions.get(pid); + if (execution) execution.terminate(); + else this.cancelled.add(pid); + }; + const finish = (outcome: { exitCode: number | null; durationMs: number; aborted: boolean }) => { + if (ended) return; + ended = true; + if (pid !== undefined) this.cancelled.delete(pid); + observer?.end(outcome); + operation?.end(outcome.aborted ? "cancelled" : outcome.exitCode === 0 ? "succeeded" : "failed"); + if (!outcome.aborted && outcome.exitCode !== 0) launch?.onFailure?.({ exitCode: outcome.exitCode ?? undefined, failure: report.failure }); + }; + const lines = new LineSplitter((line) => { + let message: RunnerMessage; + try { + const value: unknown = JSON.parse(line); + if (!value || typeof value !== "object" || !("type" in value)) { socket.destroy(); return; } + message = value as RunnerMessage; + } + catch { socket.destroy(); return; } + if (!launch) { + if (message.type !== "start" || typeof message.secret !== "string" || !(launch = this.launches.get(message.secret)) + || !Array.isArray(message.argv) || !message.argv.every((arg) => typeof arg === "string") + || typeof message.cwd !== "string" || !Number.isSafeInteger(message.pid) || message.pid <= 0) { socket.destroy(); return; } + clearTimeout(handshake); + pid = message.pid; + operation = this.view.start(`Task ${launch.name}`, { detail: message.cwd, cancel }); + observer = observeCommands(() => operation!)(message.argv, message.cwd); + void this.view.reveal().catch((error: unknown) => operation?.log(`Could not show Task Progress: ${error instanceof Error ? error.message : String(error)}`)); + return; + } + if (ended) return; + switch (message.type) { + case "record": + report.record(message.record); + observer?.record?.(message.record); + break; + case "line": { + const text = stripVTControlCharacters(message.text); + report.line(text); + observer?.line?.(text, message.stderr); + break; + } + case "end": + finish(message); + break; + default: + socket.destroy(); + } + }); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => lines.push(chunk)); + socket.on("error", () => { /* The close event settles a connected task, including extension shutdown. */ }); + socket.on("close", () => { + clearTimeout(handshake); + this.sockets.delete(socket); + finish({ exitCode: null, durationMs: performance.now() - started, aborted: true }); + }); + } + + dispose(): void { + this.disposed = true; + for (const execution of this.executions.values()) execution.terminate(); + for (const socket of this.sockets) socket.destroy(); + this.server?.close(); + for (const subscription of this.subscriptions) subscription.dispose(); + this.executions.clear(); + this.cancelled.clear(); + this.launches.clear(); + this.view.dispose(); + } +} diff --git a/packages/vscode/src/taskProgressView.ts b/packages/vscode/src/taskProgressView.ts new file mode 100644 index 0000000..02d4482 --- /dev/null +++ b/packages/vscode/src/taskProgressView.ts @@ -0,0 +1,82 @@ +import type { TimelineNode } from "@elide/ide-core"; +import * as vscode from "vscode"; +import type { ElideActivity } from "./activity.js"; + +export const TASK_PROGRESS_VIEW_ID = "elide.taskProgress"; +const PLACED_STATE_KEY = "elide.taskProgress.terminalPlacement"; + +/** A task-only projection of Activity's live history, beside the native task terminal. */ +export class TaskProgressView implements vscode.TreeDataProvider, vscode.Disposable { + private readonly tasks = new WeakSet(); + private readonly expanded = new WeakSet(); + private readonly tree: vscode.TreeView; + private readonly subscriptions: vscode.Disposable[]; + private disposed = false; + readonly onDidChangeTreeData: vscode.Event; + private placement: Promise | undefined; + + constructor(private readonly activity: ElideActivity, private readonly state: vscode.Memento) { + this.onDidChangeTreeData = activity.onDidChangeTreeData; + this.tree = vscode.window.createTreeView(TASK_PROGRESS_VIEW_ID, { treeDataProvider: this }); + this.subscriptions = [ + this.tree, + activity.onDidChangeOperation((operation) => this.expandSteps(operation)), + vscode.commands.registerCommand("elide.showTaskProgress", () => this.reveal()), + ]; + } + + /** Place on first use, not during activation or task discovery, which must not open a terminal. */ + get ready(): Promise { + return this.placement ??= this.place(this.state).catch((error: unknown) => { + if (!this.disposed) void vscode.window.showErrorMessage(`Elide: could not place Task Progress beside the terminal: ${error instanceof Error ? error.message : String(error)}`); + throw error; + }); + } + + start(label: string, options: { detail?: string; cancel?: () => void } = {}): TimelineNode { + const operation = this.activity.start(label, options); + this.tasks.add(operation); + return operation; + } + + getChildren(node?: TimelineNode): TimelineNode[] { + return node ? this.activity.getChildren(node) : this.activity.getChildren().filter((operation) => this.tasks.has(operation)); + } + + getTreeItem(node: TimelineNode): vscode.TreeItem { + return this.activity.getTreeItem(node); + } + + getParent(node: TimelineNode): TimelineNode | undefined { + return this.activity.getParent(node); + } + + /** Open the user's current location of the view without taking keyboard focus from the terminal. */ + async reveal(): Promise { + await this.ready; + if (!this.disposed) await vscode.commands.executeCommand(`${TASK_PROGRESS_VIEW_ID}.open`, { preserveFocus: true }); + } + + /** Expand once when the first reported task arrives, even if its CLI row was initially a leaf. */ + private expandSteps(operation: TimelineNode): void { + if (!this.tasks.has(operation) || this.expanded.has(operation)) return; + const command = operation.children.find((child) => child.children.length > 0); + if (!command) return; + this.expanded.add(operation); + void this.ready.then(async () => { + if (!this.disposed) await this.tree.reveal(command, { expand: true, select: false, focus: false }); + }).catch((error: unknown) => operation.log(`Could not expand task steps: ${error instanceof Error ? error.message : String(error)}`)); + } + + private async place(state: vscode.Memento): Promise { + if (state.get(PLACED_STATE_KEY, false)) return; + // Contributed views cannot target the built-in Terminal container directly. Never undo later user moves. + await vscode.commands.executeCommand("vscode.moveViews", { viewIds: [TASK_PROGRESS_VIEW_ID], destinationId: "terminal" }); + await state.update(PLACED_STATE_KEY, true); + } + + dispose(): void { + this.disposed = true; + for (const subscription of this.subscriptions) subscription.dispose(); + } +} diff --git a/packages/vscode/src/taskRunner.ts b/packages/vscode/src/taskRunner.ts new file mode 100644 index 0000000..74edd09 --- /dev/null +++ b/packages/vscode/src/taskRunner.ts @@ -0,0 +1,418 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { Socket } from "node:net"; +import { constants } from "node:os"; +import { stripVTControlCharacters } from "node:util"; +import { parseElideRecord, renderElideRecord, type ElideRecord } from "@elide/ide-core"; + +const PREVIOUS_NODE_MODE = "ELIDE_TASK_RUNNER_ELECTRON_RUN_AS_NODE"; +const CONNECT_TIMEOUT_MS = 5_000; +const TERMINATION_GRACE_MS = 5_000; +const FLUSH_TIMEOUT_MS = 1_000; + +type ProgressMessage = + | { type: "start"; secret: string; label: string; argv: string[]; cwd: string; pid: number } + | { type: "record"; record: ElideRecord } + | { type: "line"; text: string; stderr: boolean } + | { type: "end"; exitCode: number | null; durationMs: number; aborted: boolean }; + +/** Only JSON candidates wait for a newline; ordinary stderr, including prompts, is displayed immediately. */ +class TaskStderr { + private pending = ""; + private mode: "start" | "record" | "text" = "start"; + + constructor( + private readonly terminal: (text: string) => void, + private readonly send: (message: ProgressMessage) => void, + private readonly color: boolean, + ) {} + + push(chunk: string): void { + let start = 0; + while (start < chunk.length) { + const newline = chunk.indexOf("\n", start); + const part = chunk.slice(start, newline < 0 ? undefined : newline); + if (this.mode === "start" && part.length > 0) this.mode = part.startsWith("{") ? "record" : "text"; + this.pending += part; + if (this.mode === "text") this.terminal(part); + if (newline < 0) return; + this.complete(true); + start = newline + 1; + } + } + + flush(): void { + if (this.pending.length > 0) this.complete(false); + } + + private complete(newline: boolean): void { + const text = this.pending.endsWith("\r") ? this.pending.slice(0, -1) : this.pending; + const record = this.mode === "record" ? parseElideRecord(text) : undefined; + if (record) { + this.send({ type: "record", record }); + const style = this.color ? recordStyle(record) : undefined; + for (const line of renderElideRecord(record)) { + this.terminal(`${style && line ? `\x1b[${style}m${line}\x1b[0m` : line}\n`); + this.line(line); + } + } else { + if (this.mode !== "text") this.terminal(this.pending); + if (newline) this.terminal("\n"); + this.line(text); + } + this.pending = ""; + this.mode = "start"; + } + + private line(text: string): void { + this.send({ type: "line", text: stripVTControlCharacters(text).replace(/\r/g, ""), stderr: true }); + } +} + +function recordStyle(record: ElideRecord): string | undefined { + if (record.type === "task-finished") return record.outcome === "success" ? "32" : record.outcome === "failure" ? "31" : "2"; + if (record.type === "build-finished") return record.outcome === "success" ? "32" : "31"; + if (record.type === "message") return record.level === "error" ? "31" : record.level === "warning" ? "33" : record.level === "hint" ? "36" : undefined; + return undefined; +} + +interface ProcessInfo { + pid: number; + parent: number; + group: number; + born: string; +} + +/** `detached` would take the CLI out of the terminal's foreground group and break interactive input. */ +class TaskProcessTree { + private known = new Map(); + private ownedGroup = false; + private groupSignalled = false; + private stopping: NodeJS.Signals | undefined; + private forced = false; + private stopped = false; + private warned = false; + private readonly signalled = new Set(); + private refreshing: Promise | undefined; + + constructor( + private readonly child: ChildProcess, + private readonly env: NodeJS.ProcessEnv, + private readonly warn: (text: string) => void, + ) {} + + terminate(signal: NodeJS.Signals): void { + this.stopping = signal; + // Enumerate before signalling the launcher so its JVMs can still be discovered and tracked after reparenting. + void this.refresh(); + } + + async force(): Promise { + this.forced = true; + await this.refresh(); + } + + dispose(): void { + this.stopped = true; + } + + async waitForExit(): Promise { + while (!this.stopped) { + await this.refresh(); + if (this.known.size === 0) return; + const { promise, resolve } = Promise.withResolvers(); + setTimeout(resolve, process.platform === "win32" ? 500 : 100); + await promise; + } + } + + private signalGroup(): void { + if (!this.ownedGroup || this.groupSignalled || !this.stopping) return; + this.groupSignalled = true; + // The runner handles this signal itself. Never signal somebody else's foreground group. + signalProcess(-process.pid, this.stopping); + } + + private refresh(): Promise { + if (this.refreshing) return this.refreshing; + this.refreshing = processSnapshot(this.env).then((rows) => { + if (this.stopped) return; + this.ownedGroup = rows.some((row) => row.pid === process.pid && row.group === process.pid); + const children = new Map(); + for (const row of rows) { + const siblings = children.get(row.parent); + if (siblings) siblings.push(row); + else children.set(row.parent, [row]); + } + const pending = rows.filter((row) => row.pid !== process.pid && ( + (row.pid === this.child.pid && this.child.exitCode === null && this.child.signalCode === null) + || this.known.get(row.pid) === row.born + || (this.ownedGroup && row.group === process.pid) + )); + const descendants = new Map(); + for (let at = 0; at < pending.length; at++) { + const row = pending[at]!; + if (row.pid === process.pid || descendants.has(row.pid)) continue; + descendants.set(row.pid, row); + pending.push(...(children.get(row.pid) ?? [])); + } + this.known = new Map([...descendants.values()].map((row) => [row.pid, row.born])); + this.signalGroup(); + if (!this.stopping) return; + // Descendants first: the launcher must not disappear before its JVM is signalled. + for (const row of [...descendants.values()].reverse()) { + const identity = `${row.pid}:${row.born}`; + if (!this.forced && this.signalled.has(identity)) continue; + this.signalled.add(identity); + if (process.platform === "win32" && !this.forced) this.windowsTerminate(row.pid); + else signalProcess(row.pid, this.forced ? "SIGKILL" : this.stopping); + } + }).catch((error: unknown) => { + if (!this.stopping || this.stopped) return; + if (!this.warned) { + this.warned = true; + this.warn(`Could not inspect the task process tree: ${errorText(error)}`); + } + if (this.child.pid !== undefined) signalProcess(this.child.pid, this.forced ? "SIGKILL" : this.stopping); + }).finally(() => { this.refreshing = undefined; }); + return this.refreshing; + } + + private windowsTerminate(pid: number): void { + const killer = spawn("taskkill", ["/pid", String(pid), "/t"], { + env: this.env, + stdio: "ignore", + windowsHide: true, + }); + const timer = setTimeout(() => killer.kill(), FLUSH_TIMEOUT_MS); + killer.once("error", () => signalProcess(pid, "SIGTERM")); + killer.once("close", (code) => { + clearTimeout(timer); + if (code !== 0) signalProcess(pid, "SIGTERM"); + }); + } +} + +function processSnapshot(env: NodeJS.ProcessEnv): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const windows = process.platform === "win32"; + const ps = windows + ? spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_Process | ForEach-Object { '{0} {1} 0 - {2}' -f $_.ProcessId, $_.ParentProcessId, $_.CreationDate.Ticks }"], + { env, stdio: ["ignore", "pipe", "ignore"], windowsHide: true }) + : spawn("/bin/ps", ["-A", "-o", "pid=,ppid=,pgid=,stat=,lstart="], { env, stdio: ["ignore", "pipe", "ignore"] }); + let output = ""; + const timer = setTimeout(() => { + ps.kill("SIGKILL"); + reject(new Error("process enumeration timed out")); + }, windows ? CONNECT_TIMEOUT_MS : FLUSH_TIMEOUT_MS); + ps.stdout.setEncoding("utf8").on("data", (text: string) => { output += text; }); + ps.once("error", (error) => { clearTimeout(timer); reject(error); }); + ps.once("close", (code) => { + clearTimeout(timer); + if (code !== 0) return reject(new Error(`process enumeration exited with code ${code}`)); + const rows: ProcessInfo[] = []; + for (const line of output.split("\n")) { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/.exec(line); + if (match && !match[4]!.startsWith("Z") && Number(match[1]) !== ps.pid) { + rows.push({ pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3]), born: match[5]! }); + } + } + resolve(rows); + }); + return promise; +} + +function signalProcess(pid: number, signal: NodeJS.Signals): void { + try { process.kill(pid, signal); } catch { /* Already exited. */ } +} + +function errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function childEnvironment(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, ELIDE_PROGRESS: "json" }; + const previous: unknown = JSON.parse(env[PREVIOUS_NODE_MODE] ?? "null"); + if (previous === null) delete env.ELECTRON_RUN_AS_NODE; + else if (typeof previous === "string") env.ELECTRON_RUN_AS_NODE = previous; + else throw new Error("Invalid saved ELECTRON_RUN_AS_NODE value"); + delete env[PREVIOUS_NODE_MODE]; + return env; +} + +async function run(): Promise { + const [portText, secret, label, bin, ...argv] = process.argv.slice(2); + const port = Number(portText); + if (!Number.isInteger(port) || port < 1 || port > 65_535 || !secret || label === undefined || !bin) { + throw new Error("Invalid Elide task runner arguments"); + } + const env = childEnvironment(); + const startedAt = performance.now(); + const socket = new Socket(); + socket.setNoDelay(true); + socket.resume(); + let connected = false; + let ending = false; + let transportLost = false; + let terminalBroken = false; + let terminalBlocked = false; + let protocolBlocked = false; + let aborted = false; + let cancellationSignal: NodeJS.Signals | undefined; + let child: ChildProcess | undefined; + let tree: TaskProcessTree | undefined; + let terminateTimer: NodeJS.Timeout | undefined; + let closeTimer: NodeJS.Timeout | undefined; + let settleChild: ((code: number | null, signal: NodeJS.Signals | null, force?: boolean) => void) | undefined; + let exitCode: number | null = null; + let taskExitCode = 1; + + const updateFlow = () => { + if (aborted || (!terminalBlocked && !protocolBlocked)) child?.stderr?.resume(); + else child?.stderr?.pause(); + }; + const terminal = (text: string) => { + if (!text || terminalBroken) return; + if (!process.stderr.write(text)) { terminalBlocked = true; updateFlow(); } + }; + const send = (message: ProgressMessage) => { + if (!connected || ending || transportLost || socket.destroyed) return; + if (!socket.write(`${JSON.stringify(message)}\n`)) { protocolBlocked = true; updateFlow(); } + }; + const report = (text: string) => { + const line = `Elide: ${text}`; + terminal(`${line}\n`); + send({ type: "line", text: stripVTControlCharacters(line), stderr: true }); + }; + const cancel = (signal: NodeJS.Signals) => { + if (aborted || ending) return; + aborted = true; + cancellationSignal = signal; + updateFlow(); + if (!connected) { socket.destroy(new Error("Task cancelled")); return; } + if (!tree) return; + tree.terminate(signal); + terminateTimer = setTimeout(() => { + void tree?.force().finally(() => { + if (!settleChild) return; + closeTimer = setTimeout(() => { + child?.stderr?.destroy(); + child?.unref(); + settleChild?.(null, signal, true); + }, FLUSH_TIMEOUT_MS); + }); + }, TERMINATION_GRACE_MS); + }; + const lostConnection = (error?: Error) => { + if (!connected || ending || transportLost) return; + transportLost = true; + report(`Task progress connection lost${error ? `: ${error.message}` : "."}`); + cancel("SIGTERM"); + }; + socket.on("error", lostConnection); + socket.on("close", () => lostConnection()); + const onTerminalDrain = () => { terminalBlocked = false; updateFlow(); }; + const onTerminalError = () => { terminalBroken = true; cancel("SIGTERM"); }; + process.stderr.on("drain", onTerminalDrain); + process.stderr.on("error", onTerminalError); + socket.on("drain", () => { protocolBlocked = false; updateFlow(); }); + const signals: NodeJS.Signals[] = process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGBREAK"] : ["SIGINT", "SIGTERM", "SIGHUP"]; + const handlers = signals.map((signal) => () => cancel(signal)); + signals.forEach((signal, index) => process.on(signal, handlers[index]!)); + + try { + const connection = Promise.withResolvers(); + const timeout = setTimeout(() => socket.destroy(new Error("Task progress connection timed out")), CONNECT_TIMEOUT_MS); + const fail = (error: Error) => { clearTimeout(timeout); connection.reject(error); }; + socket.once("error", fail); + socket.connect(port, "127.0.0.1", () => { + const start: ProgressMessage = { type: "start", secret, label, argv, cwd: process.cwd(), pid: process.pid }; + socket.write(`${JSON.stringify(start)}\n`, (error) => { + clearTimeout(timeout); + socket.off("error", fail); + if (error) return connection.reject(error); + connected = true; + connection.resolve(); + }); + }); + await connection.promise; + if (!aborted && !transportLost) { + let noColor = env.NO_COLOR !== undefined; + let forceColor = env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== "" && env.FORCE_COLOR !== "0"; + for (const argument of argv) { + if (argument === "--") break; + if (argument === "--no-color") noColor = true; + else if (argument === "--color") forceColor = true; + } + const color = !noColor && (forceColor || (env.FORCE_COLOR !== "0" && env.TERM !== "dumb" && Boolean(process.stderr.isTTY || process.stdout.isTTY))); + const stderr = new TaskStderr(terminal, send, color); + const header = `$ elide${argv.length ? ` ${argv.join(" ")}` : ""}`; + terminal(color ? `\x1b[2m${header}\x1b[0m\n` : `${header}\n`); + // stdin and stdout are the actual task terminal, not pipes or a replacement pseudoterminal. + child = spawn(bin, argv, { cwd: process.cwd(), env, stdio: ["inherit", "inherit", "pipe"], windowsHide: false }); + tree = new TaskProcessTree(child, env, report); + const completion = Promise.withResolvers<{ code: number | null; signal: NodeJS.Signals | null }>(); + let settled = false; + let waitingForTree = false; + let startError: number | undefined; + const complete = (code: number | null, signal: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + settleChild = undefined; + clearTimeout(terminateTimer); + clearTimeout(closeTimer); + tree?.dispose(); + stderr.flush(); + completion.resolve({ code: startError ?? code, signal }); + }; + settleChild = (code, signal, force = false) => { + if (settled) return; + if (!aborted || force) return complete(code, signal); + if (waitingForTree) return; + waitingForTree = true; + void tree!.waitForExit().then(() => complete(code, signal)); + }; + child.stderr!.setEncoding("utf8").on("data", (text: string) => stderr.push(text)); + child.once("error", (error: NodeJS.ErrnoException) => { + startError = error.code === "ENOENT" ? 127 : error.code === "EACCES" ? 126 : 1; + report(`Could not start ${bin}: ${error.message}`); + }); + child.once("close", (code, signal) => { + // Terminal interrupts can reach the CLI before the runner; crash signals are failures, not cancellations. + if (signal && !aborted && signals.includes(signal)) cancel(signal); + settleChild?.(code, signal); + }); + const result = await completion.promise; + exitCode = result.signal ? null : result.code; + taskExitCode = result.code ?? 128 + (constants.signals[cancellationSignal ?? result.signal ?? "SIGTERM"] ?? 15); + } + } catch (error: unknown) { + if (!aborted) report(`Could not run task: ${errorText(error)}`); + exitCode = aborted ? null : 1; + } finally { + clearTimeout(terminateTimer); + clearTimeout(closeTimer); + tree?.dispose(); + ending = true; + if (connected && !transportLost && !socket.destroyed) { + const end: ProgressMessage = { type: "end", exitCode, durationMs: performance.now() - startedAt, aborted }; + const flushed = Promise.withResolvers(); + const timeout = setTimeout(finish, FLUSH_TIMEOUT_MS); + function finish() { clearTimeout(timeout); socket.destroy(); flushed.resolve(); } + socket.once("error", finish); + socket.once("close", finish); + socket.end(`${JSON.stringify(end)}\n`, finish); + await flushed.promise; + } else socket.destroy(); + signals.forEach((signal, index) => process.off(signal, handlers[index]!)); + process.stderr.off("drain", onTerminalDrain); + // Keep the error listener through pending terminal writes so a closed terminal cannot crash cleanup. + if (aborted && cancellationSignal) taskExitCode = 128 + (constants.signals[cancellationSignal] ?? 15); + process.exitCode = taskExitCode; + } +} + +void run().catch((error: unknown) => { + process.stderr.write(`Elide: ${errorText(error)}\n`); + process.exitCode = 1; +}); diff --git a/packages/vscode/src/tasks.ts b/packages/vscode/src/tasks.ts index 203e75e..9ef7b21 100644 --- a/packages/vscode/src/tasks.ts +++ b/packages/vscode/src/tasks.ts @@ -2,7 +2,6 @@ import path from "node:path"; import { ElideCli, ElideCommandFailedError, - ElideFailureReport, elideInvocationArgs, elideInvocationOptionsFrom, elideRecordOutput, @@ -63,7 +62,7 @@ export async function executeElideTask( presentation?: vscode.TaskPresentationOptions, ): Promise { const { definition, label } = taskDefinition(project, command, invocation); - const task = new ElideTaskProvider(workspace).resolveTask(new vscode.Task(definition, project.folder, label, ELIDE_TASK_TYPE)); + const task = await new ElideTaskProvider(workspace).resolveTask(new vscode.Task(definition, project.folder, label, ELIDE_TASK_TYPE)); if (!task) return undefined; if (presentation) task.presentationOptions = { ...task.presentationOptions, ...presentation }; return await vscode.tasks.executeTask(task); @@ -195,26 +194,26 @@ export async function executeProgramTask(project: ElideProject, label: string, p export class ElideTaskProvider implements vscode.TaskProvider { constructor(private readonly workspace: ElideWorkspace) {} - provideTasks(): vscode.Task[] { + async provideTasks(): Promise { const tasks: vscode.Task[] = []; for (const project of this.workspace.projects) { const rel = path.relative(project.folder.uri.fsPath, project.root) || undefined; const base = (command: ElideCommand, args: string[] = []): ElideTaskDefinition => ({ type: ELIDE_TASK_TYPE, command, ...(args.length ? { args } : {}), ...(rel ? { project: rel } : {}) }); - const build = this.createTask(project, base("build")); + const build = await this.createTask(project, base("build")); build.group = vscode.TaskGroup.Build; tasks.push(build); - const test = this.createTask(project, base("test")); + const test = await this.createTask(project, base("test")); test.group = vscode.TaskGroup.Test; tasks.push(test); - tasks.push(this.createTask(project, base("install"))); + tasks.push(await this.createTask(project, base("install"))); for (const ep of project.model?.entrypoints ?? []) { - tasks.push(this.createTask(project, base("run", entrypointArgs(ep)), entrypointLabel(ep))); + tasks.push(await this.createTask(project, base("run", entrypointArgs(ep)), entrypointLabel(ep))); } } return tasks; } - resolveTask(task: vscode.Task): vscode.Task | undefined { + async resolveTask(task: vscode.Task): Promise { const def = task.definition as Partial; if (def.type !== ELIDE_TASK_TYPE || !def.command) return undefined; const project = this.projectForDefinition(def, task.scope); @@ -233,19 +232,20 @@ export class ElideTaskProvider implements vscode.TaskProvider { return candidates[0] ?? { root: folder.uri.fsPath, manifestPath: path.join(folder.uri.fsPath, "elide.pkl"), folder }; } - /** - * The task a definition stands for. With `onFailure` the extension runs the process itself, in a pseudoterminal, - * and reports a failure with what Elide printed; a process task's output reaches only its terminal. - */ - createTask(project: ElideProject, def: ElideTaskDefinition, label?: string, name?: string, onFailure?: (failed: ElideTaskFailure) => void): vscode.Task { + /** Native task terminal plus a structured progress stream; failure handlers receive Elide's error and hint. */ + async createTask(project: ElideProject, def: ElideTaskDefinition, label?: string, name?: string, onFailure?: (failed: ElideTaskFailure) => void): Promise { const rel = path.relative(project.folder.uri.fsPath, project.root); const suffix = rel ? ` (${rel})` : ""; const taskName = name ?? `${label ?? [def.command, ...(def.args ?? [])].join(" ")}${suffix}`; const elide = taskProcess(project, def); - const execution = onFailure ? reportedExecution(taskName, elide, onFailure) - : new vscode.ProcessExecution(elide.dist.bin, elide.argv, { cwd: elide.cwd, ...(elide.env ? { env: elide.env } : {}) }); + // Keep the terminal's ANSI colors and explicit task/environment opt-outs. + const env = { ...elide.env }; + if (process.env.NO_COLOR === undefined && env.NO_COLOR === undefined) env.FORCE_COLOR ??= process.env.FORCE_COLOR ?? "1"; + elide.env = env; + const execution = await this.workspace.taskProgress.execution(taskName, { ...elide, bin: elide.dist.bin }, onFailure + ? (failed) => onFailure({ ...failed, name: taskName, reveal: () => revealTerminal(taskName) }) : undefined); const task = new vscode.Task(def, project.folder, taskName, ELIDE_TASK_TYPE, execution, ["$elide"]); - task.presentationOptions = { reveal: vscode.TaskRevealKind.Always, panel: vscode.TaskPanelKind.Shared, clear: true }; + task.presentationOptions = { reveal: vscode.TaskRevealKind.Always, panel: vscode.TaskPanelKind.Shared, clear: true, echo: false }; return task; } } @@ -261,12 +261,7 @@ export interface ElideTaskFailure { reveal(): void; } -/** - * Run one `elide` task like {@link executeElideTask}, reporting a failure to `onFailure` with Elide's error and hint. - * Its terminal shows Elide's plain-text progress (`ELIDE_PROGRESS=plain`) — the form it prints without a terminal of - * its own — which the `$elide` problem matcher reads as before. VS Code ends it with `onDidEndTaskProcess` and the - * exit code, as it does a process task. - */ +/** Run one native task, reporting its failure with Elide's error and hint; progress is also shown beside Terminal. */ export async function executeReportedElideTask( workspace: ElideWorkspace, project: ElideProject, @@ -275,41 +270,7 @@ export async function executeReportedElideTask( onFailure: (failed: ElideTaskFailure) => void, ): Promise { const { definition, label } = taskDefinition(project, command, invocation); - return await vscode.tasks.executeTask(new ElideTaskProvider(workspace).createTask(project, definition, undefined, label, onFailure)); -} - -function reportedExecution(name: string, { dist, cwd, argv, env }: TaskProcess, onFailure: (failed: ElideTaskFailure) => void): vscode.CustomExecution { - return new vscode.CustomExecution(async () => { - const write = new vscode.EventEmitter(); - const close = new vscode.EventEmitter(); - const abort = new AbortController(); - const report = new ElideFailureReport(); - const run = async () => { - write.fire(`\x1b[2m$ elide ${argv.join(" ")}\x1b[0m\r\n`); - let exitCode: number | undefined = 0; - try { - await new ElideCli(dist, cwd).run(argv, { - signal: abort.signal, - env: { ELIDE_PROGRESS: "plain", ...env }, - onLine: (line) => { - report.line(line); - write.fire(`${line}\r\n`); - }, - }); - } catch (error) { - if (abort.signal.aborted) return; - exitCode = error instanceof ElideCommandFailedError ? error.exitCode ?? 1 : undefined; - if (exitCode === undefined) { - const message = error instanceof Error ? error.message : String(error); - report.line(`error: ${message}`); - write.fire(`\x1b[31mFailed to start elide: ${message}\x1b[0m\r\n`); - } - } - close.fire(exitCode ?? 1); - if (exitCode !== 0) onFailure({ name, exitCode, failure: report.failure, reveal: () => revealTerminal(name) }); - }; - return { onDidWrite: write.event, onDidClose: close.event, open: () => void run(), close: () => abort.abort() }; - }); + return await vscode.tasks.executeTask(await new ElideTaskProvider(workspace).createTask(project, definition, undefined, label, onFailure)); } /** The terminal a task ran in, which VS Code titles with the task's name; else the terminal panel. */ diff --git a/packages/vscode/test/androidFailures.test.ts b/packages/vscode/test/androidFailures.test.ts index 48eca46..fca3943 100644 --- a/packages/vscode/test/androidFailures.test.ts +++ b/packages/vscode/test/androidFailures.test.ts @@ -15,6 +15,9 @@ function run(script: string): { status: number | null; stderr: string } { const source = new URL("../src/", import.meta.url).pathname; const result = spawnSync(process.execPath, ["--eval", ` import assert from "node:assert/strict"; + import { spawn } from "node:child_process"; + import { once } from "node:events"; + import { TimelineNode } from "@elide/ide-core"; import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -29,16 +32,17 @@ function run(script: string): { status: number | null; stderr: string } { event = (listener) => { this.listeners.push(listener); return { dispose() {} }; }; fire(value) { for (const listener of this.listeners) listener(value); } } + const startedTask = new EventEmitter(), endedTask = new EventEmitter(); + class ProcessExecution { constructor(process, args, options) { Object.assign(this, {process,args,options}); } } const terminals = [{ name: "Task - build android-run-app", show: () => shown.push("task terminal") }]; mock.module("vscode", () => ({ EventEmitter, - CustomExecution: class { constructor(callback) { this.callback = callback; } }, - ProcessExecution: class {}, + ProcessExecution, Task: class { constructor(definition, scope, name, source, execution, matchers) { Object.assign(this, { definition, scope, name, source, execution, matchers }); } }, TaskRevealKind: { Always: 1 }, TaskPanelKind: { Shared: 1 }, Uri: { file: (fsPath) => ({ fsPath }) }, - tasks: { executeTask: async (task) => task }, + tasks: { executeTask: async (task) => task, onDidStartTaskProcess: startedTask.event, onDidEndTaskProcess: endedTask.event }, commands: { executeCommand: async (...args) => { commands.push(args); } }, workspace: { workspaceFolders: [{}] }, window: { @@ -51,6 +55,9 @@ function run(script: string): { status: number | null; stderr: string } { })); mock.module(source + "config.js", () => ({ configuredInvocation: () => ({}), readConfig: () => ({ home: globalThis.elideHome }) })); mock.module(source + "jetbrains.js", () => ({ isJetBrainsDebugger: (type) => type === "intellij_jvm", jetBrainsDebuggerType() {}, jetBrainsExtension() {}, notifyKotlinSetupPending() {}, pendingKotlinSetup: async () => undefined })); + mock.module(source + "taskProgressView.js", () => ({ + TaskProgressView: class { start(label) { return TimelineNode.root(label); } async reveal() {} dispose() {} }, + })); const ui = { lines: [], log(line) { this.lines.push(line); } }; const dirs = []; const scratch = (prefix) => { const dir = realpathSync(mkdtempSync(path.join(os.tmpdir(), prefix))); dirs.push(dir); return dir; }; @@ -62,9 +69,28 @@ function run(script: string): { status: number | null; stderr: string } { chmodSync(path.join(home, "bin", "elide"), 0o755); return home; }; + const progresses = []; + const makeWorkspace = async project => { + // Load only after VS Code and view mocks exist; the process/protocol service itself remains real. + const { ElideTaskProgress } = await import(source + "taskProgress.ts"); + const taskProgress = new ElideTaskProgress({}, {}, source + "taskRunner.ts"); + progresses.push(taskProgress); + return {projects:[project],taskProgress}; + }; + const runTask = async task => { + const native = task.execution; + const child = spawn(native.process, native.args, {cwd:native.options.cwd,env:{...process.env,...native.options.env},stdio:"ignore"}); + const execution = {task,terminate:() => child.kill("SIGTERM")}; + startedTask.fire({execution,processId:child.pid}); + const [code] = await once(child,"close"); + endedTask.fire({execution,exitCode:code}); + await immediate(); + return code; + }; try { ${script} } finally { + for (const progress of progresses) progress.dispose(); for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); } `], { cwd: new URL("..", import.meta.url).pathname, encoding: "utf8", timeout: 20_000 }); @@ -78,39 +104,22 @@ test("a failed Android task notifies with Elide's error and hint, and offers the const root = scratch("elide-app-"); const project = { root, manifestPath: path.join(root, "elide.pkl"), folder: { uri: { fsPath: root } }, model: { android: { targets: [{ name: "app", kind: "apk" }] } } }; - const workspace = { projects: [project] }; + const workspace = await makeWorkspace(project); // Loaded only once the VS Code mock is installed. const { executeAndroidTask } = await import(source + "android.ts"); const task = await executeAndroidTask(workspace, ui, project, { args: ["android-run-app"], options: { device: "emulator-5554" } }); - assert.equal(task.name, "build android-run-app"); - assert.deepEqual(task.definition, { type: "elide", command: "build", args: ["android-run-app"], options: { device: "emulator-5554" } }); - assert.deepEqual(task.matchers, ["$elide"], "the problem matcher still reads the terminal"); - const pty = await task.execution.callback(); - const written = []; - const closed = Promise.withResolvers(); - pty.onDidWrite((text) => written.push(text)); - pty.onDidClose((code) => closed.resolve(code)); pick = "Create Android Signing Keystore"; - pty.open(); - assert.equal(await closed.promise, 1, "the task ends with Elide's exit code"); - await immediate(); - const output = written.join(""); - assert.match(output, /\\$ elide build android-run-app --device=emulator-5554/); - assert.match(output, /error: Keystore \\/work\\/app\\/\\.dev\\/crypto\\/debug\\.keystore not found\\r\\n/); - assert.match(output, /progress=plain/, "the terminal shows Elide's plain progress"); - const message = "Elide: Keystore /work/app/.dev/crypto/debug.keystore not found — hint: Create one with: elide build android-keystore-app"; - assert.deepEqual(notices, [{ level: "error", message, actions: ["Create Android Signing Keystore", "Show Terminal"] }]); - assert.deepEqual(ui.lines, [message], "the Elide output channel gets the same line"); + const code = await runTask(task); + assert.equal(code, 1, "the task ends with Elide's exit code"); + assert.equal(notices[0].level,"error"); + assert.ok(notices[0].message.includes("/work/app/.dev/crypto/debug.keystore")); + assert.ok(notices[0].message.includes("elide build android-keystore-app")); + assert.deepEqual(notices[0].actions, ["Create Android Signing Keystore", "Show Terminal"]); assert.deepEqual(commands, [["elide.androidCreateKeystore", { root, artifact: "app" }]]); // Show Terminal reveals the task's own terminal, which VS Code titles with the task's name. pick = "Show Terminal"; - const again = await (await executeAndroidTask(workspace, ui, project, { args: ["android-run-app"] })).execution.callback(); - const ended = Promise.withResolvers(); - again.onDidClose(() => ended.resolve()); - again.open(); - await ended.promise; - await immediate(); + await runTask(await executeAndroidTask(workspace, ui, project, { args: ["android-run-app"] })); assert.deepEqual(shown, ["task terminal"]); `)).toEqual({ status: 0, stderr: "" }); }); @@ -127,14 +136,9 @@ test("a Run whose install Android refuses for another signing key offers to unin const { executeAndroidTask } = await import(source + "android.ts"); const { androidFailureActions } = await import(source + "failures.ts"); const reinstalled = []; - const task = await executeAndroidTask({ projects: [project] }, ui, project, { args: ["android-run-app"] }, async (application) => { reinstalled.push(application); }); - const pty = await task.execution.callback(); - const closed = Promise.withResolvers(); - pty.onDidClose(() => closed.resolve()); + const task = await executeAndroidTask(await makeWorkspace(project), ui, project, { args: ["android-run-app"] }, async (application) => { reinstalled.push(application); }); pick = "Uninstall and Retry"; - pty.open(); - await closed.promise; - await immediate(); + await runTask(task); assert.deepEqual(notices.at(-1).actions, ["Uninstall and Retry", "Show Terminal"]); assert.deepEqual(reinstalled, ["sample.app"], "the action uninstalls the application the failure names"); assert.deepEqual(commands, []); diff --git a/packages/vscode/test/taskProgress.test.ts b/packages/vscode/test/taskProgress.test.ts new file mode 100644 index 0000000..129b251 --- /dev/null +++ b/packages/vscode/test/taskProgress.test.ts @@ -0,0 +1,85 @@ +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; + +// Isolate VS Code module mocks from Bun's shared module registry, as the other lifecycle tests do. +test("task progress isolates concurrent runs, reruns, cancellation, and unrelated task processes", () => { + const source = new URL("../src/", import.meta.url).pathname; + const result = spawnSync(process.execPath, ["--eval", ` + import assert from "node:assert/strict"; + import { once, EventEmitter } from "node:events"; + import { connect } from "node:net"; + import { mock } from "bun:test"; + import { TimelineNode } from "@elide/ide-core"; + const source = ${JSON.stringify(source)}; + const events = new EventEmitter(); + const starts = [], ends = [], roots = [], cancels = []; + class ProcessExecution { constructor(process, args, options) { Object.assign(this, {process,args,options}); } } + mock.module("vscode", () => ({ + ProcessExecution, + tasks: { + onDidStartTaskProcess: fn => { starts.push(fn); return {dispose() {}}; }, + onDidEndTaskProcess: fn => { ends.push(fn); return {dispose() {}}; }, + }, + })); + mock.module(source + "taskProgressView.js", () => ({ + TaskProgressView: class { + start(label, options) { + const root = TimelineNode.root(label, {onChange: () => events.emit("changed")}); + roots.push(root); cancels.push(options.cancel); events.emit("started"); return root; + } + async reveal() {} + dispose() {} + }, + })); + // Static import would load vscode before its isolated runtime mocks have been installed. + const { ElideTaskProgress } = await import(source + "taskProgress.ts"); + const progress = new ElideTaskProgress({}, {}, source + "taskRunner.ts"); + const failures = []; + const process = await progress.execution("build app", {bin:"/elide",cwd:"/project",argv:["build","app"]}, failure => failures.push(failure)); + const port = Number(process.args[1]), secret = process.args[2]; + const unrelated = {task:{execution:new ProcessExecution("/other",[],{})},terminate(){ throw new Error("Unrelated task was terminated"); }}; + starts.forEach(fn => fn({processId:90,execution:unrelated})); + const bad = connect(port,"127.0.0.1"); + await once(bad,"connect"); const rejected = once(bad,"close"); bad.write("null\\n"); await rejected; + const client = async pid => { + const socket = connect(port,"127.0.0.1"); await once(socket,"connect"); + const ready = once(events,"started"); + socket.write(JSON.stringify({type:"start",secret,label:"build app",argv:["build","app"],cwd:"/project",pid})+"\\n"); + await ready; return socket; + }; + const until = async predicate => { while(!predicate()) await once(events,"changed"); }; + let stoppedA=0, stoppedB=0; + const a = await client(101), b = await client(102); + const taskA = {task:{execution:process},terminate(){stoppedA++;}}; + const taskB = {task:{execution:process},terminate(){stoppedB++;}}; + // A click can arrive before VS Code delivers its process-start event. + cancels[0](); + starts.forEach(fn => fn({processId:102,execution:taskB})); + starts.forEach(fn => fn({processId:101,execution:taskA})); + assert.equal(stoppedA,1); assert.equal(stoppedB,0); + const cancelled = until(() => roots[0].state === "cancelled"); a.end(); await cancelled; + ends.forEach(fn => fn({execution:taskA})); + assert.equal(roots[1].state,"running"); + const finished = until(() => roots[1].state === "failed"); + b.end([ + {type:"record",record:{type:"task-started",task:"compile",message:"Compile app",time:0}}, + {type:"record",record:{type:"message",level:"error",text:"Compiler rejected app",time:1}}, + {type:"record",record:{type:"task-finished",task:"compile",message:"Compile app",outcome:"failure",durationMs:15,time:2}}, + {type:"end",exitCode:7,durationMs:20,aborted:false}, + ].map(value=>JSON.stringify(value)+"\\n").join("")); + await finished; + assert.deepEqual(failures,[{exitCode:7,failure:{errors:1,error:"Compiler rejected app"}}]); + assert.equal(roots[1].children[0].children[0].state,"failed"); + ends.forEach(fn => fn({execution:taskB})); + const rerun = await client(103); + const done = until(() => roots[2].state === "succeeded"); + rerun.end(JSON.stringify({type:"end",exitCode:0,durationMs:2,aborted:false})+"\\n"); await done; + assert.notEqual(roots[2],roots[1]); + assert.equal(roots[0].state,"cancelled"); assert.equal(roots[1].state,"failed"); + progress.dispose(); + assert.equal(stoppedB,0); assert.equal(stoppedA,1); + `], { encoding: "utf8", timeout: 15_000 }); + expect(result.error).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/packages/vscode/test/taskRunner.test.ts b/packages/vscode/test/taskRunner.test.ts new file mode 100644 index 0000000..a2a1d9d --- /dev/null +++ b/packages/vscode/test/taskRunner.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { stripVTControlCharacters } from "node:util"; +import { ElideFailureReport, LineSplitter, type ElideRecord } from "@elide/ide-core"; + +interface Message { + type: string; + secret?: string; + pid?: number; + record?: ElideRecord; + exitCode?: number | null; + aborted?: boolean; +} + +async function run(script: string, options: { env?: NodeJS.ProcessEnv; argv?: string[]; cancel?: boolean } = {}) { + const root = mkdtempSync(path.join(tmpdir(), "elide-task-runner-")); + const bin = path.join(root, "elide"); + writeFileSync(bin, `#!/bin/sh\n${script}\n`, { mode: 0o755 }); + const messages: Message[] = []; + const ready = Promise.withResolvers(); + const server = createServer((socket) => { + const lines = new LineSplitter((line) => { + const message = JSON.parse(line) as Message; + messages.push(message); + if (message.type === "record" && message.record?.type === "task-started") ready.resolve(); + }); + socket.setEncoding("utf8"); + socket.on("data", (chunk: string) => lines.push(chunk)); + socket.on("end", () => socket.end()); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Missing test endpoint"); + const child = spawn(process.execPath, [new URL("../src/taskRunner.ts", import.meta.url).pathname, + String(address.port), "test-secret", "build app", bin, ...(options.argv ?? ["build", "app"])], { + cwd: root, + env: { ...process.env, ...options.env, ELIDE_TASK_RUNNER_ELECTRON_RUN_AS_NODE: "null" }, + stdio: ["pipe", "pipe", "pipe"], + timeout: 15_000, + }); + const exit = once(child, "close"); + let stdout = "", stderr = ""; + child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; }); + child.stderr.setEncoding("utf8").on("data", (chunk: string) => { stderr += chunk; }); + child.stdin.end("typed input\n"); + try { + if (options.cancel) { + await Promise.race([ready.promise, exit.then(() => { throw new Error("Task exited before becoming cancellable"); })]); + child.kill("SIGTERM"); + } + const [code, signal] = await exit; + const closed = once(server, "close"); + server.close(); + await closed; + return { code, signal, stdout, stderr, messages }; + } finally { + child.kill("SIGKILL"); + if (server.listening) server.close(); + rmSync(root, { recursive: true, force: true }); + } +} + +test("native task output and stdin survive structured progress, and failure records keep their exit code", async () => { + const result = await run(` +read answer +printf 'program: %s\\n' "$answer" +printf '{"type":"task-started","task":"compile","message":"Compile app","time":0}\\n' >&2 +printf '{"type":"task-progress","task":"compile","message":"Compiling","progress":1,"total":2,"time":1}\\n' >&2 +printf '{"type":"message","level":"error","text":"Compiler rejected app","time":2}\\n' >&2 +printf '{"type":"task-finished","task":"compile","message":"Compile app","outcome":"failure","durationMs":20,"time":3}\\n' >&2 +exit 7`, { env: { NO_COLOR: undefined, FORCE_COLOR: "1" } }); + expect(result.code).toBe(7); + expect(result.stdout).toContain("program: typed input\n"); + expect(result.stderr).toContain("\x1b[31m"); + expect(stripVTControlCharacters(result.stderr)).toContain("error: Compiler rejected app"); + expect(result.stderr).not.toContain('{"type":'); + expect(result.messages[0]).toMatchObject({ type: "start", secret: "test-secret" }); + expect(result.messages.at(-1)).toMatchObject({ type: "end", exitCode: 7, aborted: false }); + const failure = new ElideFailureReport(); + for (const message of result.messages) if (message.record) failure.record(message.record); + expect(failure.failure).toEqual({ errors: 1, error: "Compiler rejected app" }); + expect(result.messages.filter((message) => message.record?.type === "task-progress").map((message) => message.record)).toEqual([ + { type: "task-progress", task: "compile", message: "Compiling", progress: 1, total: 2, time: 1 }, + ]); +}); + +test("a CLI killed by a crash signal fails the task instead of reporting a cancellation", async () => { + const result = await run("kill -ABRT $$"); + expect(result.code).toBe(134); + expect(result.messages.at(-1)).toMatchObject({ type: "end", exitCode: null, aborted: false }); +}); + +test("rendered terminal progress respects NO_COLOR", async () => { + const result = await run(`printf '{"type":"build-finished","outcome":"success","durationMs":20,"time":0}\\n' >&2`, { env: { NO_COLOR: "1", FORCE_COLOR: "1" } }); + expect(result.code).toBe(0); + expect(result.stderr).toBe(stripVTControlCharacters(result.stderr)); +}); + +test("CLI color opt-outs apply to progress but program arguments do not change CLI styling", async () => { + const script = `printf '{"type":"build-finished","outcome":"success","durationMs":20,"time":0}\\n' >&2`; + const env = { NO_COLOR: undefined, FORCE_COLOR: "1" }; + const plain = await run(script, { env, argv: ["build", "--no-color", "app"] }); + expect(plain.code).toBe(0); + expect(plain.stderr).toBe(stripVTControlCharacters(plain.stderr)); + const programFlag = await run(script, { env, argv: ["run", "app", "--", "--no-color"] }); + expect(programFlag.code).toBe(0); + expect(programFlag.stderr).toContain("\x1b[32m"); +}); + +test("cancelling a native task closes output pipes held by a stubborn descendant", async () => { + const result = await run(` +(trap '' TERM; sleep 60) & +printf '{"type":"task-started","task":"wait","message":"Waiting","time":0}\\n' >&2 +wait`, { cancel: true }); + expect(result.messages.at(-1)).toMatchObject({ type: "end", aborted: true }); + expect(result.code === 0 && result.signal === null).toBe(false); +}, 20_000);