From 27429c581b2909f5066621099beeba6425414805 Mon Sep 17 00:00:00 2001 From: Hatton Date: Thu, 12 Mar 2026 16:46:18 -0600 Subject: [PATCH 1/9] More agent and test control of Bloom.exe * an agent skill.md and scripts for starting, stopping, and connecting to Bloom.ex * api/common/instanceInfo now gives information like the path to the exe file so that agents and tests can check that they are talking to the right bloom (vs. one in another worktree entirely) Future: allow multiple Blooms to be running. e.g. Add startup arguments for HTTP port and CDP port. Bypass the single-instance token when given the ports. Update the helper scripts to launch Bloom with explicit ports. Make sure agents and tests kill the exact bloom they ran; they should be able to determine the pid. --- .../skills/bloom-exe-cdp-automation/SKILL.md | 175 ++++++++++ .../bloomProcessCommon.mjs | 305 ++++++++++++++++++ .../bloomProcessStatus.mjs | 116 +++++++ .../bloom-exe-cdp-automation/bloomRun.mjs | 41 +++ .../killBloomProcess.mjs | 65 ++++ .../webview2Targets.mjs | 226 +++++++++++++ .vscode/settings.json | 10 +- .vscode/tasks.json | 12 + .../bloom-exe-collection-topbar.uitest.ts | 28 ++ .../component-tests/bloom-exe-tabs.uitest.ts | 79 +++++ .../component-tester/bloomExeCdp.ts | 95 ++++++ .../playwright.bloom-exe.config.ts | 35 ++ src/BloomExe/WebView2Browser.cs | 14 +- src/BloomExe/web/controllers/CommonApi.cs | 26 ++ 14 files changed, 1223 insertions(+), 4 deletions(-) create mode 100644 .github/skills/bloom-exe-cdp-automation/SKILL.md create mode 100644 .github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs create mode 100644 .github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs create mode 100644 .github/skills/bloom-exe-cdp-automation/bloomRun.mjs create mode 100644 .github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs create mode 100644 .github/skills/bloom-exe-cdp-automation/webview2Targets.mjs create mode 100644 src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts create mode 100644 src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts create mode 100644 src/BloomBrowserUI/react_components/component-tester/playwright.bloom-exe.config.ts diff --git a/.github/skills/bloom-exe-cdp-automation/SKILL.md b/.github/skills/bloom-exe-cdp-automation/SKILL.md new file mode 100644 index 000000000000..6f0eca9d3b39 --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/SKILL.md @@ -0,0 +1,175 @@ +--- +name: bloom-exe-cdp-automation +description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE. +argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests" +user-invocable: true +--- + +# Bloom Exe CDP Automation + +## Outcome +Use the real embedded WebView2 inside Bloom.exe as the automation target. Determine whether Bloom is already running, whether it belongs to this worktree, stop the right processes when necessary, start the current worktree, discover the live CDP target, and drive the UI through the embedded browser instead of Bloom APIs. + +## When To Use +- You need to know whether Bloom is already running. +- You need to know whether the running Bloom came from the wrong worktree. +- You need to kill a confusing stale Bloom or `dotnet watch` parent process. +- You need to start Bloom from the current worktree. +- You need to attach to the embedded WebView2 for DOM, console, network, and screenshot/debug access. +- You need Playwright tests to hit the actual exe instead of `http://localhost:8089/bloom/CURRENTPAGE` in a separate browser tab. + +## Default Assumptions +- Current repo root is derived automatically from the script location. +- Bloom project path is `src/BloomExe/BloomExe.csproj`. +- Current-worktree launches normally start their HTTP server search at `http://localhost:8089` and use the next odd ports if needed. +- Running Bloom reports its actual HTTP and CDP ports through `http://localhost:/bloom/api/common/instanceInfo`. +- Bloom's embedded WebView2 currently exposes CDP on port `9222` in debug builds. + +## Commands + +Run all of these from `src/BloomBrowserUI` unless noted otherwise. + +Important: +- Agents using this skill MUST use the checked-in helper scripts below, not package.json aliases and not ad hoc `wmic` commands. +- In this workspace, assume the default terminal is bash unless you explicitly opened another shell. Do not use cmd-only syntax such as `cd /d D:\...` in bash. +- Do not run raw `wmic ...` commands from a bash terminal as part of this skill workflow. +- Do not redirect WMIC output to temp files from bash. +- The VS Code bash terminals in this workspace have shown bracketed-paste/shell-integration problems where ad hoc WMIC commands appear to hang or are injected incorrectly. The checked-in Node wrappers avoid that by calling WMIC directly without going through shell redirection. +- Only fall back to raw Windows commands if the checked-in wrappers themselves are broken and you are explicitly debugging them. If you do that, prefer `cmd /c` over bash redirection. + +### Status + +```bash +node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs +node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json +node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json +``` + +Reports Bloom.exe processes, detected repo roots, attributable `dotnet watch` parents, ambiguous watchers, and whether the workspace API and CDP endpoint are reachable. + +Use `--running-bloom` when the user explicitly wants the already-running Bloom instead of a worktree-owned instance. This scans Bloom's standard HTTP port range, asks any running Bloom for `common/instanceInfo`, and reports the ports that instance says it is using. +### Kill Bloom + +```bash +node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs +node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched +``` + +Use the plain form to stop all detected Bloom-related processes. Use `--only-mismatched` to stop only the Bloom instance that does not belong to the current worktree. + +Important: if Bloom was started with `dotnet watch run`, killing only `Bloom.exe` is not enough because the watcher will restart it. Prefer the provided kill script so the watcher and child process are both terminated. + +### Start Bloom + +```bash +node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs +node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch +``` + +Use `--watch` when you expect to iterate. These helpers compute the repo root and pass an absolute `--project` path to `dotnet`, which avoids ambiguous relative watcher command lines. + +### Discover the CDP target + +```bash +node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs +node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait +node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait +``` + +Use `--wait` after startup so the command blocks until the embedded browser target is available. + +## Core Workflow +1. Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json`. +2. If Bloom is not running, start it from the current worktree with `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch` or `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs`. +3. If Bloom is running from a different worktree, stop it with `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`. +4. If Bloom is running from the correct worktree and the task only needs browser automation, do not restart it. +5. Run `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait` to discover the live WebView2 target. +6. Attach a confirmed client to `http://localhost:9222`. +7. Manipulate the UI by clicking or typing in the attached browser context. Do not use Bloom API endpoints to simulate the user action itself. +8. Use browser-native inspection for DOM, console, and network. +9. If the task is test-related, run the exe-backed Playwright suite. + +## Running Bloom Workflow +Use this when the user says to reuse the already-running Bloom. + +1. Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json`. +2. If no running Bloom instance is reported, tell the user there is no running Bloom to reuse. +3. If one is reported, do not kill or restart it because of worktree mismatch. +4. Run `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait`. +5. Attach to the reported instance and work only against the ports it reported about itself. + +## Rules + +### Reuse the current worktree instance +- Reuse it. +- Attach over CDP and drive the UI directly. +- Do not restart unless the user explicitly wants a fresh run or you need to load new code. + +### Treat wrong-worktree Bloom as a blocker +- Treat that as a blocker because it produces extremely confusing results. +- Report the detected repo root from `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs`. +- Kill the mismatched process with `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`. +- Then start the current worktree. + +### Start with the helper, not raw watch commands +- Start it from the current worktree. +- Prefer `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch` if you expect to iterate. +- Prefer `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs` if you only need a single verification run. + +### Reuse the running Bloom when the user asks for it +- Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json`. +- Reuse the returned running Bloom instance even if it does not match the current worktree. +- Discover its CDP target with `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait`. +- Do not kill or restart it unless the user explicitly asks for that. + +### Prove browser-native access when needed +- Show the CDP target from `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait`. +- Attach with Playwright. +- Demonstrate reading `body.className`, the top-bar iframe, console messages, and the `workspace/selectTab` request. + +## Confirmed Path + +- `playwright` Node library via `chromium.connectOverCDP("http://localhost:9222")` +- `@playwright/test` runner via the exe-backed suite in `src/BloomBrowserUI/react_components/component-tester` + +Not confirmed here: +- `chrome-devtools-mcp` as an attached client to Bloom's existing WebView2 target +- the current Playwright MCP browser wrappers as an attached client to Bloom's existing WebView2 target + +Reason: the current MCP wrappers in this environment control their own browser instance and do not expose a way to attach to an already-running external CDP endpoint. Until those tools add explicit attach support, prefer the script plus Playwright path above. + +## Tests +- Run from `src/BloomBrowserUI/react_components/component-tester`. +- Use `yarn playwright test --config playwright.bloom-exe.config.ts`. +- Run one file with `yarn playwright test --config playwright.bloom-exe.config.ts ../TopBar/component-tests/bloom-exe-tabs.uitest.ts`. + +These tests attach to the real Bloom.exe target over CDP and verify tab switching plus console and network observation. + +## Notes +- Prefer the Node helpers over PowerShell. The Node scripts use `wmic`, `taskkill`, and `dotnet` directly because the PowerShell path proved too brittle. +- Prefer the checked-in Node helper commands over raw Windows shell commands. Subagents should normally run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json`, `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`, `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs`, and `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait`, not ad hoc `wmic` commands. +- When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. +- Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. +- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. + +## Completion Checks +- Bloom's status is known: not running, running from current worktree, or running from different worktree. +- Any mismatched Bloom instance has been stopped before running the current worktree. +- The CDP endpoint responds at `http://localhost:9222/json/version`. +- `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait` returns a real Bloom target. +- The automation client can read DOM state and interact with the embedded top bar. +- If tests were requested, the exe-backed Playwright suite passes. + +## Output Contract +Report: +- whether Bloom was already running +- which repo root the running Bloom came from +- whether you killed a mismatched or stale process +- which command you used to start Bloom +- which client attached successfully +- what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results + +## Example Prompts +- `Use bloom-exe-cdp-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.` +- `Use bloom-exe-cdp-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.` +- `Use bloom-exe-cdp-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.` diff --git a/.github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs b/.github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs new file mode 100644 index 000000000000..5acb1e697813 --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs @@ -0,0 +1,305 @@ +import { execFileSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const standardBloomStartingHttpPort = 8089; +const standardBloomPortIncrement = 2; +const standardBloomPortCount = 10; + +const toLocalOrigin = (port) => `http://localhost:${port}`; +const toBloomApiBaseUrl = (port) => `${toLocalOrigin(port)}/bloom/api`; +const toPositiveInteger = (value) => { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +}; + +export const getStandardBloomHttpPorts = () => + Array.from( + { length: standardBloomPortCount }, + (_, index) => + standardBloomStartingHttpPort + index * standardBloomPortIncrement, + ); + +export const getDefaultRepoRoot = () => + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + ); + +const normalizePath = (value) => { + if (!value) { + return undefined; + } + + const trimmed = value.trim().replace(/^"|"$/g, ""); + if (!trimmed) { + return undefined; + } + + return path.resolve(trimmed).replace(/\//g, "\\"); +}; + +export const extractRepoRoot = (text) => { + if (!text) { + return undefined; + } + + const normalized = text.replace(/\//g, "\\"); + + const projectMatch = normalized.match( + /([A-Za-z]:\\[^"\r\n]+?)\\src\\BloomExe\\BloomExe\.csproj/i, + ); + if (projectMatch?.[1]) { + return normalizePath(projectMatch[1]); + } + + const exeMatch = normalized.match( + /([A-Za-z]:\\[^"\r\n]+?)\\output\\[^"\r\n]+?\\Bloom\.exe/i, + ); + if (exeMatch?.[1]) { + return normalizePath(exeMatch[1]); + } + + return undefined; +}; + +const parseWmicList = (text) => { + const lines = text.replace(/\r/g, "").split("\n"); + const records = []; + let current = {}; + + const flush = () => { + if (Object.keys(current).length > 0) { + records.push(current); + current = {}; + } + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) { + flush(); + continue; + } + + const equalsIndex = trimmed.indexOf("="); + if (equalsIndex < 0) { + continue; + } + + const key = trimmed.slice(0, equalsIndex); + const value = trimmed.slice(equalsIndex + 1); + current[key] = value; + } + + flush(); + return records; +}; + +const queryProcessesByName = (name) => { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const output = execFileSync( + "wmic", + [ + "process", + "where", + `name='${name}'`, + "get", + "ProcessId,ParentProcessId,Name,ExecutablePath,CommandLine", + "/format:list", + ], + { + encoding: "utf8", + timeout: 5000, + windowsHide: true, + }, + ); + return parseWmicList(output); + } catch (error) { + if (attempt === 2) { + return []; + } + } + } + + return []; +}; + +export const getWindowsProcessSnapshot = () => { + const rawProcesses = [ + ...queryProcessesByName("Bloom.exe"), + ...queryProcessesByName("dotnet.exe"), + ] + .map((record) => ({ + processId: Number(record.ProcessId || 0), + parentProcessId: Number(record.ParentProcessId || 0), + name: record.Name, + executablePath: record.ExecutablePath || undefined, + commandLine: record.CommandLine || undefined, + })) + .filter((record) => record.processId > 0 && record.name); + + const byId = new Map( + rawProcesses.map((record) => [record.processId, record]), + ); + return { rawProcesses, byId }; +}; + +export const buildProcessChain = (processRecord, byId) => { + const chain = []; + let current = processRecord; + + for (let i = 0; i < 8 && current; i++) { + chain.push({ + processId: current.processId, + parentProcessId: current.parentProcessId, + name: current.name, + executablePath: current.executablePath, + commandLine: current.commandLine, + repoRoot: + extractRepoRoot(current.executablePath) || + extractRepoRoot(current.commandLine), + }); + + current = byId.get(current.parentProcessId); + } + + return chain; +}; + +export const classifyProcesses = (expectedRepoRoot) => { + const normalizedExpectedRepoRoot = normalizePath(expectedRepoRoot); + const { rawProcesses, byId } = getWindowsProcessSnapshot(); + + const toRecord = (processRecord) => { + const processChain = buildProcessChain(processRecord, byId); + const detectedRepoRoot = processChain.find( + (entry) => entry.repoRoot, + )?.repoRoot; + + return { + processId: processRecord.processId, + name: processRecord.name, + executablePath: processRecord.executablePath, + commandLine: processRecord.commandLine, + detectedRepoRoot, + matchesExpectedRepoRoot: + !!detectedRepoRoot && + !!normalizedExpectedRepoRoot && + detectedRepoRoot.toLowerCase() === + normalizedExpectedRepoRoot.toLowerCase(), + processChain, + }; + }; + + const bloomProcesses = rawProcesses + .filter((processRecord) => processRecord.name === "Bloom.exe") + .map(toRecord); + + const rawWatchProcesses = rawProcesses + .filter( + (processRecord) => + processRecord.name === "dotnet.exe" && + processRecord.commandLine?.includes("BloomExe.csproj") && + (processRecord.commandLine.includes("dotnet-watch.dll") || + processRecord.commandLine.includes("DOTNET_WATCH=1") || + processRecord.commandLine.includes(" watch run ")), + ) + .map(toRecord); + + const watchProcesses = rawWatchProcesses.filter( + (processRecord) => processRecord.detectedRepoRoot, + ); + const ambiguousWatchProcesses = rawWatchProcesses.filter( + (processRecord) => !processRecord.detectedRepoRoot, + ); + + return { + expectedRepoRoot: normalizedExpectedRepoRoot, + bloomProcesses, + watchProcesses, + ambiguousWatchProcesses, + }; +}; + +export const fetchJsonEndpoint = async (url) => { + try { + const response = await fetch(url); + const body = await response.text(); + return { + reachable: response.ok, + statusCode: response.status, + json: body ? JSON.parse(body) : undefined, + error: response.ok + ? undefined + : `${response.status} ${response.statusText}`, + }; + } catch (error) { + return { + reachable: false, + statusCode: undefined, + json: undefined, + error: error instanceof Error ? error.message : String(error), + }; + } +}; + +export const findRunningStandardBloomInstances = async () => { + const responses = await Promise.all( + getStandardBloomHttpPorts().map(async (port) => ({ + port, + instanceInfo: await fetchJsonEndpoint( + `${toBloomApiBaseUrl(port)}/common/instanceInfo`, + ), + })), + ); + + return responses + .filter( + ({ instanceInfo }) => instanceInfo.reachable && !!instanceInfo.json, + ) + .map(({ port, instanceInfo }) => { + const info = instanceInfo.json; + const httpPort = toPositiveInteger(info.httpPort) ?? port; + const cdpPort = toPositiveInteger(info.cdpPort); + + return { + ...info, + discoveredViaPort: port, + httpPort, + origin: toLocalOrigin(httpPort), + workspaceTabsUrl: + info.workspaceTabsUrl || + `${toBloomApiBaseUrl(httpPort)}/workspace/tabs`, + cdpPort, + cdpOrigin: + info.cdpOrigin || + (cdpPort ? toLocalOrigin(cdpPort) : undefined), + }; + }) + .sort((left, right) => left.httpPort - right.httpPort); +}; + +export const findRunningStandardBloomInstance = async () => { + const instances = await findRunningStandardBloomInstances(); + return instances[0]; +}; + +export const killProcessIds = (processIds) => { + const killed = []; + + for (const processId of processIds) { + try { + execFileSync("taskkill", ["/PID", String(processId), "/F"], { + encoding: "utf8", + stdio: "pipe", + }); + killed.push(processId); + } catch {} + } + + return killed; +}; diff --git a/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs b/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs new file mode 100644 index 000000000000..9ac248954ac4 --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs @@ -0,0 +1,116 @@ +import { + classifyProcesses, + fetchJsonEndpoint, + findRunningStandardBloomInstances, + getDefaultRepoRoot, +} from "./bloomProcessCommon.mjs"; + +const args = process.argv.slice(2); +const json = args.includes("--json"); +const runningBloom = args.includes("--running-bloom"); +const repoRootArgIndex = args.indexOf("--repo-root"); +const expectedRepoRoot = + repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); + +const processState = classifyProcesses(expectedRepoRoot); +const runningBloomInstances = runningBloom + ? await findRunningStandardBloomInstances() + : []; +const selectedRunningBloomInstance = runningBloom + ? runningBloomInstances[0] + : undefined; +const workspaceTabsUrl = selectedRunningBloomInstance + ? selectedRunningBloomInstance.workspaceTabsUrl + : "http://localhost:8089/bloom/api/workspace/tabs"; +const cdpVersionUrl = selectedRunningBloomInstance?.cdpOrigin + ? `${selectedRunningBloomInstance.cdpOrigin}/json/version` + : "http://localhost:9222/json/version"; +const workspaceTabs = await fetchJsonEndpoint(workspaceTabsUrl); +const cdpVersion = await fetchJsonEndpoint(cdpVersionUrl); + +const result = { + mode: runningBloom ? "running-bloom" : "current-worktree", + expectedRepoRoot: processState.expectedRepoRoot, + isRunning: processState.bloomProcesses.length > 0, + runningFromExpectedRepoRoot: processState.bloomProcesses.some( + (processRecord) => processRecord.matchesExpectedRepoRoot, + ), + runningFromDifferentRepoRoot: processState.bloomProcesses.some( + (processRecord) => !processRecord.matchesExpectedRepoRoot, + ), + bloomProcesses: processState.bloomProcesses, + watchProcesses: processState.watchProcesses, + ambiguousWatchProcesses: processState.ambiguousWatchProcesses, + runningBloomInstances, + selectedRunningBloomInstance, + endpoints: { + workspaceTabs, + cdpVersion, + }, +}; + +if (json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} + +console.log(`Expected repo root: ${result.expectedRepoRoot}`); +console.log(`Bloom running: ${result.isRunning}`); +if (runningBloom) { + console.log( + `Running Bloom instances found: ${result.runningBloomInstances.length}`, + ); + if (result.selectedRunningBloomInstance) { + console.log( + `Selected running Bloom HTTP port: ${result.selectedRunningBloomInstance.httpPort}`, + ); + console.log( + `Selected running Bloom executable: ${ + result.selectedRunningBloomInstance.executablePath || "unknown" + }`, + ); + } +} +console.log( + `Running from expected repo root: ${result.runningFromExpectedRepoRoot}`, +); +console.log( + `Running from different repo root: ${result.runningFromDifferentRepoRoot}`, +); +console.log( + `Workspace tabs endpoint reachable: ${result.endpoints.workspaceTabs.reachable}`, +); +console.log(`CDP endpoint reachable: ${result.endpoints.cdpVersion.reachable}`); + +for (const bloomProcess of result.bloomProcesses) { + console.log(""); + console.log(`Bloom PID ${bloomProcess.processId}`); + console.log(`Executable: ${bloomProcess.executablePath || "unknown"}`); + console.log( + `Detected repo root: ${bloomProcess.detectedRepoRoot || "unknown"}`, + ); + console.log( + `Matches expected repo root: ${bloomProcess.matchesExpectedRepoRoot}`, + ); + + for (const chainEntry of bloomProcess.processChain) { + console.log(` [${chainEntry.processId}] ${chainEntry.name}`); + if (chainEntry.repoRoot) { + console.log(` repoRoot: ${chainEntry.repoRoot}`); + } + if (chainEntry.commandLine) { + console.log(` commandLine: ${chainEntry.commandLine}`); + } + } +} + +if (result.bloomProcesses.length === 0) { + console.log("No Bloom.exe process found."); +} + +if (result.ambiguousWatchProcesses.length > 0) { + console.log(""); + console.log( + `Ambiguous watch processes: ${result.ambiguousWatchProcesses.length} (relative paths; not attributed to a repo root)`, + ); +} diff --git a/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs b/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs new file mode 100644 index 000000000000..04982332fc1a --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { getDefaultRepoRoot } from "./bloomProcessCommon.mjs"; + +const args = process.argv.slice(2); +const watch = args.includes("--watch"); +const repoRootArgIndex = args.indexOf("--repo-root"); +const repoRoot = + repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); +const projectPath = path.join(repoRoot, "src", "BloomExe", "BloomExe.csproj"); + +if (!existsSync(projectPath)) { + console.error( + `Bloom project not found at ${projectPath}. Verify --repo-root or run the command from this worktree.`, + ); + process.exit(1); +} + +const dotnetArgs = watch + ? ["watch", "run", "--project", projectPath] + : ["run", "--project", projectPath]; + +const child = spawn("dotnet", dotnetArgs, { + stdio: "inherit", + shell: false, +}); + +child.on("error", (error) => { + console.error(`Failed to start dotnet: ${error.message}`); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + process.exit(code ?? 0); +}); diff --git a/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs b/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs new file mode 100644 index 000000000000..7ddd211c3b26 --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs @@ -0,0 +1,65 @@ +import { + classifyProcesses, + getDefaultRepoRoot, + killProcessIds, +} from "./bloomProcessCommon.mjs"; + +const args = process.argv.slice(2); +const json = args.includes("--json"); +const onlyMismatched = args.includes("--only-mismatched"); +const repoRootArgIndex = args.indexOf("--repo-root"); +const expectedRepoRoot = + repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); + +const processState = classifyProcesses(expectedRepoRoot); +const bloomProcesses = processState.bloomProcesses.filter( + (processRecord) => + !onlyMismatched || !processRecord.matchesExpectedRepoRoot, +); +const fallbackWatchProcesses = processState.watchProcesses.filter( + (processRecord) => + processRecord.detectedRepoRoot && + (!onlyMismatched || !processRecord.matchesExpectedRepoRoot), +); + +const processIds = new Set(); + +for (const bloomProcess of bloomProcesses) { + for (const chainEntry of bloomProcess.processChain) { + if ( + chainEntry.name === "Bloom.exe" || + (chainEntry.name === "dotnet.exe" && + chainEntry.commandLine?.includes("BloomExe.csproj")) + ) { + processIds.add(chainEntry.processId); + } + } +} + +if (processIds.size === 0) { + for (const watchProcess of fallbackWatchProcesses) { + processIds.add(watchProcess.processId); + } +} + +const requestedProcessIds = [...processIds].sort((left, right) => right - left); +const killedProcessIds = killProcessIds(requestedProcessIds); + +const result = { + expectedRepoRoot: processState.expectedRepoRoot, + onlyMismatched, + requestedProcessIds, + killedProcessIds, +}; + +if (json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} + +if (killedProcessIds.length === 0) { + console.log("No Bloom-related processes were killed."); + process.exit(0); +} + +console.log(`Killed process IDs: ${killedProcessIds.join(", ")}`); diff --git a/.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs b/.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs new file mode 100644 index 000000000000..5f8ddad1e509 --- /dev/null +++ b/.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs @@ -0,0 +1,226 @@ +import { findRunningStandardBloomInstance } from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + host: "localhost", + port: "9222", + json: false, + all: false, + runningBloom: false, + wait: false, + timeoutMs: 15000, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--json") { + options.json = true; + continue; + } + + if (arg === "--all") { + options.all = true; + continue; + } + + if (arg === "--running-bloom") { + options.runningBloom = true; + continue; + } + + if (arg === "--wait") { + options.wait = true; + continue; + } + + if (arg === "--host") { + options.host = args[i + 1] || options.host; + i++; + continue; + } + + if (arg === "--port") { + options.port = args[i + 1] || options.port; + i++; + continue; + } + + if (arg === "--timeout-ms") { + options.timeoutMs = Number(args[i + 1] || options.timeoutMs); + i++; + } + } + + return options; +}; + +const fetchJson = async (url) => { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Request failed: ${response.status} ${response.statusText} for ${url}`, + ); + } + + return response.json(); +}; + +const delay = async (ms) => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const isLikelyBloomTarget = (target) => { + if (target.type !== "page") { + return false; + } + + if (!target.url || target.url.startsWith("devtools://")) { + return false; + } + + return ( + target.url.includes("/bloom/") || + target.title?.includes("InMemoryHtmlFile") || + target.title?.includes("Bloom") + ); +}; + +const normalizeFrontendUrl = (origin, frontendUrl) => { + if (!frontendUrl) { + return undefined; + } + + if ( + frontendUrl.startsWith("http://") || + frontendUrl.startsWith("https://") + ) { + return frontendUrl; + } + + return `${origin}${frontendUrl}`; +}; + +const printText = (result) => { + console.log(`CDP endpoint: ${result.origin}`); + console.log(`Browser: ${result.version.Browser || "unknown"}`); + console.log(`Protocol: ${result.version["Protocol-Version"] || "unknown"}`); + console.log(`Targets: ${result.targets.length}`); + + if (result.targets.length === 0) { + console.log("No matching targets found."); + console.log( + "Try rerunning with --all if Bloom is open but did not match the default filter.", + ); + return; + } + + for (const target of result.targets) { + console.log(""); + console.log(`[${target.id}] ${target.title || "(untitled)"}`); + console.log(`type: ${target.type}`); + console.log(`url: ${target.url}`); + console.log( + `webSocketDebuggerUrl: ${target.webSocketDebuggerUrl || ""}`, + ); + if (target.devtoolsFrontendUrl) { + console.log(`devtoolsFrontendUrl: ${target.devtoolsFrontendUrl}`); + } + if (target.likelyBloomTarget) { + console.log("likelyBloomTarget: true"); + } + } +}; + +const main = async () => { + const options = parseArgs(); + const deadline = Date.now() + options.timeoutMs; + + let version; + let filteredTargets = []; + let origin = `http://${options.host}:${options.port}`; + let runningBloomInstance; + let lastError; + + while (true) { + try { + if (options.runningBloom) { + runningBloomInstance = await findRunningStandardBloomInstance(); + if (!runningBloomInstance) { + throw new Error( + "No running Bloom instance was found on Bloom's standard HTTP port range.", + ); + } + + if (!runningBloomInstance.cdpOrigin) { + throw new Error( + "The running Bloom instance did not report a CDP endpoint.", + ); + } + + origin = runningBloomInstance.cdpOrigin; + } + + const [currentVersion, targets] = await Promise.all([ + fetchJson(`${origin}/json/version`), + fetchJson(`${origin}/json/list`), + ]); + + version = currentVersion; + const normalizedTargets = targets.map((target) => ({ + id: target.id, + type: target.type, + title: target.title, + url: target.url, + webSocketDebuggerUrl: target.webSocketDebuggerUrl, + devtoolsFrontendUrl: normalizeFrontendUrl( + origin, + target.devtoolsFrontendUrl, + ), + likelyBloomTarget: isLikelyBloomTarget(target), + })); + + filteredTargets = options.all + ? normalizedTargets + : normalizedTargets.filter( + (target) => target.likelyBloomTarget, + ); + + if ( + !options.wait || + filteredTargets.length > 0 || + Date.now() >= deadline + ) { + break; + } + } catch (error) { + lastError = error; + if (!options.wait || Date.now() >= deadline) { + throw error; + } + } + + await delay(250); + } + + const result = { + origin, + version, + targets: filteredTargets, + primaryTarget: filteredTargets[0], + runningBloomInstance, + error: lastError instanceof Error ? lastError.message : undefined, + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + printText(result); +}; + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/.vscode/settings.json b/.vscode/settings.json index 07a34c23f284..0a17430c5da7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -68,5 +68,13 @@ "Winforms", "xmatter" ], - "chat.useNestedAgentsMdFiles": true + "chat.useNestedAgentsMdFiles": true, + "workbench.colorCustomizations": { + "statusBar.background": "#215732", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#2f7c47", + "statusBarItem.remoteBackground": "#215732", + "statusBarItem.remoteForeground": "#e7e7e7" + }, + "peacock.color": "#215732" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 090c48023787..b02b4b3530ff 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -36,6 +36,18 @@ "${workspaceFolder}/Bloom.sln" ], "problemMatcher": "$msCompile" + }, + { + "label": "bloom-exe-run", + "type": "shell", + "command": "dotnet", + "args": [ + "run", + "--project", + "${workspaceFolder}/src/BloomExe/BloomExe.csproj" + ], + "isBackground": true, + "group": "build" } ] } diff --git a/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts new file mode 100644 index 000000000000..169c94562f7b --- /dev/null +++ b/src/BloomBrowserUI/react_components/TopBar/CollectionTopBarControls/component-tests/bloom-exe-collection-topbar.uitest.ts @@ -0,0 +1,28 @@ +import { test, expect } from "../../../component-tester/playwrightTest"; +import { + connectToBloomExe, + getBloomTopBarFrame, + waitForActiveWorkspaceTab, +} from "../../../component-tester/bloomExeCdp"; + +test.describe("CollectionTopBarControls on Bloom.exe", () => { + test("shows the real collection top bar controls from the embedded exe", async () => { + const connection = await connectToBloomExe(); + + try { + const topBarFrame = await getBloomTopBarFrame(connection.page); + + await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await waitForActiveWorkspaceTab("collection"); + + await expect( + topBarFrame.getByText("Settings", { exact: true }), + ).toBeVisible(); + await expect( + topBarFrame.getByText("Other Collection", { exact: true }), + ).toBeVisible(); + } finally { + await connection.browser.close(); + } + }); +}); diff --git a/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts new file mode 100644 index 000000000000..a0b013072a93 --- /dev/null +++ b/src/BloomBrowserUI/react_components/TopBar/component-tests/bloom-exe-tabs.uitest.ts @@ -0,0 +1,79 @@ +import { test, expect } from "../../component-tester/playwrightTest"; +import { + connectToBloomExe, + getBloomTopBarFrame, + waitForActiveWorkspaceTab, +} from "../../component-tester/bloomExeCdp"; + +test.describe("Bloom exe CDP top bar", () => { + test("switches embedded workspace tabs through the real top bar iframe", async () => { + const connection = await connectToBloomExe(); + + try { + const topBarFrame = await getBloomTopBarFrame(connection.page); + + await topBarFrame.getByRole("tab", { name: "Collections" }).click(); + await waitForActiveWorkspaceTab("collection"); + await expect(connection.page.locator("body")).toHaveClass( + /collection-mode/, + ); + + await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await waitForActiveWorkspaceTab("publish"); + await expect(connection.page.locator("body")).toHaveClass( + /publish-mode/, + ); + + await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await waitForActiveWorkspaceTab("edit"); + await expect(connection.page.locator("body")).toHaveClass( + /edit-mode/, + ); + } finally { + await connection.browser.close(); + } + }); + + test("can observe console output and network traffic while attached to Bloom.exe", async () => { + const connection = await connectToBloomExe(); + + try { + const topBarFrame = await getBloomTopBarFrame(connection.page); + const consoleMessages: string[] = []; + const requestUrls: string[] = []; + + connection.page.on("console", (message) => { + consoleMessages.push(message.text()); + }); + connection.page.on("request", (request) => { + requestUrls.push(request.url()); + }); + + await connection.page.evaluate(() => { + console.log("bloom-exe-cdp-console-smoke"); + }); + + await expect + .poll(() => + consoleMessages.includes("bloom-exe-cdp-console-smoke"), + ) + .toBe(true); + + await topBarFrame.getByRole("tab", { name: "Publish" }).click(); + await waitForActiveWorkspaceTab("publish"); + + await expect + .poll(() => + requestUrls.some((url) => + url.includes("/bloom/api/workspace/selectTab"), + ), + ) + .toBe(true); + + await topBarFrame.getByRole("tab", { name: "Edit" }).click(); + await waitForActiveWorkspaceTab("edit"); + } finally { + await connection.browser.close(); + } + }); +}); diff --git a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts new file mode 100644 index 000000000000..5a2aaade1bc3 --- /dev/null +++ b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts @@ -0,0 +1,95 @@ +import { Browser, Frame, Page, chromium } from "./playwrightTest"; + +type WorkspaceTabId = "collection" | "edit" | "publish"; +const cdpEndpoints = ["http://127.0.0.1:9222", "http://localhost:9222"]; + +export const connectToBloomExe = async (): Promise<{ + browser: Browser; + page: Page; +}> => { + let browser: Browser | undefined; + let lastError: unknown; + + for (const endpoint of cdpEndpoints) { + try { + browser = await chromium.connectOverCDP(endpoint); + break; + } catch (error) { + lastError = error; + } + } + + if (!browser) { + throw lastError instanceof Error + ? lastError + : new Error( + `Could not connect to Bloom WebView2 over CDP at ${cdpEndpoints.join(", ")}. Verify that Bloom is running and remote debugging is enabled.`, + ); + } + + const pages = browser.contexts().flatMap((context) => context.pages()); + const page = pages.find( + (candidate) => + candidate.url().includes("/bloom/") && + !candidate.url().startsWith("devtools://"), + ); + + if (!page) { + await browser.close(); + throw new Error( + `Could not find a Bloom WebView2 target on ${cdpEndpoints.join(", ")}. Start Bloom first and confirm remote debugging is enabled.`, + ); + } + + await page.waitForLoadState("domcontentloaded"); + return { browser, page }; +}; + +export const getBloomTopBarFrame = async (page: Page): Promise => { + const topBarHandle = await page.$("#topBar"); + if (!topBarHandle) { + throw new Error("Could not find the Bloom topBar iframe."); + } + + const frame = await topBarHandle.contentFrame(); + if (!frame) { + throw new Error("The Bloom topBar iframe did not expose a frame."); + } + + await frame.waitForLoadState("domcontentloaded"); + return frame; +}; + +export const getWorkspaceTabs = async (): Promise<{ + tabStates: Record; +}> => { + const response = await fetch( + "http://localhost:8089/bloom/api/workspace/tabs", + ); + if (!response.ok) { + throw new Error( + `workspace/tabs failed: ${response.status} ${response.statusText}`, + ); + } + + return response.json(); +}; + +export const waitForActiveWorkspaceTab = async ( + tab: WorkspaceTabId, +): Promise => { + const timeoutAt = Date.now() + 10000; + + while (Date.now() < timeoutAt) { + const tabs = await getWorkspaceTabs(); + if (tabs.tabStates[tab] === "active") { + return; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `Timed out waiting for workspace tab '${tab}' to become active.`, + ); +}; diff --git a/src/BloomBrowserUI/react_components/component-tester/playwright.bloom-exe.config.ts b/src/BloomBrowserUI/react_components/component-tester/playwright.bloom-exe.config.ts new file mode 100644 index 000000000000..d53780af703d --- /dev/null +++ b/src/BloomBrowserUI/react_components/component-tester/playwright.bloom-exe.config.ts @@ -0,0 +1,35 @@ +import * as path from "path"; +import type { PlaywrightTestConfig } from "@playwright/test"; + +const nodeModulesPath = path.resolve(__dirname, "node_modules"); +const currentNodePath = process.env.NODE_PATH + ? process.env.NODE_PATH.split(path.delimiter) + : []; + +if (!currentNodePath.includes(nodeModulesPath)) { + process.env.NODE_PATH = [nodeModulesPath, ...currentNodePath] + .filter(Boolean) + .join(path.delimiter); +} + +// eslint-disable-next-line @typescript-eslint/no-require-imports +Object.keys(require.cache).forEach((key) => { + if (key.includes("@playwright/test") || key.includes("playwright/lib")) { + delete require.cache[key]; + } +}); + +const config: PlaywrightTestConfig = { + testDir: "..", + testMatch: "**/bloom-exe*.uitest.ts", + timeout: 30000, + workers: 1, + expect: { + timeout: 5000, + }, + use: { + trace: "on-first-retry", + }, +}; + +export default config; diff --git a/src/BloomExe/WebView2Browser.cs b/src/BloomExe/WebView2Browser.cs index 3b6138822a29..412833300e91 100644 --- a/src/BloomExe/WebView2Browser.cs +++ b/src/BloomExe/WebView2Browser.cs @@ -26,6 +26,13 @@ namespace Bloom { public partial class WebView2Browser : Browser { + public static int? RemoteDebuggingPort => +#if DEBUG + 9222; +#else + null; +#endif + public static string AlternativeWebView2Path; private bool _readyToNavigate; private PasteCommand _pasteCommand; @@ -286,9 +293,10 @@ private async Task InitWebView() { additionalBrowserArgs += " --accept-lang=" + _uiLanguageOfThisRun; } - #region DEBUG - additionalBrowserArgs += " --remote-debugging-port=9222 "; // allow external inspector connect - #endregion + if (RemoteDebuggingPort.HasValue) + { + additionalBrowserArgs += $" --remote-debugging-port={RemoteDebuggingPort.Value} "; // allow external inspector connect + } var op = new CoreWebView2EnvironmentOptions(additionalBrowserArgs); diff --git a/src/BloomExe/web/controllers/CommonApi.cs b/src/BloomExe/web/controllers/CommonApi.cs index 2bac4ab3a618..e784f42aadd0 100644 --- a/src/BloomExe/web/controllers/CommonApi.cs +++ b/src/BloomExe/web/controllers/CommonApi.cs @@ -188,6 +188,7 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) false, false ); + apiHandler.RegisterEndpointHandler("common/instanceInfo", HandleInstanceInfo, false); // This is useful for debugging TypeScript code, especially on Linux. I wouldn't necessarily expect // to see it used anywhere in code that gets submitted and merged. apiHandler.RegisterEndpointHandler( @@ -229,6 +230,31 @@ public void RegisterWithApiHandler(BloomApiHandler apiHandler) ); } + private void HandleInstanceInfo(ApiRequest request) + { + if (request.HttpMethod != HttpMethods.Get) + throw new ArgumentException("common/instanceInfo only supports GET"); + + var executablePath = Application.ExecutablePath; + var cdpPort = Bloom.WebView2Browser.RemoteDebuggingPort; + request.ReplyWithJson( + new + { + instanceKind = "running-bloom", + processId = Process.GetCurrentProcess().Id, + executablePath, + executableDirectory = Path.GetDirectoryName(executablePath), + httpPort = BloomServer.portForHttp, + serverUrl = BloomServer.ServerUrl, + serverUrlWithBloomPrefix = BloomServer.ServerUrlWithBloomPrefixEndingInSlash, + workspaceTabsUrl = BloomServer.ServerUrlWithBloomPrefixEndingInSlash + + "api/workspace/tabs", + cdpPort, + cdpOrigin = cdpPort.HasValue ? $"http://localhost:{cdpPort.Value}" : null, + } + ); + } + /// /// Whether any modifications to the current book may currently be saved. /// This is used by many things that don't otherwise need to know about Team Collections, From 617a9008af67a9fcbddb9cb8396ce17ad9262155 Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 13 Mar 2026 14:49:52 -0600 Subject: [PATCH 2/9] allow isolated bloom.exe launches Allows tests or agents to launch their own copies of bloom.exe on their own ports, lessoning the chance of stepping on other running versions running from different code directories. Does not yet include any kind of principled random port choosing. --- .github/skills/bloom-automation/SKILL.md | 253 +++++++++++++++ .../bloomProcessCommon.mjs | 297 ++++++++++++++++-- .../bloom-automation/bloomProcessStatus.mjs | 214 +++++++++++++ .github/skills/bloom-automation/bloomRun.mjs | 256 +++++++++++++++ .../bloom-automation/killBloomProcess.mjs | 190 +++++++++++ .../bloom-automation/switchWorkspaceTab.mjs | 296 +++++++++++++++++ .../webview2Targets.mjs | 44 ++- .../skills/bloom-exe-cdp-automation/SKILL.md | 175 ----------- .../bloomProcessStatus.mjs | 116 ------- .../bloom-exe-cdp-automation/bloomRun.mjs | 41 --- .../killBloomProcess.mjs | 65 ---- .../component-tester/bloomExeCdp.ts | 21 +- src/BloomExe/Program.cs | 150 ++++++++- src/BloomExe/ProjectContext.cs | 2 +- src/BloomExe/WebView2Browser.cs | 5 +- src/BloomExe/Workspace/WorkspaceView.cs | 4 +- src/BloomExe/web/BloomServer.cs | 62 +++- src/BloomExe/web/controllers/CommonApi.cs | 1 + src/BloomTests/ProgramTests.cs | 41 +++ 19 files changed, 1792 insertions(+), 441 deletions(-) create mode 100644 .github/skills/bloom-automation/SKILL.md rename .github/skills/{bloom-exe-cdp-automation => bloom-automation}/bloomProcessCommon.mjs (50%) create mode 100644 .github/skills/bloom-automation/bloomProcessStatus.mjs create mode 100644 .github/skills/bloom-automation/bloomRun.mjs create mode 100644 .github/skills/bloom-automation/killBloomProcess.mjs create mode 100644 .github/skills/bloom-automation/switchWorkspaceTab.mjs rename .github/skills/{bloom-exe-cdp-automation => bloom-automation}/webview2Targets.mjs (80%) delete mode 100644 .github/skills/bloom-exe-cdp-automation/SKILL.md delete mode 100644 .github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs delete mode 100644 .github/skills/bloom-exe-cdp-automation/bloomRun.mjs delete mode 100644 .github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs create mode 100644 src/BloomTests/ProgramTests.cs diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md new file mode 100644 index 000000000000..5c4bfc1970ec --- /dev/null +++ b/.github/skills/bloom-automation/SKILL.md @@ -0,0 +1,253 @@ +--- +name: bloom-automation +description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE. +argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests" +user-invocable: true +--- + +# Bloom Exe CDP Automation + +## Outcome +Use the real embedded WebView2 inside Bloom.exe as the automation target. Determine whether Bloom is already running, whether it belongs to this worktree, stop the right processes when necessary, start the current worktree, discover the live CDP target, and drive the UI through the embedded browser instead of Bloom APIs. + +## When To Use +- You need to know whether Bloom is already running. +- You need to know whether the running Bloom came from the wrong worktree. +- You need to kill a confusing stale Bloom or `dotnet watch` parent process. +- You need to start Bloom from the current worktree. +- You need to attach to the embedded WebView2 for DOM, console, network, and screenshot/debug access. +- You need Playwright tests to hit the actual exe instead of `http://localhost:8089/bloom/CURRENTPAGE` in a separate browser tab. + +## Default Assumptions +- Current repo root is derived automatically from the script location. +- Bloom project path is `src/BloomExe/BloomExe.csproj`. +- The helper launch script chooses an explicit port block by default instead of relying on Bloom's standard search. It allocates HTTP bases from `18089`, `18099`, `18109`, ... and reserves `http`, `http+1`, and `http+2`, with CDP on `http+3`. +- Running Bloom reports its actual HTTP and CDP ports through `http://localhost:/bloom/api/common/instanceInfo`. +- Bloom's embedded WebView2 still defaults to CDP port `9222` in debug builds when no explicit `--cdp-port` argument is supplied. + +## Commands + +Examples below assume you are somewhere inside the repository and first compute the repo root once: + +```bash +repo_root="$(git rev-parse --show-toplevel)" +``` + +Terminal: +- In this VS Code workspace, the shared bash terminal keeps whatever cwd the previous command left behind. +- Prefer running the helper through `$repo_root/.github/skills/...` so the command does not depend on the current working directory. + +Important: +- Agents using this skill MUST use the checked-in helper scripts below, not package.json aliases and not ad hoc `wmic` commands. +- In this workspace, assume the default terminal is bash unless you explicitly opened another shell. Do not use cmd-only syntax such as `cd /d D:\...` in bash. +- Do not run raw `wmic ...` commands from a bash terminal as part of this skill workflow. +- Do not redirect WMIC output to temp files from bash. +- The VS Code bash terminals in this workspace have shown bracketed-paste/shell-integration problems where ad hoc WMIC commands appear to hang or are injected incorrectly. The checked-in Node wrappers avoid that by calling WMIC directly without going through shell redirection. +- Only fall back to raw Windows commands if the checked-in wrappers themselves are broken and you are explicitly debugging them. If you do that, prefer `cmd /c` over bash redirection. + +### Status + +```bash +node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" +node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --json +node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --running-bloom --json +node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --http-port 18089 --json +``` + +Reports Bloom.exe processes, detected repo roots, attributable `dotnet watch` parents, ambiguous watchers, and whether the workspace API and CDP endpoint are reachable. + +Use `--running-bloom` when the user explicitly wants the already-running Bloom instead of a worktree-owned instance. This scans Bloom's standard HTTP port range, asks any running Bloom for `common/instanceInfo`, and reports the ports that instance says it is using. +Use `--http-port ` when you launched Bloom through `bloomRun.mjs` and want the exact instance that owns that HTTP port. This is the preferred multi-instance workflow because it gives you the precise Bloom PID and CDP port even when several Blooms from the same worktree are running. +### Kill Bloom + +```bash +node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" +node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --only-mismatched +node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --http-port 18089 +node "$repo_root/.github/skills/bloom-automation/killBloomProcess.mjs" --pid 12345 --watch-pid 12340 +``` + +Use the plain form to stop all detected Bloom-related processes. Use `--only-mismatched` to stop only the Bloom instance that does not belong to the current worktree. +Use `--http-port ` to stop the exact Bloom instance bound to that HTTP port, together with any `dotnet` parent in its process chain. Use `--pid` or `--watch-pid` only when you already know the exact process IDs you want to stop. + +Important: if Bloom was started with `dotnet watch run`, killing only `Bloom.exe` is not enough because the watcher will restart it. Prefer the provided kill script so the watcher and child process are both terminated. + +### Start Bloom + +```bash +node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" +node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" --watch +node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" --http-port 18089 --cdp-port 18092 +``` + +Use `--watch` when you expect to iterate. These helpers compute the repo root, acquire a lock on a non-overlapping port block, pass explicit `--http-port` and `--cdp-port` arguments to Bloom, and print the `dotnet` PID immediately plus the Bloom PID once `common/instanceInfo` becomes reachable. +`bloomRun.mjs` is intentionally long-lived: for normal launches it keeps running until the launched Bloom instance exits, even if `dotnet run` returns earlier after spawning `Bloom.exe`. If Bloom reports ready and then dies shortly afterward, the helper now reports that as a failed launch instead of silently succeeding. + +Agent workflow for `bloomRun.mjs`: +- Start it in a background terminal. +- Do not wait for the command to finish. A successful launch is the `Bloom ready. HTTP ..., websocket ..., CDP ..., Bloom PID ...` line, not process exit. +- If the helper later reports that the Bloom PID exited shortly after reporting ready, treat that as a failed launch and do not target that HTTP port. +- After starting it, read or poll that background terminal's output until the `Bloom ready.` line appears, then use the reported HTTP port as the identity of the new instance. +- After you have the HTTP port, continue with `bloomProcessStatus.mjs --http-port --json`, `webview2Targets.mjs --http-port --json --wait`, or `switchWorkspaceTab.mjs --http-port --tab ...`. +- Keep the background terminal open for the lifetime of that Bloom instance. The helper may outlive the `dotnet` process because it continues tracking the actual Bloom PID and holding the port lease. + +### Discover the CDP target + +```bash +node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" +node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --json --wait +node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --running-bloom --json --wait +node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --http-port 18089 --json --wait +``` + +Use `--wait` after startup so the command blocks until the embedded browser target is available. + +### Switch a workspace tab + +```bash +node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --running-bloom --tab edit --json +node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --http-port 18089 --tab publish --json +``` + +This helper attaches to the reported WebView2 target over CDP, clicks the real top bar tab, waits for `workspace/tabs` to report it active, and prints the resulting state. + +## Minimal Running Bloom Attach Workflow + +Use this exact path when the user says to reuse the already-running Bloom and you need the fewest possible steps. + +1. Report the running instance: + +```bash +repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --running-bloom --json +``` + +2. Switch the real running Bloom through the skill-local helper: + +```bash +repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/switchWorkspaceTab.mjs" --running-bloom --tab edit --json +``` + +3. Only if you need low-level debugging evidence, inspect the exact CDP target: + +```bash +repo_root="$(git rev-parse --show-toplevel)" && node "$repo_root/.github/skills/bloom-automation/webview2Targets.mjs" --running-bloom --json --wait +``` + +Notes: +- `switchWorkspaceTab.mjs` lives in this skill directory and loads Playwright from `src/BloomBrowserUI/react_components/component-tester` automatically. +- The minimal action path is step 2 by itself. Run step 1 first only when you need to report the chosen HTTP/CDP ports. +- Run step 3 only when you need raw CDP target details for debugging. + +## Core Workflow +1. Run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --json` if you need to know whether an ordinary current-worktree instance is already running. +2. If you need a fresh automation-owned instance, start it with `node .github/skills/bloom-automation/bloomRun.mjs --watch` or `node .github/skills/bloom-automation/bloomRun.mjs`. +3. Copy the printed HTTP and CDP ports. If you need the exact PID later, run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --http-port --json`. +4. If you instead want to reuse a current-worktree instance that Bloom found by itself, only then use repo-root matching and `--only-mismatched` cleanup. +5. Run `node .github/skills/bloom-automation/webview2Targets.mjs --http-port --json --wait` to discover the live WebView2 target for that exact instance when you need debugging detail. +6. Use `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --http-port --tab ` for top bar interactions, or attach another confirmed client to the reported `cdpOrigin` if you need lower-level inspection. +7. Manipulate the UI by clicking or typing in the attached browser context. Do not use Bloom API endpoints to simulate the user action itself. +8. Use browser-native inspection for DOM, console, and network. +9. If the task is test-related, run the exe-backed Playwright suite with `BLOOM_HTTP_PORT= BLOOM_CDP_PORT= yarn playwright test --config playwright.bloom-exe.config.ts`. + +## Running Bloom Workflow +Use this when the user says to reuse the already-running Bloom. + +1. Run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --running-bloom --json`. +2. If no running Bloom instance is reported, tell the user there is no running Bloom to reuse. +3. If one is reported, do not kill or restart it because of worktree mismatch. +4. Use `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab ` for top bar actions, or `node .github/skills/bloom-automation/webview2Targets.mjs --running-bloom --json --wait` when you need CDP target detail. +5. Attach to the reported instance and work only against the ports it reported about itself. + +## Rules + +### Reuse the current worktree instance +- Reuse it. +- Attach over CDP and drive the UI directly. +- Do not restart unless the user explicitly wants a fresh run or you need to load new code. + +### Treat wrong-worktree Bloom as a blocker +- Treat that as a blocker because it produces extremely confusing results. +- Report the detected repo root from `node .github/skills/bloom-automation/bloomProcessStatus.mjs`. +- Kill the mismatched process with `node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched`. +- Then start the current worktree. + +### Start with the helper, not raw watch commands +- Start it from the current worktree. +- Prefer `node .github/skills/bloom-automation/bloomRun.mjs --watch` if you expect to iterate. +- Prefer `node .github/skills/bloom-automation/bloomRun.mjs` if you only need a single verification run. +- Treat the printed HTTP port as the identity of that instance. Use `bloomProcessStatus.mjs --http-port `, `webview2Targets.mjs --http-port `, and `killBloomProcess.mjs --http-port ` to target it precisely. +- Never wait for `bloomRun.mjs` to exit as a readiness signal. It is a long-lived launcher. Wait for the `Bloom ready.` line in the background terminal output instead, and treat a later `Bloom PID ... exited shortly after reporting ready` message as a failed launch. + +### Reuse the running Bloom when the user asks for it +- Run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --running-bloom --json`. +- Reuse the returned running Bloom instance even if it does not match the current worktree. +- Use `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab ` for direct top bar interaction, or discover its CDP target with `node .github/skills/bloom-automation/webview2Targets.mjs --running-bloom --json --wait` when you need the raw target details. +- Do not kill or restart it unless the user explicitly asks for that. + +### Prove browser-native access when needed +- Show the CDP target from `node .github/skills/bloom-automation/webview2Targets.mjs --json --wait`. +- Attach with Playwright. +- Demonstrate reading `body.className`, the top-bar iframe, console messages, and the `workspace/selectTab` request. +- For multi-instance work, prefer `webview2Targets.mjs --http-port --json --wait` and the matching `cdpOrigin` it reports. + +### Verified two-instance smoke path +- Launch one instance on `--http-port 18089 --cdp-port 18092`. +- Launch a second instance on `--http-port 18099 --cdp-port 18102`. +- Target the first instance with `switchWorkspaceTab.mjs --http-port 18089 --tab edit`. +- Target the second instance with `switchWorkspaceTab.mjs --http-port 18099 --tab publish`. +- Use explicit ports throughout; do not mix `--running-bloom` with this workflow. + +## Confirmed Path + +- `playwright` Node library via the `cdpOrigin` reported by `common/instanceInfo` or `webview2Targets.mjs` +- `@playwright/test` runner via the exe-backed suite in `src/BloomBrowserUI/react_components/component-tester` + +Not confirmed here: +- `chrome-devtools-mcp` as an attached client to Bloom's existing WebView2 target +- the current Playwright MCP browser wrappers as an attached client to Bloom's existing WebView2 target + +Reason: the current MCP wrappers in this environment control their own browser instance and do not expose a way to attach to an already-running external CDP endpoint. Until those tools add explicit attach support, prefer the script plus Playwright path above. + +## Tests +- Run from `src/BloomBrowserUI/react_components/component-tester`. +- Use `BLOOM_HTTP_PORT= BLOOM_CDP_PORT= yarn playwright test --config playwright.bloom-exe.config.ts`. +- Run one file with `BLOOM_HTTP_PORT= BLOOM_CDP_PORT= yarn playwright test --config playwright.bloom-exe.config.ts ../TopBar/component-tests/bloom-exe-tabs.uitest.ts`. + +These tests attach to the real Bloom.exe target over CDP and verify tab switching plus console and network observation. + +## Notes +- Prefer the Node helpers over PowerShell. The Node scripts use `wmic`, `taskkill`, and `dotnet` directly because the PowerShell path proved too brittle. +- Prefer the checked-in Node helper commands over raw Windows shell commands. Subagents should normally run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --json`, `node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched`, `node .github/skills/bloom-automation/bloomRun.mjs`, `node .github/skills/bloom-automation/webview2Targets.mjs --json --wait`, and `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab edit`, not ad hoc `wmic` commands. +- `bloomRun.mjs` keeps a lightweight lease file for the chosen HTTP block while it is running so concurrent agents do not both choose the same automation ports. +- For agent-driven launches, the background terminal is part of the control plane. Leave it running and poll its output for `Bloom ready.` instead of waiting for command completion. The helper may keep running after `dotnet` exits because it is tracking the actual Bloom process. +- Exact-target cleanup is intentionally strict: `killBloomProcess.mjs --http-port ` should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved. +- When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. +- Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. +- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node .github/skills/bloom-automation/bloomRun.mjs --watch`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. +- When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow. + +## Completion Checks +- Bloom's status is known: not running, running from current worktree, or running from different worktree. +- Any mismatched Bloom instance has been stopped before running the current worktree, unless you intentionally started a separate explicit-port instance. +- The chosen HTTP port returns `common/instanceInfo`, including the exact Bloom PID and CDP port. +- The reported CDP endpoint responds at `/json/version`. +- `node .github/skills/bloom-automation/webview2Targets.mjs --http-port --json --wait` returns a real Bloom target. +- The automation client can read DOM state and interact with the embedded top bar. +- If tests were requested, the exe-backed Playwright suite passes. + +## Output Contract +Report: +- whether Bloom was already running +- which repo root the running Bloom came from +- whether you killed a mismatched or stale process +- which command you used to start Bloom +- which HTTP/CDP ports were assigned +- which Bloom PID and `dotnet` PID were associated with that instance +- which client attached successfully +- what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results + +## Example Prompts +- `Use bloom-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.` +- `Use bloom-automation to switch the already-running Bloom to the Edit tab.` +- `Use bloom-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.` +- `Use bloom-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.` diff --git a/.github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs similarity index 50% rename from .github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs rename to .github/skills/bloom-automation/bloomProcessCommon.mjs index 5acb1e697813..afc151991ab0 100644 --- a/.github/skills/bloom-exe-cdp-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -1,10 +1,29 @@ import { execFileSync } from "node:child_process"; +import { + closeSync, + mkdirSync, + openSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const standardBloomStartingHttpPort = 8089; const standardBloomPortIncrement = 2; const standardBloomPortCount = 10; +const automationBloomStartingHttpPort = 18089; +const automationBloomPortBlockSize = 10; +const automationBloomPortBlockCount = 200; +const automationBloomReservedHttpOffsets = [0, 1, 2]; +const automationBloomCdpOffset = 3; +const bloomPortLeaseDirectory = path.join( + os.tmpdir(), + "bloom-exe-cdp-automation-port-leases", +); const toLocalOrigin = (port) => `http://localhost:${port}`; const toBloomApiBaseUrl = (port) => `${toLocalOrigin(port)}/bloom/api`; @@ -28,6 +47,219 @@ export const getDefaultRepoRoot = () => "..", ); +export const getBloomPortPlan = ( + httpPort, + cdpPort = httpPort + automationBloomCdpOffset, +) => ({ + httpPort, + webSocketPort: httpPort + 1, + reservedHttpPorts: automationBloomReservedHttpOffsets.map( + (offset) => httpPort + offset, + ), + cdpPort, +}); + +export const formatBloomPortPlan = (portPlan) => + `HTTP ${portPlan.httpPort}, websocket ${portPlan.webSocketPort}, reserved ${portPlan.reservedHttpPorts[2]}, CDP ${portPlan.cdpPort}`; + +const isProcessRunning = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const getBloomPortLeasePath = (httpPort) => + path.join(bloomPortLeaseDirectory, `${httpPort}.json`); + +const readBloomPortLease = (leasePath) => { + try { + return JSON.parse(readFileSync(leasePath, "utf8")); + } catch { + return undefined; + } +}; + +const tryAcquireBloomPortLeaseFile = (portPlan) => { + mkdirSync(bloomPortLeaseDirectory, { recursive: true }); + const leasePath = getBloomPortLeasePath(portPlan.httpPort); + + for (let attempt = 0; attempt < 2; attempt++) { + try { + const fd = openSync(leasePath, "wx"); + writeFileSync( + fd, + JSON.stringify( + { + ownerPid: process.pid, + httpPort: portPlan.httpPort, + cdpPort: portPlan.cdpPort, + createdAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + closeSync(fd); + return { + path: leasePath, + ownerPid: process.pid, + portPlan, + }; + } catch (error) { + if (error?.code !== "EEXIST") { + throw error; + } + + const existingLease = readBloomPortLease(leasePath); + if ( + existingLease?.ownerPid && + isProcessRunning(existingLease.ownerPid) + ) { + return undefined; + } + + try { + unlinkSync(leasePath); + } catch { + return undefined; + } + } + } + + return undefined; +}; + +const canListenOnLoopbackPort = async (port) => { + await new Promise((resolve) => setImmediate(resolve)); + + return new Promise((resolve) => { + const server = net.createServer(); + let resolved = false; + + const finish = (result) => { + if (resolved) { + return; + } + + resolved = true; + resolve(result); + }; + + server.once("error", () => { + server.close(() => finish(false)); + }); + server.once("listening", () => { + server.close(() => finish(true)); + }); + server.listen({ + host: "127.0.0.1", + port, + exclusive: true, + }); + }); +}; + +const areLoopbackPortsAvailable = async (ports) => { + for (const port of ports) { + if (!(await canListenOnLoopbackPort(port))) { + return false; + } + } + + return true; +}; + +export const releaseBloomPortLease = (lease) => { + if (!lease?.path) { + return; + } + + try { + unlinkSync(lease.path); + } catch {} +}; + +export const acquireBloomPortLease = async (requestedPorts = {}) => { + const explicitHttpPort = + requestedPorts.httpPort === undefined + ? undefined + : toPositiveInteger(requestedPorts.httpPort); + const explicitCdpPort = + requestedPorts.cdpPort === undefined + ? undefined + : toPositiveInteger(requestedPorts.cdpPort); + + if (requestedPorts.httpPort !== undefined && !explicitHttpPort) { + throw new Error("--http-port must be an integer from 1 to 65535."); + } + + if (requestedPorts.cdpPort !== undefined && !explicitCdpPort) { + throw new Error("--cdp-port must be an integer from 1 to 65535."); + } + + if (explicitCdpPort && !explicitHttpPort) { + throw new Error("--cdp-port requires --http-port in bloomRun.mjs."); + } + + const explicitPlan = explicitHttpPort + ? getBloomPortPlan(explicitHttpPort, explicitCdpPort) + : undefined; + const lastReservedOffset = + automationBloomReservedHttpOffsets[ + automationBloomReservedHttpOffsets.length - 1 + ]; + + if ( + explicitPlan && + explicitPlan.cdpPort >= explicitPlan.httpPort && + explicitPlan.cdpPort <= explicitPlan.httpPort + lastReservedOffset + ) { + throw new Error( + "--cdp-port must not overlap Bloom's reserved HTTP block (http, http+1, http+2).", + ); + } + + const candidatePlans = explicitPlan + ? [explicitPlan] + : Array.from({ length: automationBloomPortBlockCount }, (_, index) => + getBloomPortPlan( + automationBloomStartingHttpPort + + index * automationBloomPortBlockSize, + ), + ); + + for (const portPlan of candidatePlans) { + const lease = tryAcquireBloomPortLeaseFile(portPlan); + if (!lease) { + continue; + } + + const portsToCheck = [...portPlan.reservedHttpPorts, portPlan.cdpPort]; + if (await areLoopbackPortsAvailable(portsToCheck)) { + return lease; + } + + releaseBloomPortLease(lease); + + if (explicitPlan) { + throw new Error( + `Requested Bloom ports are unavailable: ${formatBloomPortPlan(portPlan)}.`, + ); + } + } + + throw new Error( + `Could not find a free Bloom automation port block starting at ${automationBloomStartingHttpPort}.`, + ); +}; + const normalizePath = (value) => { if (!value) { return undefined; @@ -65,6 +297,25 @@ export const extractRepoRoot = (text) => { return undefined; }; +export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { + const httpPort = toPositiveInteger(info?.httpPort) ?? discoveredViaPort; + const cdpPort = toPositiveInteger(info?.cdpPort); + + return { + ...info, + processId: toPositiveInteger(info?.processId), + discoveredViaPort, + httpPort, + origin: toLocalOrigin(httpPort), + workspaceTabsUrl: + info?.workspaceTabsUrl || + `${toBloomApiBaseUrl(httpPort)}/workspace/tabs`, + cdpPort, + cdpOrigin: + info?.cdpOrigin || (cdpPort ? toLocalOrigin(cdpPort) : undefined), + }; +}; + const parseWmicList = (text) => { const lines = text.replace(/\r/g, "").split("\n"); const records = []; @@ -247,13 +498,31 @@ export const fetchJsonEndpoint = async (url) => { } }; +export const fetchBloomInstanceInfo = async (httpPort) => + fetchJsonEndpoint(`${toBloomApiBaseUrl(httpPort)}/common/instanceInfo`); + +export const waitForBloomInstanceInfo = async (httpPort, timeoutMs = 30000) => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await fetchBloomInstanceInfo(httpPort); + if (response.reachable && response.json) { + return normalizeBloomInstanceInfo(response.json, httpPort); + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `Bloom did not report common/instanceInfo on http://localhost:${httpPort} within ${timeoutMs} ms.`, + ); +}; + export const findRunningStandardBloomInstances = async () => { const responses = await Promise.all( getStandardBloomHttpPorts().map(async (port) => ({ port, - instanceInfo: await fetchJsonEndpoint( - `${toBloomApiBaseUrl(port)}/common/instanceInfo`, - ), + instanceInfo: await fetchBloomInstanceInfo(port), })), ); @@ -261,25 +530,9 @@ export const findRunningStandardBloomInstances = async () => { .filter( ({ instanceInfo }) => instanceInfo.reachable && !!instanceInfo.json, ) - .map(({ port, instanceInfo }) => { - const info = instanceInfo.json; - const httpPort = toPositiveInteger(info.httpPort) ?? port; - const cdpPort = toPositiveInteger(info.cdpPort); - - return { - ...info, - discoveredViaPort: port, - httpPort, - origin: toLocalOrigin(httpPort), - workspaceTabsUrl: - info.workspaceTabsUrl || - `${toBloomApiBaseUrl(httpPort)}/workspace/tabs`, - cdpPort, - cdpOrigin: - info.cdpOrigin || - (cdpPort ? toLocalOrigin(cdpPort) : undefined), - }; - }) + .map(({ port, instanceInfo }) => + normalizeBloomInstanceInfo(instanceInfo.json, port), + ) .sort((left, right) => left.httpPort - right.httpPort); }; diff --git a/.github/skills/bloom-automation/bloomProcessStatus.mjs b/.github/skills/bloom-automation/bloomProcessStatus.mjs new file mode 100644 index 000000000000..262c2f987a78 --- /dev/null +++ b/.github/skills/bloom-automation/bloomProcessStatus.mjs @@ -0,0 +1,214 @@ +import { + buildProcessChain, + classifyProcesses, + fetchBloomInstanceInfo, + fetchJsonEndpoint, + findRunningStandardBloomInstances, + getDefaultRepoRoot, + getWindowsProcessSnapshot, + normalizeBloomInstanceInfo, +} from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + json: false, + runningBloom: false, + repoRoot: getDefaultRepoRoot(), + httpPort: undefined, + cdpPort: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--json") { + options.json = true; + continue; + } + + if (arg === "--running-bloom") { + options.runningBloom = true; + continue; + } + + if (arg === "--repo-root") { + options.repoRoot = args[i + 1] || options.repoRoot; + i++; + continue; + } + + if (arg === "--http-port") { + options.httpPort = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = arg.slice("--http-port=".length); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = arg.slice("--cdp-port=".length); + } + } + + return options; +}; + +const options = parseArgs(); +const processState = classifyProcesses(options.repoRoot); +const runningBloomInstances = options.runningBloom + ? await findRunningStandardBloomInstances() + : []; + +let selectedRunningBloomInstance; +if (options.httpPort) { + const instanceInfo = await fetchBloomInstanceInfo(Number(options.httpPort)); + if (instanceInfo.reachable && instanceInfo.json) { + selectedRunningBloomInstance = normalizeBloomInstanceInfo( + instanceInfo.json, + Number(options.httpPort), + ); + } +} else if (options.runningBloom) { + selectedRunningBloomInstance = runningBloomInstances[0]; +} + +let selectedRunningBloomProcess; +if (selectedRunningBloomInstance?.processId) { + const { byId } = getWindowsProcessSnapshot(); + const processRecord = byId.get(selectedRunningBloomInstance.processId); + + if (processRecord) { + const processChain = buildProcessChain(processRecord, byId); + const detectedRepoRoot = processChain.find( + (entry) => entry.repoRoot, + )?.repoRoot; + + selectedRunningBloomProcess = { + processId: processRecord.processId, + name: processRecord.name, + executablePath: processRecord.executablePath, + commandLine: processRecord.commandLine, + detectedRepoRoot, + matchesExpectedRepoRoot: + !!detectedRepoRoot && + detectedRepoRoot.toLowerCase() === + processState.expectedRepoRoot?.toLowerCase(), + processChain, + }; + } +} + +const workspaceTabsUrl = selectedRunningBloomInstance + ? selectedRunningBloomInstance.workspaceTabsUrl + : options.httpPort + ? `http://localhost:${Number(options.httpPort)}/bloom/api/workspace/tabs` + : "http://localhost:8089/bloom/api/workspace/tabs"; +const cdpVersionUrl = selectedRunningBloomInstance?.cdpOrigin + ? `${selectedRunningBloomInstance.cdpOrigin}/json/version` + : options.cdpPort + ? `http://localhost:${Number(options.cdpPort)}/json/version` + : "http://localhost:9222/json/version"; +const workspaceTabs = await fetchJsonEndpoint(workspaceTabsUrl); +const cdpVersion = await fetchJsonEndpoint(cdpVersionUrl); + +const result = { + mode: options.httpPort + ? "explicit-http-port" + : options.runningBloom + ? "running-bloom" + : "current-worktree", + expectedRepoRoot: processState.expectedRepoRoot, + requestedHttpPort: options.httpPort ? Number(options.httpPort) : undefined, + requestedCdpPort: options.cdpPort ? Number(options.cdpPort) : undefined, + isRunning: selectedRunningBloomInstance + ? true + : processState.bloomProcesses.length > 0, + runningFromExpectedRepoRoot: processState.bloomProcesses.some( + (processRecord) => processRecord.matchesExpectedRepoRoot, + ), + runningFromDifferentRepoRoot: processState.bloomProcesses.some( + (processRecord) => !processRecord.matchesExpectedRepoRoot, + ), + bloomProcesses: processState.bloomProcesses, + watchProcesses: processState.watchProcesses, + ambiguousWatchProcesses: processState.ambiguousWatchProcesses, + runningBloomInstances, + selectedRunningBloomInstance, + selectedRunningBloomProcess, + endpoints: { + workspaceTabs, + cdpVersion, + }, +}; + +if (options.json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} + +console.log(`Expected repo root: ${result.expectedRepoRoot}`); +console.log(`Bloom running: ${result.isRunning}`); +if (selectedRunningBloomInstance) { + console.log( + `Selected Bloom HTTP port: ${selectedRunningBloomInstance.httpPort}`, + ); + console.log( + `Selected Bloom CDP port: ${selectedRunningBloomInstance.cdpPort || "unknown"}`, + ); + console.log( + `Selected Bloom PID: ${selectedRunningBloomInstance.processId || "unknown"}`, + ); +} +console.log( + `Running from expected repo root: ${result.runningFromExpectedRepoRoot}`, +); +console.log( + `Running from different repo root: ${result.runningFromDifferentRepoRoot}`, +); +console.log( + `Workspace tabs endpoint reachable: ${result.endpoints.workspaceTabs.reachable}`, +); +console.log(`CDP endpoint reachable: ${result.endpoints.cdpVersion.reachable}`); + +for (const bloomProcess of result.bloomProcesses) { + console.log(""); + console.log(`Bloom PID ${bloomProcess.processId}`); + console.log(`Executable: ${bloomProcess.executablePath || "unknown"}`); + console.log( + `Detected repo root: ${bloomProcess.detectedRepoRoot || "unknown"}`, + ); + console.log( + `Matches expected repo root: ${bloomProcess.matchesExpectedRepoRoot}`, + ); + + for (const chainEntry of bloomProcess.processChain) { + console.log(` [${chainEntry.processId}] ${chainEntry.name}`); + if (chainEntry.repoRoot) { + console.log(` repoRoot: ${chainEntry.repoRoot}`); + } + if (chainEntry.commandLine) { + console.log(` commandLine: ${chainEntry.commandLine}`); + } + } +} + +if (result.bloomProcesses.length === 0) { + console.log("No Bloom.exe process found."); +} + +if (result.ambiguousWatchProcesses.length > 0) { + console.log(""); + console.log( + `Ambiguous watch processes: ${result.ambiguousWatchProcesses.length} (relative paths; not attributed to a repo root)`, + ); +} diff --git a/.github/skills/bloom-automation/bloomRun.mjs b/.github/skills/bloom-automation/bloomRun.mjs new file mode 100644 index 000000000000..ae851d6651b9 --- /dev/null +++ b/.github/skills/bloom-automation/bloomRun.mjs @@ -0,0 +1,256 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { + acquireBloomPortLease, + formatBloomPortPlan, + getDefaultRepoRoot, + releaseBloomPortLease, + waitForBloomInstanceInfo, +} from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + watch: false, + repoRoot: getDefaultRepoRoot(), + httpPort: undefined, + cdpPort: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--watch") { + options.watch = true; + continue; + } + + if (arg === "--repo-root") { + options.repoRoot = args[i + 1] || options.repoRoot; + i++; + continue; + } + + if (arg === "--http-port") { + options.httpPort = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = arg.slice("--http-port=".length); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = arg.slice("--cdp-port=".length); + } + } + + return options; +}; + +const options = parseArgs(); +const launchTimeoutMs = 120000; +const bloomMonitorPollMs = 500; +const shortLivedBloomMs = 5000; +const projectPath = path.join( + options.repoRoot, + "src", + "BloomExe", + "BloomExe.csproj", +); + +if (!existsSync(projectPath)) { + console.error( + `Bloom project not found at ${projectPath}. Verify --repo-root or run the command from this worktree.`, + ); + process.exit(1); +} + +const lease = await acquireBloomPortLease({ + httpPort: options.httpPort, + cdpPort: options.cdpPort, +}); +const portPlan = lease.portPlan; +const dotnetArgs = options.watch + ? [ + "watch", + "run", + "--project", + projectPath, + "--", + "--http-port", + String(portPlan.httpPort), + "--cdp-port", + String(portPlan.cdpPort), + ] + : [ + "run", + "--project", + projectPath, + "--", + "--http-port", + String(portPlan.httpPort), + "--cdp-port", + String(portPlan.cdpPort), + ]; + +console.log(`Bloom launch ports: ${formatBloomPortPlan(portPlan)}`); + +const child = spawn("dotnet", dotnetArgs, { + stdio: "inherit", + shell: false, +}); + +console.log(`dotnet PID: ${child.pid}`); +console.log( + "bloomRun.mjs tracks the launched Bloom instance until it exits. Treat the 'Bloom ready.' line as the launch success signal and keep this terminal open while you target the reported HTTP port.", +); + +let childExited = false; +let leaseReleased = false; +let bloomProcessId; +let bloomReadyAt; +let bloomMonitor; +let childExitCode = 0; +let childExitSignal; + +const isProcessRunning = (pid) => { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +const stopBloomMonitor = () => { + if (!bloomMonitor) { + return; + } + + clearInterval(bloomMonitor); + bloomMonitor = undefined; +}; + +const exitForFinishedLaunch = (exitCode = 0) => { + stopBloomMonitor(); + releaseLease(); + + if (childExitSignal) { + process.kill(process.pid, childExitSignal); + return; + } + + process.exit(exitCode); +}; + +const startBloomMonitor = () => { + if (options.watch || bloomMonitor || !bloomProcessId) { + return; + } + + bloomMonitor = setInterval(() => { + if (isProcessRunning(bloomProcessId)) { + return; + } + + const runtimeMs = bloomReadyAt ? Date.now() - bloomReadyAt : undefined; + const exitedTooSoon = + runtimeMs !== undefined && runtimeMs < shortLivedBloomMs; + + if (exitedTooSoon) { + console.error( + `Bloom PID ${bloomProcessId} exited ${runtimeMs} ms after reporting ready. Treating this as a failed launch.`, + ); + } else { + console.log(`Bloom PID ${bloomProcessId} exited.`); + } + + exitForFinishedLaunch(exitedTooSoon ? 1 : childExitCode); + }, bloomMonitorPollMs); +}; + +const releaseLease = () => { + if (leaseReleased) { + return; + } + + leaseReleased = true; + releaseBloomPortLease(lease); +}; + +process.on("exit", releaseLease); + +waitForBloomInstanceInfo(portPlan.httpPort, launchTimeoutMs) + .then((instanceInfo) => { + if (childExited && !isProcessRunning(instanceInfo.processId)) { + console.error( + `Bloom reported ready on HTTP ${instanceInfo.httpPort}, but Bloom PID ${instanceInfo.processId} was already gone by the time the launcher checked it.`, + ); + exitForFinishedLaunch(childExitCode || 1); + return; + } + + bloomProcessId = instanceInfo.processId; + bloomReadyAt = Date.now(); + + console.log( + `Bloom ready. HTTP ${instanceInfo.httpPort}, websocket ${instanceInfo.webSocketPort || portPlan.webSocketPort}, CDP ${instanceInfo.cdpPort || portPlan.cdpPort}, Bloom PID ${instanceInfo.processId}.`, + ); + + if (childExited && !options.watch) { + console.log( + `dotnet exited, but Bloom PID ${instanceInfo.processId} is still running. Continuing to monitor that Bloom process and hold the port lease.`, + ); + } + + startBloomMonitor(); + }) + .catch((error) => { + console.error(error.message); + + if (childExited) { + exitForFinishedLaunch(childExitCode || 1); + } + }); + +child.on("error", (error) => { + console.error(`Failed to start dotnet: ${error.message}`); + exitForFinishedLaunch(1); +}); + +child.on("exit", (code, signal) => { + childExited = true; + childExitCode = code ?? 0; + childExitSignal = signal ?? undefined; + + if (bloomProcessId && isProcessRunning(bloomProcessId) && !options.watch) { + console.log( + `dotnet exited${code !== null ? ` with code ${code}` : ""}, but Bloom PID ${bloomProcessId} is still running. Waiting for Bloom to exit before releasing the port lease.`, + ); + startBloomMonitor(); + return; + } + + if (!bloomProcessId) { + console.log( + `dotnet exited before Bloom reported ready on HTTP ${portPlan.httpPort}. Waiting briefly for Bloom to appear.`, + ); + return; + } + + exitForFinishedLaunch(childExitCode); +}); diff --git a/.github/skills/bloom-automation/killBloomProcess.mjs b/.github/skills/bloom-automation/killBloomProcess.mjs new file mode 100644 index 000000000000..ff6528114184 --- /dev/null +++ b/.github/skills/bloom-automation/killBloomProcess.mjs @@ -0,0 +1,190 @@ +import { + buildProcessChain, + classifyProcesses, + fetchBloomInstanceInfo, + getDefaultRepoRoot, + getWindowsProcessSnapshot, + killProcessIds, + normalizeBloomInstanceInfo, +} from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + json: false, + onlyMismatched: false, + repoRoot: getDefaultRepoRoot(), + httpPort: undefined, + pid: undefined, + watchPid: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--json") { + options.json = true; + continue; + } + + if (arg === "--only-mismatched") { + options.onlyMismatched = true; + continue; + } + + if (arg === "--repo-root") { + options.repoRoot = args[i + 1] || options.repoRoot; + i++; + continue; + } + + if (arg === "--http-port") { + options.httpPort = args[i + 1]; + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = arg.slice("--http-port=".length); + continue; + } + + if (arg === "--pid") { + options.pid = Number(args[i + 1]); + i++; + continue; + } + + if (arg.startsWith("--pid=")) { + options.pid = Number(arg.slice("--pid=".length)); + continue; + } + + if (arg === "--watch-pid") { + options.watchPid = Number(args[i + 1]); + i++; + continue; + } + + if (arg.startsWith("--watch-pid=")) { + options.watchPid = Number(arg.slice("--watch-pid=".length)); + } + } + + return options; +}; + +const options = parseArgs(); +const processState = classifyProcesses(options.repoRoot); +const processIds = new Set(); +const exactTargetRequested = + !!options.httpPort || !!options.pid || !!options.watchPid; +let targetedInstance; +let exactTargetResolutionError; + +if (options.httpPort) { + const instanceInfo = await fetchBloomInstanceInfo(options.httpPort); + if (instanceInfo.reachable && instanceInfo.json) { + targetedInstance = normalizeBloomInstanceInfo( + instanceInfo.json, + Number(options.httpPort), + ); + if (targetedInstance.processId) { + processIds.add(targetedInstance.processId); + } + } else { + exactTargetResolutionError = `No Bloom instance reported common/instanceInfo on http://localhost:${options.httpPort}.`; + } +} + +if (Number.isInteger(options.pid) && options.pid > 0) { + processIds.add(options.pid); +} + +if (Number.isInteger(options.watchPid) && options.watchPid > 0) { + processIds.add(options.watchPid); +} + +if (processIds.size > 0) { + const { byId } = getWindowsProcessSnapshot(); + + for (const requestedProcessId of [...processIds]) { + const processRecord = byId.get(requestedProcessId); + if (!processRecord) { + continue; + } + + const processChain = buildProcessChain(processRecord, byId); + for (const chainEntry of processChain) { + if ( + chainEntry.name === "Bloom.exe" || + (chainEntry.name === "dotnet.exe" && + chainEntry.commandLine?.includes("BloomExe.csproj")) + ) { + processIds.add(chainEntry.processId); + } + } + } +} else if (!exactTargetRequested) { + const bloomProcesses = processState.bloomProcesses.filter( + (processRecord) => + !options.onlyMismatched || !processRecord.matchesExpectedRepoRoot, + ); + const fallbackWatchProcesses = processState.watchProcesses.filter( + (processRecord) => + processRecord.detectedRepoRoot && + (!options.onlyMismatched || !processRecord.matchesExpectedRepoRoot), + ); + + for (const bloomProcess of bloomProcesses) { + for (const chainEntry of bloomProcess.processChain) { + if ( + chainEntry.name === "Bloom.exe" || + (chainEntry.name === "dotnet.exe" && + chainEntry.commandLine?.includes("BloomExe.csproj")) + ) { + processIds.add(chainEntry.processId); + } + } + } + + if (processIds.size === 0) { + for (const watchProcess of fallbackWatchProcesses) { + processIds.add(watchProcess.processId); + } + } +} + +const requestedProcessIds = [...processIds].sort((left, right) => right - left); +const killedProcessIds = killProcessIds(requestedProcessIds); + +const result = { + expectedRepoRoot: processState.expectedRepoRoot, + onlyMismatched: options.onlyMismatched, + exactTargetRequested, + exactTargetResolutionError, + requestedHttpPort: options.httpPort ? Number(options.httpPort) : undefined, + targetedInstance, + requestedProcessIds, + killedProcessIds, +}; + +if (options.json) { + console.log(JSON.stringify(result, null, 2)); + process.exit(0); +} + +if (exactTargetRequested && requestedProcessIds.length === 0) { + console.log( + exactTargetResolutionError || + "No explicit Bloom process target could be resolved.", + ); + process.exit(1); +} + +if (killedProcessIds.length === 0) { + console.log("No Bloom-related processes were killed."); + process.exit(0); +} + +console.log(`Killed process IDs: ${killedProcessIds.join(", ")}`); diff --git a/.github/skills/bloom-automation/switchWorkspaceTab.mjs b/.github/skills/bloom-automation/switchWorkspaceTab.mjs new file mode 100644 index 000000000000..fe9f072131e0 --- /dev/null +++ b/.github/skills/bloom-automation/switchWorkspaceTab.mjs @@ -0,0 +1,296 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { + fetchBloomInstanceInfo, + findRunningStandardBloomInstance, + getDefaultRepoRoot, + normalizeBloomInstanceInfo, +} from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + runningBloom: false, + httpPort: undefined, + cdpPort: undefined, + tab: undefined, + json: false, + timeoutMs: 10000, + }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === "--running-bloom") { + options.runningBloom = true; + continue; + } + + if (arg === "--http-port") { + options.httpPort = args[index + 1] || options.httpPort; + index++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = arg.slice("--http-port=".length); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = args[index + 1] || options.cdpPort; + index++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = arg.slice("--cdp-port=".length); + continue; + } + + if (arg === "--tab") { + options.tab = args[index + 1] || options.tab; + index++; + continue; + } + + if (arg.startsWith("--tab=")) { + options.tab = arg.slice("--tab=".length); + continue; + } + + if (arg === "--timeout-ms") { + options.timeoutMs = Number(args[index + 1] || options.timeoutMs); + index++; + continue; + } + + if (arg === "--json") { + options.json = true; + continue; + } + + if (arg === "--help") { + printHelp(); + process.exit(0); + } + } + + return options; +}; + +const printHelp = () => { + console.log( + "Usage: node .github/skills/bloom-automation/switchWorkspaceTab.mjs (--running-bloom | --http-port ) --tab [--cdp-port ] [--json] [--timeout-ms ]", + ); +}; + +const normalizeTab = (tab) => { + switch ((tab || "").toLowerCase()) { + case "collection": + case "collections": + return "collection"; + case "edit": + return "edit"; + case "publish": + return "publish"; + default: + return undefined; + } +}; + +const getTabLabel = (tab) => { + switch (tab) { + case "collection": + return "Collections"; + case "edit": + return "Edit"; + case "publish": + return "Publish"; + default: + throw new Error(`Unsupported tab '${tab}'.`); + } +}; + +const loadPlaywright = () => { + const repoRoot = getDefaultRepoRoot(); + const componentTesterDir = path.join( + repoRoot, + "src", + "BloomBrowserUI", + "react_components", + "component-tester", + ); + const requireFromComponentTester = createRequire( + path.join(componentTesterDir, "package.json"), + ); + + try { + return requireFromComponentTester("playwright"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Could not load Playwright from ${componentTesterDir}. Run 'yarn install' in src/BloomBrowserUI/react_components/component-tester if dependencies are missing. Original error: ${message}`, + ); + } +}; + +const getWorkspaceTabs = async (workspaceTabsUrl) => { + const response = await fetch(workspaceTabsUrl); + if (!response.ok) { + throw new Error( + `workspace/tabs failed: ${response.status} ${response.statusText} for ${workspaceTabsUrl}`, + ); + } + + return response.json(); +}; + +const waitForActiveWorkspaceTab = async (workspaceTabsUrl, tab, timeoutMs) => { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const tabs = await getWorkspaceTabs(workspaceTabsUrl); + if (tabs.tabStates?.[tab] === "active") { + return tabs; + } + + await new Promise((resolve) => setTimeout(resolve, 250)); + } + + throw new Error( + `Timed out waiting for workspace tab '${tab}' to become active.`, + ); +}; + +const resolveInstance = async (options) => { + if (options.httpPort) { + const httpPort = Number(options.httpPort); + const response = await fetchBloomInstanceInfo(httpPort); + if (!response.reachable || !response.json) { + throw new Error( + `No Bloom instance reported common/instanceInfo on http://localhost:${httpPort}.`, + ); + } + + const instance = normalizeBloomInstanceInfo(response.json, httpPort); + if (options.cdpPort) { + instance.cdpPort = Number(options.cdpPort); + instance.cdpOrigin = `http://localhost:${instance.cdpPort}`; + } + + return instance; + } + + if (options.runningBloom) { + const instance = await findRunningStandardBloomInstance(); + if (!instance) { + throw new Error( + "No running Bloom instance was found on Bloom's standard HTTP port range.", + ); + } + + if (options.cdpPort) { + instance.cdpPort = Number(options.cdpPort); + instance.cdpOrigin = `http://localhost:${instance.cdpPort}`; + } + + return instance; + } + + throw new Error("Specify either --running-bloom or --http-port ."); +}; + +const getBloomPage = (browser) => { + const pages = browser.contexts().flatMap((context) => context.pages()); + return pages.find( + (candidate) => + candidate.url().includes("/bloom/") && + !candidate.url().startsWith("devtools://"), + ); +}; + +const main = async () => { + const options = parseArgs(); + const tab = normalizeTab(options.tab); + + if (!tab) { + printHelp(); + throw new Error("--tab must be one of: collection, edit, publish."); + } + + const instance = await resolveInstance(options); + if (!instance.cdpOrigin) { + throw new Error( + "The selected Bloom instance did not report a CDP endpoint.", + ); + } + + const { chromium } = loadPlaywright(); + const browser = await chromium.connectOverCDP(instance.cdpOrigin); + + try { + const page = getBloomPage(browser); + if (!page) { + throw new Error( + `Could not find a Bloom WebView2 target on ${instance.cdpOrigin}.`, + ); + } + + await page.waitForLoadState("domcontentloaded"); + + const topBarHandle = await page.$("#topBar"); + if (!topBarHandle) { + throw new Error("Could not find the Bloom topBar iframe."); + } + + const topBarFrame = await topBarHandle.contentFrame(); + if (!topBarFrame) { + throw new Error("The Bloom topBar iframe did not expose a frame."); + } + + await topBarFrame.waitForLoadState("domcontentloaded"); + await topBarFrame.getByRole("tab", { name: getTabLabel(tab) }).click(); + + const tabs = await waitForActiveWorkspaceTab( + instance.workspaceTabsUrl, + tab, + options.timeoutMs, + ); + const bodyClassName = await page + .locator("body") + .evaluate((element) => element.className); + + const result = { + instance: { + processId: instance.processId, + httpPort: instance.httpPort, + cdpPort: instance.cdpPort, + cdpOrigin: instance.cdpOrigin, + workspaceTabsUrl: instance.workspaceTabsUrl, + }, + selectedTab: tab, + bodyClassName, + pageUrl: page.url(), + tabStates: tabs.tabStates, + }; + + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Bloom HTTP port: ${result.instance.httpPort}`); + console.log(`Bloom CDP port: ${result.instance.cdpPort}`); + console.log(`Selected tab: ${result.selectedTab}`); + console.log(`Body class: ${result.bodyClassName}`); + console.log(JSON.stringify(result.tabStates)); + } finally { + await browser.close(); + } +}; + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs b/.github/skills/bloom-automation/webview2Targets.mjs similarity index 80% rename from .github/skills/bloom-exe-cdp-automation/webview2Targets.mjs rename to .github/skills/bloom-automation/webview2Targets.mjs index 5f8ddad1e509..f62fb8e74e40 100644 --- a/.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs +++ b/.github/skills/bloom-automation/webview2Targets.mjs @@ -1,4 +1,8 @@ -import { findRunningStandardBloomInstance } from "./bloomProcessCommon.mjs"; +import { + fetchBloomInstanceInfo, + findRunningStandardBloomInstance, + normalizeBloomInstanceInfo, +} from "./bloomProcessCommon.mjs"; const parseArgs = () => { const args = process.argv.slice(2); @@ -8,6 +12,7 @@ const parseArgs = () => { json: false, all: false, runningBloom: false, + httpPort: undefined, wait: false, timeoutMs: 15000, }; @@ -29,6 +34,17 @@ const parseArgs = () => { continue; } + if (arg === "--http-port") { + options.httpPort = args[i + 1] || options.httpPort; + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = arg.slice("--http-port=".length); + continue; + } + if (arg === "--wait") { options.wait = true; continue; @@ -140,11 +156,33 @@ const main = async () => { let filteredTargets = []; let origin = `http://${options.host}:${options.port}`; let runningBloomInstance; + let selectedInstance; let lastError; while (true) { try { - if (options.runningBloom) { + if (options.httpPort) { + const instanceInfo = await fetchBloomInstanceInfo( + Number(options.httpPort), + ); + if (!instanceInfo.reachable || !instanceInfo.json) { + throw new Error( + `No Bloom instance reported common/instanceInfo on http://localhost:${options.httpPort}.`, + ); + } + + selectedInstance = normalizeBloomInstanceInfo( + instanceInfo.json, + Number(options.httpPort), + ); + if (!selectedInstance.cdpOrigin) { + throw new Error( + "The selected Bloom instance did not report a CDP endpoint.", + ); + } + + origin = selectedInstance.cdpOrigin; + } else if (options.runningBloom) { runningBloomInstance = await findRunningStandardBloomInstance(); if (!runningBloomInstance) { throw new Error( @@ -208,7 +246,7 @@ const main = async () => { version, targets: filteredTargets, primaryTarget: filteredTargets[0], - runningBloomInstance, + runningBloomInstance: selectedInstance || runningBloomInstance, error: lastError instanceof Error ? lastError.message : undefined, }; diff --git a/.github/skills/bloom-exe-cdp-automation/SKILL.md b/.github/skills/bloom-exe-cdp-automation/SKILL.md deleted file mode 100644 index 6f0eca9d3b39..000000000000 --- a/.github/skills/bloom-exe-cdp-automation/SKILL.md +++ /dev/null @@ -1,175 +0,0 @@ ---- -name: bloom-exe-cdp-automation -description: Use when an agent needs to determine if Bloom is already running, detect whether the running Bloom came from a different worktree, kill Bloom or dotnet-watch parents, start Bloom from the current worktree, attach to the embedded WebView2 over CDP, inspect DOM/console/network, or run Playwright tests against the actual exe instead of CURRENTPAGE. -argument-hint: "repo root or worktree, task such as status, restart, attach, run exe-backed tests" -user-invocable: true ---- - -# Bloom Exe CDP Automation - -## Outcome -Use the real embedded WebView2 inside Bloom.exe as the automation target. Determine whether Bloom is already running, whether it belongs to this worktree, stop the right processes when necessary, start the current worktree, discover the live CDP target, and drive the UI through the embedded browser instead of Bloom APIs. - -## When To Use -- You need to know whether Bloom is already running. -- You need to know whether the running Bloom came from the wrong worktree. -- You need to kill a confusing stale Bloom or `dotnet watch` parent process. -- You need to start Bloom from the current worktree. -- You need to attach to the embedded WebView2 for DOM, console, network, and screenshot/debug access. -- You need Playwright tests to hit the actual exe instead of `http://localhost:8089/bloom/CURRENTPAGE` in a separate browser tab. - -## Default Assumptions -- Current repo root is derived automatically from the script location. -- Bloom project path is `src/BloomExe/BloomExe.csproj`. -- Current-worktree launches normally start their HTTP server search at `http://localhost:8089` and use the next odd ports if needed. -- Running Bloom reports its actual HTTP and CDP ports through `http://localhost:/bloom/api/common/instanceInfo`. -- Bloom's embedded WebView2 currently exposes CDP on port `9222` in debug builds. - -## Commands - -Run all of these from `src/BloomBrowserUI` unless noted otherwise. - -Important: -- Agents using this skill MUST use the checked-in helper scripts below, not package.json aliases and not ad hoc `wmic` commands. -- In this workspace, assume the default terminal is bash unless you explicitly opened another shell. Do not use cmd-only syntax such as `cd /d D:\...` in bash. -- Do not run raw `wmic ...` commands from a bash terminal as part of this skill workflow. -- Do not redirect WMIC output to temp files from bash. -- The VS Code bash terminals in this workspace have shown bracketed-paste/shell-integration problems where ad hoc WMIC commands appear to hang or are injected incorrectly. The checked-in Node wrappers avoid that by calling WMIC directly without going through shell redirection. -- Only fall back to raw Windows commands if the checked-in wrappers themselves are broken and you are explicitly debugging them. If you do that, prefer `cmd /c` over bash redirection. - -### Status - -```bash -node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs -node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json -node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json -``` - -Reports Bloom.exe processes, detected repo roots, attributable `dotnet watch` parents, ambiguous watchers, and whether the workspace API and CDP endpoint are reachable. - -Use `--running-bloom` when the user explicitly wants the already-running Bloom instead of a worktree-owned instance. This scans Bloom's standard HTTP port range, asks any running Bloom for `common/instanceInfo`, and reports the ports that instance says it is using. -### Kill Bloom - -```bash -node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs -node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched -``` - -Use the plain form to stop all detected Bloom-related processes. Use `--only-mismatched` to stop only the Bloom instance that does not belong to the current worktree. - -Important: if Bloom was started with `dotnet watch run`, killing only `Bloom.exe` is not enough because the watcher will restart it. Prefer the provided kill script so the watcher and child process are both terminated. - -### Start Bloom - -```bash -node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs -node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch -``` - -Use `--watch` when you expect to iterate. These helpers compute the repo root and pass an absolute `--project` path to `dotnet`, which avoids ambiguous relative watcher command lines. - -### Discover the CDP target - -```bash -node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs -node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait -node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait -``` - -Use `--wait` after startup so the command blocks until the embedded browser target is available. - -## Core Workflow -1. Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json`. -2. If Bloom is not running, start it from the current worktree with `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch` or `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs`. -3. If Bloom is running from a different worktree, stop it with `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`. -4. If Bloom is running from the correct worktree and the task only needs browser automation, do not restart it. -5. Run `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait` to discover the live WebView2 target. -6. Attach a confirmed client to `http://localhost:9222`. -7. Manipulate the UI by clicking or typing in the attached browser context. Do not use Bloom API endpoints to simulate the user action itself. -8. Use browser-native inspection for DOM, console, and network. -9. If the task is test-related, run the exe-backed Playwright suite. - -## Running Bloom Workflow -Use this when the user says to reuse the already-running Bloom. - -1. Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json`. -2. If no running Bloom instance is reported, tell the user there is no running Bloom to reuse. -3. If one is reported, do not kill or restart it because of worktree mismatch. -4. Run `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait`. -5. Attach to the reported instance and work only against the ports it reported about itself. - -## Rules - -### Reuse the current worktree instance -- Reuse it. -- Attach over CDP and drive the UI directly. -- Do not restart unless the user explicitly wants a fresh run or you need to load new code. - -### Treat wrong-worktree Bloom as a blocker -- Treat that as a blocker because it produces extremely confusing results. -- Report the detected repo root from `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs`. -- Kill the mismatched process with `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`. -- Then start the current worktree. - -### Start with the helper, not raw watch commands -- Start it from the current worktree. -- Prefer `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch` if you expect to iterate. -- Prefer `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs` if you only need a single verification run. - -### Reuse the running Bloom when the user asks for it -- Run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --running-bloom --json`. -- Reuse the returned running Bloom instance even if it does not match the current worktree. -- Discover its CDP target with `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --running-bloom --json --wait`. -- Do not kill or restart it unless the user explicitly asks for that. - -### Prove browser-native access when needed -- Show the CDP target from `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait`. -- Attach with Playwright. -- Demonstrate reading `body.className`, the top-bar iframe, console messages, and the `workspace/selectTab` request. - -## Confirmed Path - -- `playwright` Node library via `chromium.connectOverCDP("http://localhost:9222")` -- `@playwright/test` runner via the exe-backed suite in `src/BloomBrowserUI/react_components/component-tester` - -Not confirmed here: -- `chrome-devtools-mcp` as an attached client to Bloom's existing WebView2 target -- the current Playwright MCP browser wrappers as an attached client to Bloom's existing WebView2 target - -Reason: the current MCP wrappers in this environment control their own browser instance and do not expose a way to attach to an already-running external CDP endpoint. Until those tools add explicit attach support, prefer the script plus Playwright path above. - -## Tests -- Run from `src/BloomBrowserUI/react_components/component-tester`. -- Use `yarn playwright test --config playwright.bloom-exe.config.ts`. -- Run one file with `yarn playwright test --config playwright.bloom-exe.config.ts ../TopBar/component-tests/bloom-exe-tabs.uitest.ts`. - -These tests attach to the real Bloom.exe target over CDP and verify tab switching plus console and network observation. - -## Notes -- Prefer the Node helpers over PowerShell. The Node scripts use `wmic`, `taskkill`, and `dotnet` directly because the PowerShell path proved too brittle. -- Prefer the checked-in Node helper commands over raw Windows shell commands. Subagents should normally run `node ../../.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs --json`, `node ../../.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs --only-mismatched`, `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs`, and `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait`, not ad hoc `wmic` commands. -- When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. -- Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. -- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node ../../.github/skills/bloom-exe-cdp-automation/bloomRun.mjs --watch`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. - -## Completion Checks -- Bloom's status is known: not running, running from current worktree, or running from different worktree. -- Any mismatched Bloom instance has been stopped before running the current worktree. -- The CDP endpoint responds at `http://localhost:9222/json/version`. -- `node ../../.github/skills/bloom-exe-cdp-automation/webview2Targets.mjs --json --wait` returns a real Bloom target. -- The automation client can read DOM state and interact with the embedded top bar. -- If tests were requested, the exe-backed Playwright suite passes. - -## Output Contract -Report: -- whether Bloom was already running -- which repo root the running Bloom came from -- whether you killed a mismatched or stale process -- which command you used to start Bloom -- which client attached successfully -- what browser-native evidence you collected: DOM state, console output, network request, tab state, or test results - -## Example Prompts -- `Use bloom-exe-cdp-automation to determine whether Bloom is already running from this worktree and attach Playwright to the embedded browser.` -- `Use bloom-exe-cdp-automation to kill the wrong-worktree Bloom and start the current checkout with dotnet watch.` -- `Use bloom-exe-cdp-automation to run the exe-backed Playwright top bar smoke tests against the actual Bloom.exe window.` diff --git a/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs b/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs deleted file mode 100644 index 9ac248954ac4..000000000000 --- a/.github/skills/bloom-exe-cdp-automation/bloomProcessStatus.mjs +++ /dev/null @@ -1,116 +0,0 @@ -import { - classifyProcesses, - fetchJsonEndpoint, - findRunningStandardBloomInstances, - getDefaultRepoRoot, -} from "./bloomProcessCommon.mjs"; - -const args = process.argv.slice(2); -const json = args.includes("--json"); -const runningBloom = args.includes("--running-bloom"); -const repoRootArgIndex = args.indexOf("--repo-root"); -const expectedRepoRoot = - repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); - -const processState = classifyProcesses(expectedRepoRoot); -const runningBloomInstances = runningBloom - ? await findRunningStandardBloomInstances() - : []; -const selectedRunningBloomInstance = runningBloom - ? runningBloomInstances[0] - : undefined; -const workspaceTabsUrl = selectedRunningBloomInstance - ? selectedRunningBloomInstance.workspaceTabsUrl - : "http://localhost:8089/bloom/api/workspace/tabs"; -const cdpVersionUrl = selectedRunningBloomInstance?.cdpOrigin - ? `${selectedRunningBloomInstance.cdpOrigin}/json/version` - : "http://localhost:9222/json/version"; -const workspaceTabs = await fetchJsonEndpoint(workspaceTabsUrl); -const cdpVersion = await fetchJsonEndpoint(cdpVersionUrl); - -const result = { - mode: runningBloom ? "running-bloom" : "current-worktree", - expectedRepoRoot: processState.expectedRepoRoot, - isRunning: processState.bloomProcesses.length > 0, - runningFromExpectedRepoRoot: processState.bloomProcesses.some( - (processRecord) => processRecord.matchesExpectedRepoRoot, - ), - runningFromDifferentRepoRoot: processState.bloomProcesses.some( - (processRecord) => !processRecord.matchesExpectedRepoRoot, - ), - bloomProcesses: processState.bloomProcesses, - watchProcesses: processState.watchProcesses, - ambiguousWatchProcesses: processState.ambiguousWatchProcesses, - runningBloomInstances, - selectedRunningBloomInstance, - endpoints: { - workspaceTabs, - cdpVersion, - }, -}; - -if (json) { - console.log(JSON.stringify(result, null, 2)); - process.exit(0); -} - -console.log(`Expected repo root: ${result.expectedRepoRoot}`); -console.log(`Bloom running: ${result.isRunning}`); -if (runningBloom) { - console.log( - `Running Bloom instances found: ${result.runningBloomInstances.length}`, - ); - if (result.selectedRunningBloomInstance) { - console.log( - `Selected running Bloom HTTP port: ${result.selectedRunningBloomInstance.httpPort}`, - ); - console.log( - `Selected running Bloom executable: ${ - result.selectedRunningBloomInstance.executablePath || "unknown" - }`, - ); - } -} -console.log( - `Running from expected repo root: ${result.runningFromExpectedRepoRoot}`, -); -console.log( - `Running from different repo root: ${result.runningFromDifferentRepoRoot}`, -); -console.log( - `Workspace tabs endpoint reachable: ${result.endpoints.workspaceTabs.reachable}`, -); -console.log(`CDP endpoint reachable: ${result.endpoints.cdpVersion.reachable}`); - -for (const bloomProcess of result.bloomProcesses) { - console.log(""); - console.log(`Bloom PID ${bloomProcess.processId}`); - console.log(`Executable: ${bloomProcess.executablePath || "unknown"}`); - console.log( - `Detected repo root: ${bloomProcess.detectedRepoRoot || "unknown"}`, - ); - console.log( - `Matches expected repo root: ${bloomProcess.matchesExpectedRepoRoot}`, - ); - - for (const chainEntry of bloomProcess.processChain) { - console.log(` [${chainEntry.processId}] ${chainEntry.name}`); - if (chainEntry.repoRoot) { - console.log(` repoRoot: ${chainEntry.repoRoot}`); - } - if (chainEntry.commandLine) { - console.log(` commandLine: ${chainEntry.commandLine}`); - } - } -} - -if (result.bloomProcesses.length === 0) { - console.log("No Bloom.exe process found."); -} - -if (result.ambiguousWatchProcesses.length > 0) { - console.log(""); - console.log( - `Ambiguous watch processes: ${result.ambiguousWatchProcesses.length} (relative paths; not attributed to a repo root)`, - ); -} diff --git a/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs b/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs deleted file mode 100644 index 04982332fc1a..000000000000 --- a/.github/skills/bloom-exe-cdp-automation/bloomRun.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import path from "node:path"; -import { getDefaultRepoRoot } from "./bloomProcessCommon.mjs"; - -const args = process.argv.slice(2); -const watch = args.includes("--watch"); -const repoRootArgIndex = args.indexOf("--repo-root"); -const repoRoot = - repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); -const projectPath = path.join(repoRoot, "src", "BloomExe", "BloomExe.csproj"); - -if (!existsSync(projectPath)) { - console.error( - `Bloom project not found at ${projectPath}. Verify --repo-root or run the command from this worktree.`, - ); - process.exit(1); -} - -const dotnetArgs = watch - ? ["watch", "run", "--project", projectPath] - : ["run", "--project", projectPath]; - -const child = spawn("dotnet", dotnetArgs, { - stdio: "inherit", - shell: false, -}); - -child.on("error", (error) => { - console.error(`Failed to start dotnet: ${error.message}`); - process.exit(1); -}); - -child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - - process.exit(code ?? 0); -}); diff --git a/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs b/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs deleted file mode 100644 index 7ddd211c3b26..000000000000 --- a/.github/skills/bloom-exe-cdp-automation/killBloomProcess.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { - classifyProcesses, - getDefaultRepoRoot, - killProcessIds, -} from "./bloomProcessCommon.mjs"; - -const args = process.argv.slice(2); -const json = args.includes("--json"); -const onlyMismatched = args.includes("--only-mismatched"); -const repoRootArgIndex = args.indexOf("--repo-root"); -const expectedRepoRoot = - repoRootArgIndex >= 0 ? args[repoRootArgIndex + 1] : getDefaultRepoRoot(); - -const processState = classifyProcesses(expectedRepoRoot); -const bloomProcesses = processState.bloomProcesses.filter( - (processRecord) => - !onlyMismatched || !processRecord.matchesExpectedRepoRoot, -); -const fallbackWatchProcesses = processState.watchProcesses.filter( - (processRecord) => - processRecord.detectedRepoRoot && - (!onlyMismatched || !processRecord.matchesExpectedRepoRoot), -); - -const processIds = new Set(); - -for (const bloomProcess of bloomProcesses) { - for (const chainEntry of bloomProcess.processChain) { - if ( - chainEntry.name === "Bloom.exe" || - (chainEntry.name === "dotnet.exe" && - chainEntry.commandLine?.includes("BloomExe.csproj")) - ) { - processIds.add(chainEntry.processId); - } - } -} - -if (processIds.size === 0) { - for (const watchProcess of fallbackWatchProcesses) { - processIds.add(watchProcess.processId); - } -} - -const requestedProcessIds = [...processIds].sort((left, right) => right - left); -const killedProcessIds = killProcessIds(requestedProcessIds); - -const result = { - expectedRepoRoot: processState.expectedRepoRoot, - onlyMismatched, - requestedProcessIds, - killedProcessIds, -}; - -if (json) { - console.log(JSON.stringify(result, null, 2)); - process.exit(0); -} - -if (killedProcessIds.length === 0) { - console.log("No Bloom-related processes were killed."); - process.exit(0); -} - -console.log(`Killed process IDs: ${killedProcessIds.join(", ")}`); diff --git a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts index 5a2aaade1bc3..0acfa81346e5 100644 --- a/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts +++ b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts @@ -1,7 +1,20 @@ import { Browser, Frame, Page, chromium } from "./playwrightTest"; type WorkspaceTabId = "collection" | "edit" | "publish"; -const cdpEndpoints = ["http://127.0.0.1:9222", "http://localhost:9222"]; +const configuredCdpPort = process.env.BLOOM_CDP_PORT; +const configuredHttpPort = process.env.BLOOM_HTTP_PORT; +const configuredCdpOrigin = process.env.BLOOM_CDP_ORIGIN; +const cdpEndpoints = configuredCdpOrigin + ? [configuredCdpOrigin] + : configuredCdpPort + ? [ + `http://127.0.0.1:${configuredCdpPort}`, + `http://localhost:${configuredCdpPort}`, + ] + : ["http://127.0.0.1:9222", "http://localhost:9222"]; +const workspaceTabsUrl = + process.env.BLOOM_WORKSPACE_TABS_URL || + `http://localhost:${configuredHttpPort || "8089"}/bloom/api/workspace/tabs`; export const connectToBloomExe = async (): Promise<{ browser: Browser; @@ -63,12 +76,10 @@ export const getBloomTopBarFrame = async (page: Page): Promise => { export const getWorkspaceTabs = async (): Promise<{ tabStates: Record; }> => { - const response = await fetch( - "http://localhost:8089/bloom/api/workspace/tabs", - ); + const response = await fetch(workspaceTabsUrl); if (!response.ok) { throw new Error( - `workspace/tabs failed: ${response.status} ${response.statusText}`, + `workspace/tabs failed: ${response.status} ${response.statusText} for ${workspaceTabsUrl}`, ); } diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index b625cb2e9d66..e48ce504b8b7 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -104,6 +104,10 @@ static class Program public static SynchronizationContext MainContext { get; private set; } public static bool RunningSecondInstance { get; private set; } + internal static int? StartupHttpPort { get; private set; } + internal static int? StartupCdpPort { get; private set; } + internal static bool StartupUsesExplicitPorts => + StartupHttpPort.HasValue && StartupCdpPort.HasValue; [STAThread] [HandleProcessCorruptedStateExceptions] @@ -147,6 +151,18 @@ static int Main(string[] args1) // every startup path calls it. SetUpLocalization(); + var args = ParseStartupPortArguments(args1, out var startupPortErrorMessage); + if (startupPortErrorMessage != null) + { + MessageBox.Show( + startupPortErrorMessage, + "Bloom", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + return 1; + } + // Old comment: Firefox60 uses Gtk3, so we need to as well. (BL-10469) // Aug 2023, we've moved away from GeckoFx/Firefox to wv2, but I don't know if this is still needed or not... // Steve says he thinks Gtk3 is still better but that it likely only matters for Linux, @@ -160,7 +176,7 @@ static int Main(string[] args1) // The following is how we will do things from now on, and things can be moved // into this as time allows. See CommandLineOptions.cs. if ( - args1.Length > 0 + args.Length > 0 && new[] { "--help", @@ -173,7 +189,7 @@ static int Main(string[] args1) "spreadsheetExport", "spreadsheetImport", "sendFontAnalytics", - }.Contains(args1[0]) + }.Contains(args[0]) ) //restrict using the commandline parser to cases were it should work { #if !__MonoCS__ @@ -184,7 +200,7 @@ static int Main(string[] args1) var mainTask = CommandLine .Parser.Default.ParseArguments( - args1, + args, new[] { typeof(HydrateParameters), @@ -260,8 +276,6 @@ static int Main(string[] args1) try { - var args = args1; - if (SIL.PlatformUtilities.Platform.IsWindows) { OldVersionCheck(); @@ -511,7 +525,13 @@ static int Main(string[] args1) } else { - if ((Control.ModifierKeys & Keys.Control) == Keys.Control) + if (StartupUsesExplicitPorts) + { + Logger.WriteEvent( + $"Bypassing Bloom's single-instance token because explicit ports were requested. httpPort={StartupHttpPort.Value}, cdpPort={StartupCdpPort.Value}" + ); + } + else if ((Control.ModifierKeys & Keys.Control) == Keys.Control) { // control key is held down so allow second instance to run; note that we're deliberately in this state if (UniqueToken.AcquireTokenQuietly(_mutexId)) @@ -685,6 +705,124 @@ public static void ReleaseBloomToken() UniqueToken.ReleaseToken(); } + internal static string[] ParseStartupPortArguments(string[] args, out string errorMessage) + { + errorMessage = null; + StartupHttpPort = null; + StartupCdpPort = null; + + var remainingArgs = new List(); + + for (var i = 0; i < args.Length; i++) + { + if ( + TryParseStartupPortArgument( + args, + ref i, + "--http-port", + out var httpPort, + out errorMessage + ) + ) + { + if (errorMessage != null) + return Array.Empty(); + + if (StartupHttpPort.HasValue) + { + errorMessage = "Bloom only accepts one --http-port argument."; + return Array.Empty(); + } + + StartupHttpPort = httpPort; + continue; + } + + if ( + TryParseStartupPortArgument( + args, + ref i, + "--cdp-port", + out var cdpPort, + out errorMessage + ) + ) + { + if (errorMessage != null) + return Array.Empty(); + + if (StartupCdpPort.HasValue) + { + errorMessage = "Bloom only accepts one --cdp-port argument."; + return Array.Empty(); + } + + StartupCdpPort = cdpPort; + continue; + } + + remainingArgs.Add(args[i]); + } + + if ( + StartupHttpPort.HasValue + && StartupCdpPort.HasValue + && StartupCdpPort.Value >= StartupHttpPort.Value + && StartupCdpPort.Value <= StartupHttpPort.Value + 2 + ) + { + errorMessage = + "Bloom's --cdp-port must not overlap the reserved HTTP block (http, http+1, http+2)."; + return Array.Empty(); + } + + return remainingArgs.ToArray(); + } + + private static bool TryParseStartupPortArgument( + string[] args, + ref int index, + string optionName, + out int port, + out string errorMessage + ) + { + port = 0; + errorMessage = null; + var current = args[index]; + string value = null; + + if (current == optionName) + { + if (index + 1 >= args.Length) + { + errorMessage = $"Bloom requires a numeric value after {optionName}."; + return true; + } + + value = args[++index]; + } + else if (current.StartsWith(optionName + "=", StringComparison.Ordinal)) + { + value = current.Substring(optionName.Length + 1); + } + else + { + return false; + } + + if ( + !int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out port) + || port < 1 + || port > 65535 + ) + { + errorMessage = $"Bloom requires {optionName} to be an integer from 1 to 65535."; + } + + return true; + } + private static bool IsWebviewMissingOrTooOld() { string version; diff --git a/src/BloomExe/ProjectContext.cs b/src/BloomExe/ProjectContext.cs index 9d28d7de6f81..fce9c92a6269 100644 --- a/src/BloomExe/ProjectContext.cs +++ b/src/BloomExe/ProjectContext.cs @@ -386,7 +386,7 @@ IContainer parentContainer _scope .Resolve() - .Init((BloomServer.portForHttp + 1).ToString(CultureInfo.InvariantCulture)); + .Init(BloomServer.WebSocketPort.ToString(CultureInfo.InvariantCulture)); HelpLauncher.RegisterWithApiHandler(server.ApiHandler); ExternalLinkController.RegisterWithApiHandler(server.ApiHandler); ToolboxView.RegisterWithApiHandler(server.ApiHandler); diff --git a/src/BloomExe/WebView2Browser.cs b/src/BloomExe/WebView2Browser.cs index 412833300e91..d72143a50d4b 100644 --- a/src/BloomExe/WebView2Browser.cs +++ b/src/BloomExe/WebView2Browser.cs @@ -26,13 +26,16 @@ namespace Bloom { public partial class WebView2Browser : Browser { - public static int? RemoteDebuggingPort => + private static int? kDefaultRemoteDebuggingPort => #if DEBUG 9222; #else null; #endif + public static int? RemoteDebuggingPort => + Program.StartupCdpPort ?? kDefaultRemoteDebuggingPort; + public static string AlternativeWebView2Path; private bool _readyToNavigate; private PasteCommand _pasteCommand; diff --git a/src/BloomExe/Workspace/WorkspaceView.cs b/src/BloomExe/Workspace/WorkspaceView.cs index de134e1f4aa2..00b3fb604932 100644 --- a/src/BloomExe/Workspace/WorkspaceView.cs +++ b/src/BloomExe/Workspace/WorkspaceView.cs @@ -951,9 +951,7 @@ private static void ApplyUiLanguageChange(string langTag) { using (var server = new BloomWebSocketServer()) { - server.Init( - (BloomServer.portForHttp + 1).ToString(CultureInfo.InvariantCulture) - ); + server.Init(BloomServer.WebSocketPort.ToString(CultureInfo.InvariantCulture)); server.SendString("app", "uiLanguageChanged", langTag); } } diff --git a/src/BloomExe/web/BloomServer.cs b/src/BloomExe/web/BloomServer.cs index 64a4a1e1074b..89661aa63ddf 100644 --- a/src/BloomExe/web/BloomServer.cs +++ b/src/BloomExe/web/BloomServer.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Net; +using System.Net.Sockets; using System.Security.Policy; using System.Text; using System.Threading; @@ -56,12 +57,21 @@ public interface IBloomServer public class BloomServer : IBloomServer, IDisposable { public static int portForHttp; + public const int WebSocketPortOffset = 1; + public const int ReservedPortBlockLength = 3; + + public static int WebSocketPort => GetWebSocketPort(portForHttp); public static string ServerUrl { get { return "http://localhost:" + portForHttp.ToString(CultureInfo.InvariantCulture); } } + public static int GetWebSocketPort(int httpPort) + { + return httpPort + WebSocketPortOffset; + } + /// /// Prefix we add to after the RootUrl in all our urls. This is just a legacy thing we could remove. /// @@ -1468,10 +1478,22 @@ public virtual void EnsureListening() // Another thing to check on is https://github.com/bryceg/Owin.WebSocket/pull/20 which // would give us an owin-compliant version of the fleck websocket server, and we could // switch to using an owin-compliant http server like NancyFx. - for (var i = 0; !success && i < kNumberOfPortsToTry; i++) + if (Program.StartupHttpPort.HasValue) { - BloomServer.portForHttp = kStartingPort + (i * kNumberOfPortsWeNeed); - success = AttemptToOpenPort(); + BloomServer.portForHttp = Program.StartupHttpPort.Value; + success = + AreReservedCompanionPortsAvailable(BloomServer.portForHttp) + && AttemptToOpenPort(); + } + else + { + for (var i = 0; !success && i < kNumberOfPortsToTry; i++) + { + BloomServer.portForHttp = kStartingPort + (i * kNumberOfPortsWeNeed); + success = + AreReservedCompanionPortsAvailable(BloomServer.portForHttp) + && AttemptToOpenPort(); + } } if (!success) @@ -1495,6 +1517,40 @@ public virtual void EnsureListening() private static int MinWorkerThreads => Math.Max(Environment.ProcessorCount, 2); + private static bool AreReservedCompanionPortsAvailable(int httpPort) + { + return GetReservedCompanionPorts(httpPort).All(IsLoopbackPortAvailable); + } + + private static IEnumerable GetReservedCompanionPorts(int httpPort) + { + for (var offset = WebSocketPortOffset; offset < ReservedPortBlockLength; offset++) + { + yield return httpPort + offset; + } + } + + private static bool IsLoopbackPortAvailable(int port) + { + TcpListener listener = null; + + try + { + listener = new TcpListener(IPAddress.Loopback, port); + listener.Start(); + return true; + } + catch (SocketException error) + { + Logger.WriteMinorEvent($"Port {port} is unavailable: {error.Message}"); + return false; + } + finally + { + listener?.Stop(); + } + } + /// /// Tries to start listening on the currently proposed server url /// diff --git a/src/BloomExe/web/controllers/CommonApi.cs b/src/BloomExe/web/controllers/CommonApi.cs index e784f42aadd0..723befd29902 100644 --- a/src/BloomExe/web/controllers/CommonApi.cs +++ b/src/BloomExe/web/controllers/CommonApi.cs @@ -245,6 +245,7 @@ private void HandleInstanceInfo(ApiRequest request) executablePath, executableDirectory = Path.GetDirectoryName(executablePath), httpPort = BloomServer.portForHttp, + webSocketPort = BloomServer.WebSocketPort, serverUrl = BloomServer.ServerUrl, serverUrlWithBloomPrefix = BloomServer.ServerUrlWithBloomPrefixEndingInSlash, workspaceTabsUrl = BloomServer.ServerUrlWithBloomPrefixEndingInSlash diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs new file mode 100644 index 000000000000..90c876b9a869 --- /dev/null +++ b/src/BloomTests/ProgramTests.cs @@ -0,0 +1,41 @@ +using Bloom; +using NUnit.Framework; + +namespace BloomTests +{ + [TestFixture] + public class ProgramTests + { + [Test] + public void ParseStartupPortArguments_RemovesPortsAndStoresExplicitValues() + { + var remainingArgs = Program.ParseStartupPortArguments( + new[] + { + "--http-port", + "19089", + "--cdp-port=19092", + @"C:\Temp\Example.bloomcollection", + }, + out var errorMessage + ); + + Assert.That(errorMessage, Is.Null); + Assert.That(Program.StartupHttpPort, Is.EqualTo(19089)); + Assert.That(Program.StartupCdpPort, Is.EqualTo(19092)); + Assert.That(remainingArgs, Is.EqualTo(new[] { @"C:\Temp\Example.bloomcollection" })); + } + + [Test] + public void ParseStartupPortArguments_RejectsCdpPortInsideReservedHttpBlock() + { + var remainingArgs = Program.ParseStartupPortArguments( + new[] { "--http-port", "19089", "--cdp-port", "19090" }, + out var errorMessage + ); + + Assert.That(errorMessage, Does.Contain("must not overlap the reserved HTTP block")); + Assert.That(remainingArgs, Is.Empty); + } + } +} From a170199f29027ef364c24de6d09c1efafd87d432 Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 13 Mar 2026 16:45:50 -0600 Subject: [PATCH 3/9] add --label, --vite-port, show info in window title bar --- .github/skills/bloom-automation/SKILL.md | 30 +++-- .../bloom-automation/bloomProcessCommon.mjs | 4 +- .../bloomRun.mjs => scripts/watchBloomExe.mjs | 79 ++++++------ src/BloomBrowserUI/package.json | 1 + src/BloomExe/Book/Book.cs | 2 +- src/BloomExe/Edit/EditingModel.cs | 6 +- src/BloomExe/Edit/PageThumbnailList.cs | 2 + src/BloomExe/Edit/ToolboxView.cs | 10 +- src/BloomExe/Program.cs | 109 ++++++++++++++++ src/BloomExe/Shell.cs | 70 ++++++++--- src/BloomExe/Workspace/WorkspaceView.cs | 4 +- src/BloomExe/web/ReactControl.cs | 119 +++++++++++++++++- src/BloomTests/ProgramTests.cs | 5 + 13 files changed, 363 insertions(+), 78 deletions(-) rename .github/skills/bloom-automation/bloomRun.mjs => scripts/watchBloomExe.mjs (79%) diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md index 5c4bfc1970ec..120bc0b4c34f 100644 --- a/.github/skills/bloom-automation/SKILL.md +++ b/.github/skills/bloom-automation/SKILL.md @@ -19,7 +19,7 @@ Use the real embedded WebView2 inside Bloom.exe as the automation target. Determ - You need Playwright tests to hit the actual exe instead of `http://localhost:8089/bloom/CURRENTPAGE` in a separate browser tab. ## Default Assumptions -- Current repo root is derived automatically from the script location. +- Current repo root is derived automatically by the checked-in helper. - Bloom project path is `src/BloomExe/BloomExe.csproj`. - The helper launch script chooses an explicit port block by default instead of relying on Bloom's standard search. It allocates HTTP bases from `18089`, `18099`, `18109`, ... and reserves `http`, `http+1`, and `http+2`, with CDP on `http+3`. - Running Bloom reports its actual HTTP and CDP ports through `http://localhost:/bloom/api/common/instanceInfo`. @@ -35,7 +35,7 @@ repo_root="$(git rev-parse --show-toplevel)" Terminal: - In this VS Code workspace, the shared bash terminal keeps whatever cwd the previous command left behind. -- Prefer running the helper through `$repo_root/.github/skills/...` so the command does not depend on the current working directory. +- Prefer running the helper through `$repo_root/.github/skills/...` or `$repo_root/scripts/...` so the command does not depend on the current working directory. Important: - Agents using this skill MUST use the checked-in helper scripts below, not package.json aliases and not ad hoc `wmic` commands. @@ -57,7 +57,7 @@ node "$repo_root/.github/skills/bloom-automation/bloomProcessStatus.mjs" --http- Reports Bloom.exe processes, detected repo roots, attributable `dotnet watch` parents, ambiguous watchers, and whether the workspace API and CDP endpoint are reachable. Use `--running-bloom` when the user explicitly wants the already-running Bloom instead of a worktree-owned instance. This scans Bloom's standard HTTP port range, asks any running Bloom for `common/instanceInfo`, and reports the ports that instance says it is using. -Use `--http-port ` when you launched Bloom through `bloomRun.mjs` and want the exact instance that owns that HTTP port. This is the preferred multi-instance workflow because it gives you the precise Bloom PID and CDP port even when several Blooms from the same worktree are running. +Use `--http-port ` when you launched Bloom through `watchBloomExe.mjs` and want the exact instance that owns that HTTP port. This is the preferred multi-instance workflow because it gives you the precise Bloom PID and CDP port even when several Blooms from the same worktree are running. ### Kill Bloom ```bash @@ -75,15 +75,14 @@ Important: if Bloom was started with `dotnet watch run`, killing only `Bloom.exe ### Start Bloom ```bash -node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" -node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" --watch -node "$repo_root/.github/skills/bloom-automation/bloomRun.mjs" --http-port 18089 --cdp-port 18092 +node "$repo_root/scripts/watchBloomExe.mjs" +node "$repo_root/scripts/watchBloomExe.mjs" --http-port 18089 --cdp-port 18092 ``` -Use `--watch` when you expect to iterate. These helpers compute the repo root, acquire a lock on a non-overlapping port block, pass explicit `--http-port` and `--cdp-port` arguments to Bloom, and print the `dotnet` PID immediately plus the Bloom PID once `common/instanceInfo` becomes reachable. -`bloomRun.mjs` is intentionally long-lived: for normal launches it keeps running until the launched Bloom instance exits, even if `dotnet run` returns earlier after spawning `Bloom.exe`. If Bloom reports ready and then dies shortly afterward, the helper now reports that as a failed launch instead of silently succeeding. +These helpers always launch through `dotnet watch run`, compute the repo root, acquire a lock on a non-overlapping port block, pass explicit `--http-port` and `--cdp-port` arguments to Bloom, and print the `dotnet` PID immediately plus the Bloom PID once `common/instanceInfo` becomes reachable. +`watchBloomExe.mjs` is intentionally long-lived: for normal launches it keeps running under `dotnet watch run` until the launch session ends. If Bloom reports ready and then dies shortly afterward, the helper reports that as a failed launch instead of silently succeeding. -Agent workflow for `bloomRun.mjs`: +Agent workflow for `watchBloomExe.mjs`: - Start it in a background terminal. - Do not wait for the command to finish. A successful launch is the `Bloom ready. HTTP ..., websocket ..., CDP ..., Bloom PID ...` line, not process exit. - If the helper later reports that the Bloom PID exited shortly after reporting ready, treat that as a failed launch and do not target that HTTP port. @@ -140,7 +139,7 @@ Notes: ## Core Workflow 1. Run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --json` if you need to know whether an ordinary current-worktree instance is already running. -2. If you need a fresh automation-owned instance, start it with `node .github/skills/bloom-automation/bloomRun.mjs --watch` or `node .github/skills/bloom-automation/bloomRun.mjs`. +2. If you need a fresh automation-owned instance, start it with `node scripts/watchBloomExe.mjs`. 3. Copy the printed HTTP and CDP ports. If you need the exact PID later, run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --http-port --json`. 4. If you instead want to reuse a current-worktree instance that Bloom found by itself, only then use repo-root matching and `--only-mismatched` cleanup. 5. Run `node .github/skills/bloom-automation/webview2Targets.mjs --http-port --json --wait` to discover the live WebView2 target for that exact instance when you need debugging detail. @@ -173,10 +172,9 @@ Use this when the user says to reuse the already-running Bloom. ### Start with the helper, not raw watch commands - Start it from the current worktree. -- Prefer `node .github/skills/bloom-automation/bloomRun.mjs --watch` if you expect to iterate. -- Prefer `node .github/skills/bloom-automation/bloomRun.mjs` if you only need a single verification run. +- Use `node scripts/watchBloomExe.mjs` for fresh automation-owned launches. - Treat the printed HTTP port as the identity of that instance. Use `bloomProcessStatus.mjs --http-port `, `webview2Targets.mjs --http-port `, and `killBloomProcess.mjs --http-port ` to target it precisely. -- Never wait for `bloomRun.mjs` to exit as a readiness signal. It is a long-lived launcher. Wait for the `Bloom ready.` line in the background terminal output instead, and treat a later `Bloom PID ... exited shortly after reporting ready` message as a failed launch. +- Never wait for `watchBloomExe.mjs` to exit as a readiness signal. It is a long-lived launcher. Wait for the `Bloom ready.` line in the background terminal output instead, and treat a later `Bloom PID ... exited shortly after reporting ready` message as a failed launch. ### Reuse the running Bloom when the user asks for it - Run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --running-bloom --json`. @@ -217,13 +215,13 @@ These tests attach to the real Bloom.exe target over CDP and verify tab switchin ## Notes - Prefer the Node helpers over PowerShell. The Node scripts use `wmic`, `taskkill`, and `dotnet` directly because the PowerShell path proved too brittle. -- Prefer the checked-in Node helper commands over raw Windows shell commands. Subagents should normally run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --json`, `node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched`, `node .github/skills/bloom-automation/bloomRun.mjs`, `node .github/skills/bloom-automation/webview2Targets.mjs --json --wait`, and `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab edit`, not ad hoc `wmic` commands. -- `bloomRun.mjs` keeps a lightweight lease file for the chosen HTTP block while it is running so concurrent agents do not both choose the same automation ports. +- Prefer the checked-in Node helper commands over raw Windows shell commands. Subagents should normally run `node .github/skills/bloom-automation/bloomProcessStatus.mjs --json`, `node .github/skills/bloom-automation/killBloomProcess.mjs --only-mismatched`, `node scripts/watchBloomExe.mjs`, `node .github/skills/bloom-automation/webview2Targets.mjs --json --wait`, and `node .github/skills/bloom-automation/switchWorkspaceTab.mjs --running-bloom --tab edit`, not ad hoc `wmic` commands. +- `watchBloomExe.mjs` keeps a lightweight lease file for the chosen HTTP block while it is running so concurrent agents do not both choose the same automation ports. - For agent-driven launches, the background terminal is part of the control plane. Leave it running and poll its output for `Bloom ready.` instead of waiting for command completion. The helper may keep running after `dotnet` exits because it is tracking the actual Bloom process. - Exact-target cleanup is intentionally strict: `killBloomProcess.mjs --http-port ` should only kill the instance that actually reports that HTTP port, and should fail without killing anything if that target cannot be resolved. - When reporting work, include the helper commands you used so reviewers can confirm the workflow stayed on the supported path. - Wrong-worktree detection is authoritative when a real `Bloom.exe` child exists or when `dotnet watch` was started with an absolute `--project` path. -- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node .github/skills/bloom-automation/bloomRun.mjs --watch`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. +- A standalone `dotnet watch` started with a relative project path may not expose enough information to attribute it to a worktree. For current-worktree automation, start Bloom through `node scripts/watchBloomExe.mjs`, which always uses an absolute path. For the already-running Bloom workflow, use `--running-bloom` instead of trying to infer a worktree. - When more than one Bloom is running from the same worktree, repo-root matching is not enough. Use the explicit HTTP port workflow. ## Completion Checks diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index afc151991ab0..94c3713aef6f 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -205,7 +205,9 @@ export const acquireBloomPortLease = async (requestedPorts = {}) => { } if (explicitCdpPort && !explicitHttpPort) { - throw new Error("--cdp-port requires --http-port in bloomRun.mjs."); + throw new Error( + "--cdp-port requires --http-port in scripts/watchBloomExe.mjs.", + ); } const explicitPlan = explicitHttpPort diff --git a/.github/skills/bloom-automation/bloomRun.mjs b/scripts/watchBloomExe.mjs similarity index 79% rename from .github/skills/bloom-automation/bloomRun.mjs rename to scripts/watchBloomExe.mjs index ae851d6651b9..6b9452a65c2d 100644 --- a/.github/skills/bloom-automation/bloomRun.mjs +++ b/scripts/watchBloomExe.mjs @@ -1,19 +1,21 @@ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { acquireBloomPortLease, formatBloomPortPlan, - getDefaultRepoRoot, releaseBloomPortLease, waitForBloomInstanceInfo, -} from "./bloomProcessCommon.mjs"; +} from "../.github/skills/bloom-automation/bloomProcessCommon.mjs"; const parseArgs = () => { const args = process.argv.slice(2); const options = { - watch: false, - repoRoot: getDefaultRepoRoot(), + repoRoot: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ), httpPort: undefined, cdpPort: undefined, }; @@ -21,11 +23,6 @@ const parseArgs = () => { for (let i = 0; i < args.length; i++) { const arg = args[i]; - if (arg === "--watch") { - options.watch = true; - continue; - } - if (arg === "--repo-root") { options.repoRoot = args[i + 1] || options.repoRoot; i++; @@ -61,12 +58,14 @@ const options = parseArgs(); const launchTimeoutMs = 120000; const bloomMonitorPollMs = 500; const shortLivedBloomMs = 5000; +const launchesUnderWatch = true; const projectPath = path.join( options.repoRoot, "src", "BloomExe", "BloomExe.csproj", ); +const worktreeLabel = "/" + path.basename(path.resolve(options.repoRoot)) + "/"; if (!existsSync(projectPath)) { console.error( @@ -80,28 +79,19 @@ const lease = await acquireBloomPortLease({ cdpPort: options.cdpPort, }); const portPlan = lease.portPlan; -const dotnetArgs = options.watch - ? [ - "watch", - "run", - "--project", - projectPath, - "--", - "--http-port", - String(portPlan.httpPort), - "--cdp-port", - String(portPlan.cdpPort), - ] - : [ - "run", - "--project", - projectPath, - "--", - "--http-port", - String(portPlan.httpPort), - "--cdp-port", - String(portPlan.cdpPort), - ]; +const dotnetArgs = [ + "watch", + "run", + "--project", + projectPath, + "--", + "--http-port", + String(portPlan.httpPort), + "--cdp-port", + String(portPlan.cdpPort), + "--label", + worktreeLabel, +]; console.log(`Bloom launch ports: ${formatBloomPortPlan(portPlan)}`); @@ -112,7 +102,7 @@ const child = spawn("dotnet", dotnetArgs, { console.log(`dotnet PID: ${child.pid}`); console.log( - "bloomRun.mjs tracks the launched Bloom instance until it exits. Treat the 'Bloom ready.' line as the launch success signal and keep this terminal open while you target the reported HTTP port.", + "watchBloomExe.mjs tracks the launched Bloom instance until it exits. Treat the 'Bloom ready.' line as the launch success signal and keep this terminal open while you target the reported HTTP port.", ); let childExited = false; @@ -158,12 +148,19 @@ const exitForFinishedLaunch = (exitCode = 0) => { }; const startBloomMonitor = () => { - if (options.watch || bloomMonitor || !bloomProcessId) { + if (bloomMonitor || !bloomProcessId) { return; } bloomMonitor = setInterval(() => { if (isProcessRunning(bloomProcessId)) { + if ( + launchesUnderWatch && + bloomReadyAt && + Date.now() - bloomReadyAt >= shortLivedBloomMs + ) { + stopBloomMonitor(); + } return; } @@ -171,6 +168,14 @@ const startBloomMonitor = () => { const exitedTooSoon = runtimeMs !== undefined && runtimeMs < shortLivedBloomMs; + if (launchesUnderWatch && !exitedTooSoon) { + console.log( + `Bloom PID ${bloomProcessId} exited after ${runtimeMs} ms while dotnet watch remains active.`, + ); + stopBloomMonitor(); + return; + } + if (exitedTooSoon) { console.error( `Bloom PID ${bloomProcessId} exited ${runtimeMs} ms after reporting ready. Treating this as a failed launch.`, @@ -211,7 +216,7 @@ waitForBloomInstanceInfo(portPlan.httpPort, launchTimeoutMs) `Bloom ready. HTTP ${instanceInfo.httpPort}, websocket ${instanceInfo.webSocketPort || portPlan.webSocketPort}, CDP ${instanceInfo.cdpPort || portPlan.cdpPort}, Bloom PID ${instanceInfo.processId}.`, ); - if (childExited && !options.watch) { + if (childExited && !launchesUnderWatch) { console.log( `dotnet exited, but Bloom PID ${instanceInfo.processId} is still running. Continuing to monitor that Bloom process and hold the port lease.`, ); @@ -237,7 +242,11 @@ child.on("exit", (code, signal) => { childExitCode = code ?? 0; childExitSignal = signal ?? undefined; - if (bloomProcessId && isProcessRunning(bloomProcessId) && !options.watch) { + if ( + bloomProcessId && + isProcessRunning(bloomProcessId) && + !launchesUnderWatch + ) { console.log( `dotnet exited${code !== null ? ` with code ${code}` : ""}, but Bloom PID ${bloomProcessId} is still running. Waiting for Bloom to exit before releasing the port lease.`, ); diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 642e44986557..980f9308db35 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -10,6 +10,7 @@ "node": ">=22.12.0" }, "scripts": { + "exe": "node ../../scripts/watchBloomExe.mjs", "dev": "node ./scripts/dev.mjs", "// Watching: yarn dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ", "// COMMENTS: make the action a space rather than empty string so `yarn run` can list the scripts": " ", diff --git a/src/BloomExe/Book/Book.cs b/src/BloomExe/Book/Book.cs index 975009da2c66..1138be68a4da 100644 --- a/src/BloomExe/Book/Book.cs +++ b/src/BloomExe/Book/Book.cs @@ -586,7 +586,7 @@ private void AddJavaScriptForEditing(HtmlDom dom) ); HtmlDom.AddScriptFile( dom.RawDom, - "http://localhost:5173/bookEdit/editablePage.ts", + ReactControl.GetViteDevUrl("/bookEdit/editablePage.ts"), true ); } diff --git a/src/BloomExe/Edit/EditingModel.cs b/src/BloomExe/Edit/EditingModel.cs index ec2e8a27e8dd..e865f29911e7 100644 --- a/src/BloomExe/Edit/EditingModel.cs +++ b/src/BloomExe/Edit/EditingModel.cs @@ -1297,6 +1297,8 @@ internal string GetUrlForPageListFile() var _baseHtml = RobustFile .ReadAllText(frame, Encoding.UTF8) .Replace("DarkGray", backColor); + if (useViteDev) + _baseHtml = ReactControl.ReplaceViteDevOrigin(_baseHtml); var pages = CurrentBook.GetPages().ToList(); var sizeClass = pages.Count > 1 @@ -1367,8 +1369,8 @@ public HtmlDom GetXmlDocumentForEditScreenWebPage() // We don't really make a file for the page, the contents are just saved in our local server. // But we give it a url that makes it seem to be in the book folder so local urls work. // See BloomServer.MakeInMemoryHtmlFileInBookFolder() for more details. - var frameText = RobustFile - .ReadAllText(path, Encoding.UTF8) + var frameText = ReactControl + .ReplaceViteDevOrigin(RobustFile.ReadAllText(path, Encoding.UTF8)) .Replace("{simulatedPageFileInBookFolder}", GetUrlForCurrentPage()) .Replace("{simulatedPageListFile}", GetUrlForPageListFile()); var dom = new HtmlDom(XmlHtmlConverter.GetXmlDomFromHtml(frameText)); diff --git a/src/BloomExe/Edit/PageThumbnailList.cs b/src/BloomExe/Edit/PageThumbnailList.cs index 2b2f0a63fa08..af8f2797d6f4 100644 --- a/src/BloomExe/Edit/PageThumbnailList.cs +++ b/src/BloomExe/Edit/PageThumbnailList.cs @@ -84,6 +84,8 @@ public PageThumbnailList() ); var backColor = MiscUtils.ColorToHtmlCode(Palette.SidePanelBackgroundColor); _baseHtml = RobustFile.ReadAllText(frame, Encoding.UTF8).Replace("DarkGray", backColor); + if (useViteDev) + _baseHtml = ReactControl.ReplaceViteDevOrigin(_baseHtml); } private void InvokePageSelectedChanged(IPage page) diff --git a/src/BloomExe/Edit/ToolboxView.cs b/src/BloomExe/Edit/ToolboxView.cs index f8d442b92744..2c4c7250ed88 100644 --- a/src/BloomExe/Edit/ToolboxView.cs +++ b/src/BloomExe/Edit/ToolboxView.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using Bloom.Api; using Bloom.Book; using Bloom.Collection; using Bloom.web; using BloomTemp; +using SIL.IO; namespace Bloom.Edit { @@ -92,12 +94,16 @@ public static IEnumerable GetToolboxServerDirectories() public static string MakeToolboxContent(Book.Book book) { + var useViteDev = ReactControl.ShouldUseViteDev(); var path = BloomFileLocator.GetBrowserFile( false, "bookEdit/toolbox", - ReactControl.ShouldUseViteDev() ? "toolbox.vite-dev.html" : "toolbox.html" + useViteDev ? "toolbox.vite-dev.html" : "toolbox.html" ); - var domForToolbox = new HtmlDom(XmlHtmlConverter.GetXmlDomFromHtmlFile(path)); + var html = RobustFile.ReadAllText(path, Encoding.UTF8); + if (useViteDev) + html = ReactControl.ReplaceViteDevOrigin(html); + var domForToolbox = new HtmlDom(XmlHtmlConverter.GetXmlDomFromHtml(html)); XmlHtmlConverter.MakeXmlishTagsSafeForInterpretationAsHtml(domForToolbox.RawDom); return domForToolbox.getHtmlStringDisplayOnly(); } diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index e48ce504b8b7..b035cddbe45c 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -106,6 +106,8 @@ static class Program public static bool RunningSecondInstance { get; private set; } internal static int? StartupHttpPort { get; private set; } internal static int? StartupCdpPort { get; private set; } + internal static int? StartupVitePort { get; private set; } + internal static string StartupLabel { get; private set; } internal static bool StartupUsesExplicitPorts => StartupHttpPort.HasValue && StartupCdpPort.HasValue; @@ -274,6 +276,17 @@ static int Main(string[] args1) return mainTask.Result; // we're done; this is safe once there is nothing being awaited. } + if (!ValidateStartupVitePort(out var startupViteErrorMessage)) + { + MessageBox.Show( + startupViteErrorMessage, + "Bloom", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + return 1; + } + try { if (SIL.PlatformUtilities.Platform.IsWindows) @@ -710,6 +723,8 @@ internal static string[] ParseStartupPortArguments(string[] args, out string err errorMessage = null; StartupHttpPort = null; StartupCdpPort = null; + StartupVitePort = null; + StartupLabel = null; var remainingArgs = new List(); @@ -761,6 +776,52 @@ out errorMessage continue; } + if ( + TryParseStartupPortArgument( + args, + ref i, + "--vite-port", + out var vitePort, + out errorMessage + ) + ) + { + if (errorMessage != null) + return Array.Empty(); + + if (StartupVitePort.HasValue) + { + errorMessage = "Bloom only accepts one --vite-port argument."; + return Array.Empty(); + } + + StartupVitePort = vitePort; + continue; + } + + if ( + TryParseStartupStringArgument( + args, + ref i, + "--label", + out var label, + out errorMessage + ) + ) + { + if (errorMessage != null) + return Array.Empty(); + + if (StartupLabel != null) + { + errorMessage = "Bloom only accepts one --label argument."; + return Array.Empty(); + } + + StartupLabel = string.IsNullOrWhiteSpace(label) ? null : label.Trim(); + continue; + } + remainingArgs.Add(args[i]); } @@ -779,6 +840,39 @@ out errorMessage return remainingArgs.ToArray(); } + private static bool TryParseStartupStringArgument( + string[] args, + ref int index, + string optionName, + out string value, + out string errorMessage + ) + { + value = null; + errorMessage = null; + var current = args[index]; + + if (current == optionName) + { + if (index + 1 >= args.Length) + { + errorMessage = $"Bloom requires a value after {optionName}."; + return true; + } + + value = args[++index]; + return true; + } + + if (current.StartsWith(optionName + "=", StringComparison.Ordinal)) + { + value = current.Substring(optionName.Length + 1); + return true; + } + + return false; + } + private static bool TryParseStartupPortArgument( string[] args, ref int index, @@ -823,6 +917,21 @@ out string errorMessage return true; } + private static bool ValidateStartupVitePort(out string errorMessage) + { + errorMessage = null; + + if (!StartupVitePort.HasValue) + return true; + + if (ReactControl.IsViteDevServerRunning(StartupVitePort.Value)) + return true; + + errorMessage = + $"Bloom was started with --vite-port {StartupVitePort.Value}, but no Vite dev server was reachable at {ReactControl.GetViteDevOrigin(StartupVitePort.Value)}/@vite/client."; + return false; + } + private static bool IsWebviewMissingOrTooOld() { string version; diff --git a/src/BloomExe/Shell.cs b/src/BloomExe/Shell.cs index d34e7ee98c0a..311df9ff66e6 100644 --- a/src/BloomExe/Shell.cs +++ b/src/BloomExe/Shell.cs @@ -12,6 +12,7 @@ using Bloom.Properties; using Bloom.ToPalaso; using Bloom.Utils; +using Bloom.web; using Bloom.web.controllers; using Bloom.Workspace; using SIL.Extensions; @@ -249,25 +250,66 @@ protected override void OnClosing(CancelEventArgs e) public void SetWindowText(string bookName) { - // Let's only mark the window text for Alpha and Beta releases. It looks odd to have that in - // release builds, and doesn't add much since we can treat Release builds as the unmarked case. - // Note that developer builds now have a special "channel" marking as well to differentiate them - // from true Release builds in screen shots. - var formattedText = string.Format( - "{0} - Bloom {1}", - _workspaceView.Text, - GetShortVersionInfo() - ); - var channel = ApplicationUpdateSupport.ChannelName; - if (channel.ToLowerInvariant() != "release") - formattedText = string.Format("{0} {1}", formattedText, channel); - if (bookName != null) + string formattedText; + if (!string.IsNullOrWhiteSpace(Program.StartupLabel)) + { + formattedText = string.Format("Bloom {0}", Program.StartupLabel); + } + else + { + // Let's only mark the window text for Alpha and Beta releases. It looks odd to have that in + // release builds, and doesn't add much since we can treat Release builds as the unmarked case. + // Note that developer builds now have a special "channel" marking as well to differentiate them + // from true Release builds in screen shots. + formattedText = string.Format( + "{0} - Bloom {1}", + _workspaceView.Text, + GetShortVersionInfo() + ); + var channel = ApplicationUpdateSupport.ChannelName; + if (channel.ToLowerInvariant() != "release") + formattedText = string.Format("{0} {1}", formattedText, channel); + if (bookName != null) + { + formattedText = string.Format("{0} - {1}", bookName, formattedText); + } + } + + var portSummary = new[] + { + GetHttpPortTitlePart(), + GetAutomationPortTitlePart(), + GetVitePortTitlePart(), + }.Where(part => !string.IsNullOrEmpty(part)); + var portSummaryText = string.Join(" ", portSummary); + if (!string.IsNullOrEmpty(portSummaryText)) { - formattedText = string.Format("{0} - {1}", bookName, formattedText); + formattedText = string.Format("{0} - {1}", formattedText, portSummaryText); } + Text = formattedText; } + private static string GetHttpPortTitlePart() + { + var httpPort = + BloomServer.portForHttp > 0 ? BloomServer.portForHttp : Program.StartupHttpPort; + return httpPort.HasValue ? $"http:{httpPort.Value}" : null; + } + + private static string GetAutomationPortTitlePart() + { + var cdpPort = WebView2Browser.RemoteDebuggingPort; + return cdpPort.HasValue ? $"automation:{cdpPort.Value}" : null; + } + + private static string GetVitePortTitlePart() + { + return ReactControl.TryGetActiveViteDevPort(out var vitePort) + ? $"vite:{vitePort}" + : null; + } + public static string GetShortVersionInfo() { var asm = Assembly.GetExecutingAssembly(); diff --git a/src/BloomExe/Workspace/WorkspaceView.cs b/src/BloomExe/Workspace/WorkspaceView.cs index 00b3fb604932..f30b239dd825 100644 --- a/src/BloomExe/Workspace/WorkspaceView.cs +++ b/src/BloomExe/Workspace/WorkspaceView.cs @@ -278,8 +278,8 @@ private HtmlDom GetWorkspaceRootDocument() ) ); - var frameText = RobustFile - .ReadAllText(path, Encoding.UTF8) + var frameText = ReactControl + .ReplaceViteDevOrigin(RobustFile.ReadAllText(path, Encoding.UTF8)) .Replace("{simulatedPageFileInBookFolder}", "about:blank") .Replace("{simulatedPageListFile}", "about:blank"); diff --git a/src/BloomExe/web/ReactControl.cs b/src/BloomExe/web/ReactControl.cs index a09a8ee16380..10c33d69a1fa 100644 --- a/src/BloomExe/web/ReactControl.cs +++ b/src/BloomExe/web/ReactControl.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Drawing; +using System.Net.Http; using System.Windows.Forms; using Bloom.Utils; using Newtonsoft.Json; @@ -24,6 +25,8 @@ namespace Bloom.web public partial class ReactControl : UserControl { + private const int kDefaultViteDevPort = 5173; + private const string kDefaultViteDevOrigin = "http://localhost:5173"; private string _javascriptBundleName; // props to provide to the react component @@ -292,7 +295,8 @@ @keyframes spin {{ if (useViteDev) { - return $@" + return ReplaceViteDevOrigin( + $@" ReactControl (Vite {javascriptBundleName}) @@ -423,7 +427,8 @@ async function importWithFallback(primaryUrl, label, directFallbackUrl, globalNa {body} - "; + " + ); } else { @@ -453,6 +458,12 @@ async function importWithFallback(primaryUrl, label, directFallbackUrl, globalNa // if extraCheck is passed it must return true for us to use vite. public static bool ShouldUseViteDev(Func extraCheck = null) { + if (extraCheck != null && !extraCheck()) + return false; + + if (Program.StartupVitePort.HasValue) + return true; + // Should we load relevant assets from the Vite Dev server? // To save time, only consider it if this is a dev build. // This also guards against trying to load assets from the vite server @@ -460,12 +471,110 @@ public static bool ShouldUseViteDev(Func extraCheck = null) // problem if a dev is trying to run dev builds of two versions at once. if (!ApplicationUpdateSupport.IsDev) return false; - if (extraCheck != null && !extraCheck()) - return false; // If still an option, see if localhost:5173 is running. This is quite slow when it is not. // The original version used 400ms, which meant a 1200ms delay; but if it's going to succeed, // it typically does so in 2ms. I compromised on 40. - return IsLocalPortOpen(5173, 40); + return IsLocalPortOpen(kDefaultViteDevPort, 40); + } + + public static int GetViteDevPort() + { + return Program.StartupVitePort ?? kDefaultViteDevPort; + } + + public static bool TryGetActiveViteDevPort(out int vitePort) + { + if (Program.StartupVitePort.HasValue) + { + vitePort = Program.StartupVitePort.Value; + return true; + } + + if (ApplicationUpdateSupport.IsDev && IsLocalPortOpen(kDefaultViteDevPort, 40)) + { + vitePort = kDefaultViteDevPort; + return true; + } + + vitePort = 0; + return false; + } + + public static string GetViteDevOrigin(int? port = null) + { + return $"http://localhost:{port ?? GetViteDevPort()}"; + } + + public static string GetViteDevUrl(string path) + { + if (string.IsNullOrEmpty(path)) + throw new ArgumentNullException(nameof(path)); + + return GetViteDevOrigin() + (path.StartsWith("/") ? path : "/" + path); + } + + public static string ReplaceViteDevOrigin(string html) + { + if (string.IsNullOrEmpty(html) || !Program.StartupVitePort.HasValue) + return html; + + return html.Replace(kDefaultViteDevOrigin, GetViteDevOrigin()); + } + + public static bool IsViteDevServerRunning(int port, int timeoutMs = 400) + { + foreach ( + var origin in new[] + { + $"http://127.0.0.1:{port}", + $"http://[::1]:{port}", + GetViteDevOrigin(port), + } + ) + { + if (TryFetchViteClient(origin, timeoutMs)) + return true; + } + + return false; + } + + private static bool TryFetchViteClient(string origin, int timeoutMs) + { + try + { + using (var client = new HttpClient()) + { + client.Timeout = TimeSpan.FromMilliseconds(timeoutMs); + using ( + var response = client + .GetAsync(origin + "/@vite/client") + .GetAwaiter() + .GetResult() + ) + { + if (!response.IsSuccessStatusCode) + return false; + + var mediaType = response.Content.Headers.ContentType?.MediaType; + var text = response.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return ( + mediaType?.Contains( + "javascript", + StringComparison.OrdinalIgnoreCase + ) ?? false + ) + && ( + text.Contains("createHotContext", StringComparison.Ordinal) + || text.Contains("__vite", StringComparison.Ordinal) + ); + } + } + } + catch + { + return false; + } } public static bool IsLocalPortOpen(int port, int timeoutMs = 400) diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index 90c876b9a869..a6f4e18763f0 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -15,6 +15,9 @@ public void ParseStartupPortArguments_RemovesPortsAndStoresExplicitValues() "--http-port", "19089", "--cdp-port=19092", + "--vite-port", + "15173", + "--label=my-cool-feature", @"C:\Temp\Example.bloomcollection", }, out var errorMessage @@ -23,6 +26,8 @@ out var errorMessage Assert.That(errorMessage, Is.Null); Assert.That(Program.StartupHttpPort, Is.EqualTo(19089)); Assert.That(Program.StartupCdpPort, Is.EqualTo(19092)); + Assert.That(Program.StartupVitePort, Is.EqualTo(15173)); + Assert.That(Program.StartupLabel, Is.EqualTo("my-cool-feature")); Assert.That(remainingArgs, Is.EqualTo(new[] { @"C:\Temp\Example.bloomcollection" })); } From f211333d17d79de156fdd2edbc8cb7e3602bd2bc Mon Sep 17 00:00:00 2001 From: Hatton Date: Fri, 13 Mar 2026 17:52:54 -0600 Subject: [PATCH 4/9] yarn go to launch front and back end together with watching --- .../bloom-automation/bloomProcessCommon.mjs | 46 +- .../bloom-automation/bloomProcessStatus.mjs | 34 +- .../bloom-automation/killBloomProcess.mjs | 16 +- .../bloom-automation/switchWorkspaceTab.mjs | 36 +- .../bloom-automation/webview2Targets.mjs | 29 +- scripts/watchBloomExe.mjs | 50 +- src/BloomBrowserUI/package.json | 2 + src/BloomBrowserUI/scripts/go.mjs | 569 ++++++++++++++++++ src/BloomExe/Program.cs | 2 + src/BloomTests/ProgramTests.cs | 27 + 10 files changed, 773 insertions(+), 38 deletions(-) create mode 100644 src/BloomBrowserUI/scripts/go.mjs diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index 94c3713aef6f..21480d1ea34a 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -15,6 +15,8 @@ import { fileURLToPath } from "node:url"; const standardBloomStartingHttpPort = 8089; const standardBloomPortIncrement = 2; const standardBloomPortCount = 10; +// Automation launches reserve a predictable block so concurrent Blooms can avoid +// each other's HTTP, websocket, and CDP endpoints. const automationBloomStartingHttpPort = 18089; const automationBloomPortBlockSize = 10; const automationBloomPortBlockCount = 200; @@ -32,6 +34,42 @@ const toPositiveInteger = (value) => { return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; }; +export const toTcpPort = (value) => { + if (value === undefined || value === null) { + return undefined; + } + + const normalized = String(value).trim(); + if (!/^\d+$/.test(normalized)) { + return undefined; + } + + const parsed = Number(normalized); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 + ? parsed + : undefined; +}; + +export const requireTcpPortOption = (optionName, value) => { + const port = toTcpPort(value); + if (!port) { + throw new Error( + `${optionName} must be an integer from 1 to 65535. Received: ${value}`, + ); + } + + return port; +}; + +export const requireOptionValue = (args, index, optionName) => { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${optionName} requires a value.`); + } + + return value; +}; + export const getStandardBloomHttpPorts = () => Array.from( { length: standardBloomPortCount }, @@ -190,11 +228,11 @@ export const acquireBloomPortLease = async (requestedPorts = {}) => { const explicitHttpPort = requestedPorts.httpPort === undefined ? undefined - : toPositiveInteger(requestedPorts.httpPort); + : toTcpPort(requestedPorts.httpPort); const explicitCdpPort = requestedPorts.cdpPort === undefined ? undefined - : toPositiveInteger(requestedPorts.cdpPort); + : toTcpPort(requestedPorts.cdpPort); if (requestedPorts.httpPort !== undefined && !explicitHttpPort) { throw new Error("--http-port must be an integer from 1 to 65535."); @@ -300,8 +338,8 @@ export const extractRepoRoot = (text) => { }; export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { - const httpPort = toPositiveInteger(info?.httpPort) ?? discoveredViaPort; - const cdpPort = toPositiveInteger(info?.cdpPort); + const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort; + const cdpPort = toTcpPort(info?.cdpPort); return { ...info, diff --git a/.github/skills/bloom-automation/bloomProcessStatus.mjs b/.github/skills/bloom-automation/bloomProcessStatus.mjs index 262c2f987a78..31f4a3ddf831 100644 --- a/.github/skills/bloom-automation/bloomProcessStatus.mjs +++ b/.github/skills/bloom-automation/bloomProcessStatus.mjs @@ -7,6 +7,8 @@ import { getDefaultRepoRoot, getWindowsProcessSnapshot, normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, } from "./bloomProcessCommon.mjs"; const parseArgs = () => { @@ -39,24 +41,36 @@ const parseArgs = () => { } if (arg === "--http-port") { - options.httpPort = args[i + 1]; + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); i++; continue; } if (arg.startsWith("--http-port=")) { - options.httpPort = arg.slice("--http-port=".length); + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); continue; } if (arg === "--cdp-port") { - options.cdpPort = args[i + 1]; + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, i, "--cdp-port"), + ); i++; continue; } if (arg.startsWith("--cdp-port=")) { - options.cdpPort = arg.slice("--cdp-port=".length); + options.cdpPort = requireTcpPortOption( + "--cdp-port", + arg.slice("--cdp-port=".length), + ); } } @@ -71,11 +85,11 @@ const runningBloomInstances = options.runningBloom let selectedRunningBloomInstance; if (options.httpPort) { - const instanceInfo = await fetchBloomInstanceInfo(Number(options.httpPort)); + const instanceInfo = await fetchBloomInstanceInfo(options.httpPort); if (instanceInfo.reachable && instanceInfo.json) { selectedRunningBloomInstance = normalizeBloomInstanceInfo( instanceInfo.json, - Number(options.httpPort), + options.httpPort, ); } } else if (options.runningBloom) { @@ -111,12 +125,12 @@ if (selectedRunningBloomInstance?.processId) { const workspaceTabsUrl = selectedRunningBloomInstance ? selectedRunningBloomInstance.workspaceTabsUrl : options.httpPort - ? `http://localhost:${Number(options.httpPort)}/bloom/api/workspace/tabs` + ? `http://localhost:${options.httpPort}/bloom/api/workspace/tabs` : "http://localhost:8089/bloom/api/workspace/tabs"; const cdpVersionUrl = selectedRunningBloomInstance?.cdpOrigin ? `${selectedRunningBloomInstance.cdpOrigin}/json/version` : options.cdpPort - ? `http://localhost:${Number(options.cdpPort)}/json/version` + ? `http://localhost:${options.cdpPort}/json/version` : "http://localhost:9222/json/version"; const workspaceTabs = await fetchJsonEndpoint(workspaceTabsUrl); const cdpVersion = await fetchJsonEndpoint(cdpVersionUrl); @@ -128,8 +142,8 @@ const result = { ? "running-bloom" : "current-worktree", expectedRepoRoot: processState.expectedRepoRoot, - requestedHttpPort: options.httpPort ? Number(options.httpPort) : undefined, - requestedCdpPort: options.cdpPort ? Number(options.cdpPort) : undefined, + requestedHttpPort: options.httpPort, + requestedCdpPort: options.cdpPort, isRunning: selectedRunningBloomInstance ? true : processState.bloomProcesses.length > 0, diff --git a/.github/skills/bloom-automation/killBloomProcess.mjs b/.github/skills/bloom-automation/killBloomProcess.mjs index ff6528114184..7c97dde452bc 100644 --- a/.github/skills/bloom-automation/killBloomProcess.mjs +++ b/.github/skills/bloom-automation/killBloomProcess.mjs @@ -6,6 +6,8 @@ import { getWindowsProcessSnapshot, killProcessIds, normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, } from "./bloomProcessCommon.mjs"; const parseArgs = () => { @@ -39,13 +41,19 @@ const parseArgs = () => { } if (arg === "--http-port") { - options.httpPort = args[i + 1]; + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); i++; continue; } if (arg.startsWith("--http-port=")) { - options.httpPort = arg.slice("--http-port=".length); + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); continue; } @@ -87,7 +95,7 @@ if (options.httpPort) { if (instanceInfo.reachable && instanceInfo.json) { targetedInstance = normalizeBloomInstanceInfo( instanceInfo.json, - Number(options.httpPort), + options.httpPort, ); if (targetedInstance.processId) { processIds.add(targetedInstance.processId); @@ -163,7 +171,7 @@ const result = { onlyMismatched: options.onlyMismatched, exactTargetRequested, exactTargetResolutionError, - requestedHttpPort: options.httpPort ? Number(options.httpPort) : undefined, + requestedHttpPort: options.httpPort, targetedInstance, requestedProcessIds, killedProcessIds, diff --git a/.github/skills/bloom-automation/switchWorkspaceTab.mjs b/.github/skills/bloom-automation/switchWorkspaceTab.mjs index fe9f072131e0..92cbdf98d649 100644 --- a/.github/skills/bloom-automation/switchWorkspaceTab.mjs +++ b/.github/skills/bloom-automation/switchWorkspaceTab.mjs @@ -5,6 +5,8 @@ import { findRunningStandardBloomInstance, getDefaultRepoRoot, normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, } from "./bloomProcessCommon.mjs"; const parseArgs = () => { @@ -27,24 +29,36 @@ const parseArgs = () => { } if (arg === "--http-port") { - options.httpPort = args[index + 1] || options.httpPort; + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, index, "--http-port"), + ); index++; continue; } if (arg.startsWith("--http-port=")) { - options.httpPort = arg.slice("--http-port=".length); + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); continue; } if (arg === "--cdp-port") { - options.cdpPort = args[index + 1] || options.cdpPort; + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, index, "--cdp-port"), + ); index++; continue; } if (arg.startsWith("--cdp-port=")) { - options.cdpPort = arg.slice("--cdp-port=".length); + options.cdpPort = requireTcpPortOption( + "--cdp-port", + arg.slice("--cdp-port=".length), + ); continue; } @@ -165,17 +179,19 @@ const waitForActiveWorkspaceTab = async (workspaceTabsUrl, tab, timeoutMs) => { const resolveInstance = async (options) => { if (options.httpPort) { - const httpPort = Number(options.httpPort); - const response = await fetchBloomInstanceInfo(httpPort); + const response = await fetchBloomInstanceInfo(options.httpPort); if (!response.reachable || !response.json) { throw new Error( - `No Bloom instance reported common/instanceInfo on http://localhost:${httpPort}.`, + `No Bloom instance reported common/instanceInfo on http://localhost:${options.httpPort}.`, ); } - const instance = normalizeBloomInstanceInfo(response.json, httpPort); + const instance = normalizeBloomInstanceInfo( + response.json, + options.httpPort, + ); if (options.cdpPort) { - instance.cdpPort = Number(options.cdpPort); + instance.cdpPort = options.cdpPort; instance.cdpOrigin = `http://localhost:${instance.cdpPort}`; } @@ -191,7 +207,7 @@ const resolveInstance = async (options) => { } if (options.cdpPort) { - instance.cdpPort = Number(options.cdpPort); + instance.cdpPort = options.cdpPort; instance.cdpOrigin = `http://localhost:${instance.cdpPort}`; } diff --git a/.github/skills/bloom-automation/webview2Targets.mjs b/.github/skills/bloom-automation/webview2Targets.mjs index f62fb8e74e40..f2500852c96a 100644 --- a/.github/skills/bloom-automation/webview2Targets.mjs +++ b/.github/skills/bloom-automation/webview2Targets.mjs @@ -2,6 +2,8 @@ import { fetchBloomInstanceInfo, findRunningStandardBloomInstance, normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, } from "./bloomProcessCommon.mjs"; const parseArgs = () => { @@ -35,13 +37,19 @@ const parseArgs = () => { } if (arg === "--http-port") { - options.httpPort = args[i + 1] || options.httpPort; + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); i++; continue; } if (arg.startsWith("--http-port=")) { - options.httpPort = arg.slice("--http-port=".length); + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); continue; } @@ -57,11 +65,22 @@ const parseArgs = () => { } if (arg === "--port") { - options.port = args[i + 1] || options.port; + options.port = requireTcpPortOption( + "--port", + requireOptionValue(args, i, "--port"), + ); i++; continue; } + if (arg.startsWith("--port=")) { + options.port = requireTcpPortOption( + "--port", + arg.slice("--port=".length), + ); + continue; + } + if (arg === "--timeout-ms") { options.timeoutMs = Number(args[i + 1] || options.timeoutMs); i++; @@ -163,7 +182,7 @@ const main = async () => { try { if (options.httpPort) { const instanceInfo = await fetchBloomInstanceInfo( - Number(options.httpPort), + options.httpPort, ); if (!instanceInfo.reachable || !instanceInfo.json) { throw new Error( @@ -173,7 +192,7 @@ const main = async () => { selectedInstance = normalizeBloomInstanceInfo( instanceInfo.json, - Number(options.httpPort), + options.httpPort, ); if (!selectedInstance.cdpOrigin) { throw new Error( diff --git a/scripts/watchBloomExe.mjs b/scripts/watchBloomExe.mjs index 6b9452a65c2d..e3dbd60647fd 100644 --- a/scripts/watchBloomExe.mjs +++ b/scripts/watchBloomExe.mjs @@ -5,6 +5,8 @@ import { fileURLToPath } from "node:url"; import { acquireBloomPortLease, formatBloomPortPlan, + requireOptionValue, + requireTcpPortOption, releaseBloomPortLease, waitForBloomInstanceInfo, } from "../.github/skills/bloom-automation/bloomProcessCommon.mjs"; @@ -18,36 +20,66 @@ const parseArgs = () => { ), httpPort: undefined, cdpPort: undefined, + vitePort: undefined, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === "--repo-root") { - options.repoRoot = args[i + 1] || options.repoRoot; + options.repoRoot = requireOptionValue(args, i, "--repo-root"); i++; continue; } if (arg === "--http-port") { - options.httpPort = args[i + 1]; + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); i++; continue; } if (arg.startsWith("--http-port=")) { - options.httpPort = arg.slice("--http-port=".length); + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); continue; } if (arg === "--cdp-port") { - options.cdpPort = args[i + 1]; + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, i, "--cdp-port"), + ); i++; continue; } if (arg.startsWith("--cdp-port=")) { - options.cdpPort = arg.slice("--cdp-port=".length); + options.cdpPort = requireTcpPortOption( + "--cdp-port", + arg.slice("--cdp-port=".length), + ); + continue; + } + + if (arg === "--vite-port") { + options.vitePort = requireTcpPortOption( + "--vite-port", + requireOptionValue(args, i, "--vite-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--vite-port=")) { + options.vitePort = requireTcpPortOption( + "--vite-port", + arg.slice("--vite-port=".length), + ); } } @@ -93,8 +125,16 @@ const dotnetArgs = [ worktreeLabel, ]; +if (options.vitePort) { + dotnetArgs.push("--vite-port", String(options.vitePort)); +} + console.log(`Bloom launch ports: ${formatBloomPortPlan(portPlan)}`); +if (options.vitePort) { + console.log(`Bloom Vite dev port: ${options.vitePort}`); +} + const child = spawn("dotnet", dotnetArgs, { stdio: "inherit", shell: false, diff --git a/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 980f9308db35..404ca6898c7d 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -12,6 +12,8 @@ "scripts": { "exe": "node ../../scripts/watchBloomExe.mjs", "dev": "node ./scripts/dev.mjs", + "// 'go': starts yarn dev on a random Vite port, waits for it to settle, then launches Bloom with that --vite-port": " ", + "go": "node ./scripts/go.mjs", "// Watching: yarn dev starts vite + file watchers (LESS, pug, static assets, and key content folders)": " ", "// COMMENTS: make the action a space rather than empty string so `yarn run` can list the scripts": " ", "test": "vitest run", diff --git a/src/BloomBrowserUI/scripts/go.mjs b/src/BloomBrowserUI/scripts/go.mjs new file mode 100644 index 000000000000..11b09b2b7d9b --- /dev/null +++ b/src/BloomBrowserUI/scripts/go.mjs @@ -0,0 +1,569 @@ +import { spawn } from "node:child_process"; +import net from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const browserUIRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(browserUIRoot, "..", ".."); +const devScriptPath = path.join(browserUIRoot, "scripts", "dev.mjs"); +const exeScriptPath = path.join(repoRoot, "scripts", "watchBloomExe.mjs"); +const startupQuietMs = 1500; +const viteHealthTimeoutMs = 15000; +const viteHealthPollMs = 250; +const maxRandomVitePortAttempts = 10; +const gracefulShutdownMs = 1500; +const toViteOrigin = (port) => `http://localhost:${port}`; + +const parsePositiveInteger = (value) => { + const parsed = Number.parseInt(value, 10); + if (Number.isInteger(parsed) && parsed > 0 && parsed <= 65535) { + return parsed; + } + + return undefined; +}; + +const requireOptionValue = (args, index, optionName) => { + const value = args[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${optionName} requires a value.`); + } + + return value; +}; + +const parseRequiredPortValue = (optionName, value) => { + const parsed = parsePositiveInteger(value); + if (!parsed) { + throw new Error( + `${optionName} must be an integer from 1 to 65535. Received: ${value}`, + ); + } + + return parsed; +}; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + vitePort: undefined, + httpPort: undefined, + cdpPort: undefined, + }; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + + if (arg === "--vite-port") { + options.vitePort = parseRequiredPortValue( + "--vite-port", + requireOptionValue(args, index, "--vite-port"), + ); + index++; + continue; + } + + if (arg.startsWith("--vite-port=")) { + options.vitePort = parseRequiredPortValue( + "--vite-port", + arg.slice("--vite-port=".length), + ); + continue; + } + + if (arg === "--http-port") { + options.httpPort = parseRequiredPortValue( + "--http-port", + requireOptionValue(args, index, "--http-port"), + ); + index++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = parseRequiredPortValue( + "--http-port", + arg.slice("--http-port=".length), + ); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = parseRequiredPortValue( + "--cdp-port", + requireOptionValue(args, index, "--cdp-port"), + ); + index++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = parseRequiredPortValue( + "--cdp-port", + arg.slice("--cdp-port=".length), + ); + } + } + + return options; +}; + +const options = parseArgs(); + +const children = []; +let isShuttingDown = false; + +const delay = (milliseconds) => + new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const createPrefixedWriter = (prefix, target, onText) => { + let buffered = ""; + + const flushLines = (text) => { + buffered += text.replace(/\r/g, "\n"); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + + for (const line of lines) { + target.write(`${prefix}${line}\n`); + } + }; + + return { + write: (chunk) => { + const text = chunk.toString(); + onText?.(text); + flushLines(text); + }, + flush: () => { + if (!buffered) { + return; + } + + target.write(`${prefix}${buffered}\n`); + buffered = ""; + }, + }; +}; + +const pipeChildOutput = (child, prefix, onText) => { + const stdoutWriter = createPrefixedWriter(prefix, process.stdout, onText); + const stderrWriter = createPrefixedWriter(prefix, process.stderr, onText); + + child.stdout.on("data", stdoutWriter.write); + child.stderr.on("data", stderrWriter.write); + child.stdout.on("end", stdoutWriter.flush); + child.stderr.on("end", stderrWriter.flush); +}; + +const canListenOnLoopbackPort = (port) => + new Promise((resolve) => { + const server = net.createServer(); + let settled = false; + + const finish = (result) => { + if (settled) { + return; + } + + settled = true; + resolve(result); + }; + + server.once("error", () => { + server.close(() => finish(false)); + }); + + server.once("listening", () => { + server.close(() => finish(true)); + }); + + server.listen({ + host: "127.0.0.1", + port, + exclusive: true, + }); + }); + +const pickRandomAvailablePort = () => + new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once("error", reject); + server.listen({ host: "127.0.0.1", port: 0, exclusive: true }, () => { + const address = server.address(); + const port = + typeof address === "object" && address + ? address.port + : undefined; + + server.close((error) => { + if (error) { + reject(error); + return; + } + + if (!port) { + reject(new Error("Unable to choose a Vite dev port.")); + return; + } + + resolve(port); + }); + }); + }); + +const isViteClientReachable = async (port) => { + try { + const response = await fetch(`${toViteOrigin(port)}/@vite/client`, { + signal: AbortSignal.timeout(500), + }); + return response.ok; + } catch { + return false; + } +}; + +const waitForViteClient = async (port, timeoutMs) => { + const deadline = Date.now() + timeoutMs; + let consecutiveSuccesses = 0; + + while (!isShuttingDown && Date.now() < deadline) { + if (await isViteClientReachable(port)) { + consecutiveSuccesses++; + if (consecutiveSuccesses >= 2) { + return true; + } + } else { + consecutiveSuccesses = 0; + } + + await delay(viteHealthPollMs); + } + + return false; +}; + +const terminateChild = (child) => + new Promise((resolve) => { + if (!child || child.exitCode !== null || child.signalCode) { + resolve(); + return; + } + + let settled = false; + let forceTimer; + + const finish = () => { + if (settled) { + return; + } + + settled = true; + if (forceTimer) { + clearTimeout(forceTimer); + } + resolve(); + }; + + child.once("exit", finish); + + try { + child.kill("SIGINT"); + } catch { + finish(); + return; + } + + forceTimer = setTimeout(() => { + if (settled) { + return; + } + + if (process.platform === "win32") { + const killer = spawn( + "taskkill", + ["/pid", String(child.pid), "/t", "/f"], + { + stdio: "ignore", + shell: false, + }, + ); + + killer.on("exit", finish); + killer.on("error", finish); + return; + } + + try { + child.kill("SIGTERM"); + } catch {} + + setTimeout(finish, 250); + }, gracefulShutdownMs); + }); + +const shutdown = async (exitCode = 0) => { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + const normalizedExitCode = Number.isInteger(exitCode) ? exitCode : 1; + console.log(`[go] Shutting down (exit ${normalizedExitCode})...`); + + await Promise.all(children.map((child) => terminateChild(child))); + process.exit(normalizedExitCode); +}; + +process.on("SIGINT", () => { + void shutdown(0); +}); + +process.on("SIGTERM", () => { + void shutdown(0); +}); + +const buildStartupError = (message, logTail) => { + const error = new Error(message); + error.portConflict = /already in use|EADDRINUSE/i.test(logTail); + return error; +}; + +const startDevServerOnPort = (port) => + new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [devScriptPath, "--port", String(port)], + { + cwd: browserUIRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + env: { + ...process.env, + PORT: String(port), + }, + }, + ); + + children.push(child); + + let quietTimer; + let startupFinished = false; + let logTail = ""; + let sawReady = false; + let sawInitialBuild = false; + let sawWatchersStarted = false; + let lastOutputAt = Date.now(); + + const scheduleQuiescenceCheck = () => { + if (startupFinished || isShuttingDown) { + return; + } + + if (!(sawReady && sawInitialBuild && sawWatchersStarted)) { + return; + } + + clearTimeout(quietTimer); + quietTimer = setTimeout(async () => { + if (startupFinished || isShuttingDown) { + return; + } + + if (Date.now() - lastOutputAt < startupQuietMs) { + scheduleQuiescenceCheck(); + return; + } + + const healthy = await waitForViteClient( + port, + viteHealthTimeoutMs, + ); + if (!healthy) { + reject( + buildStartupError( + `Vite on port ${port} never became reachable at /@vite/client.`, + logTail, + ), + ); + return; + } + + if (Date.now() - lastOutputAt < startupQuietMs) { + scheduleQuiescenceCheck(); + return; + } + + startupFinished = true; + resolve({ child, port }); + }, startupQuietMs); + }; + + const observeText = (text) => { + lastOutputAt = Date.now(); + logTail = (logTail + text).slice(-12000); + + if (logTail.includes("ready in")) { + sawReady = true; + } + + if (logTail.includes("Initial build done")) { + sawInitialBuild = true; + } + + if (logTail.includes("Watching appearance migration files...")) { + sawWatchersStarted = true; + } + + scheduleQuiescenceCheck(); + }; + + pipeChildOutput(child, "[dev] ", observeText); + + child.on("error", (error) => { + if (startupFinished || isShuttingDown) { + return; + } + + clearTimeout(quietTimer); + reject( + buildStartupError( + `Failed to start Vite dev: ${error.message}`, + logTail, + ), + ); + }); + + child.on("exit", (code, signal) => { + clearTimeout(quietTimer); + + if (startupFinished) { + if (!isShuttingDown) { + const detail = signal + ? `signal ${signal}` + : `code ${code ?? 0}`; + console.error( + `[go] Vite dev exited unexpectedly with ${detail}.`, + ); + void shutdown(code ?? 1); + } + return; + } + + if (isShuttingDown) { + return; + } + + reject( + buildStartupError( + `Vite exited before becoming quiescent (code ${code ?? 0}${signal ? `, signal ${signal}` : ""}).`, + logTail, + ), + ); + }); + }); + +const startDevServer = async () => { + if (options.vitePort) { + const available = await canListenOnLoopbackPort(options.vitePort); + if (!available) { + throw new Error( + `Requested Vite port ${options.vitePort} is already in use.`, + ); + } + + console.log( + `[go] Starting Vite on requested port ${options.vitePort}...`, + ); + return startDevServerOnPort(options.vitePort); + } + + let lastError; + + for (let attempt = 1; attempt <= maxRandomVitePortAttempts; attempt++) { + const port = await pickRandomAvailablePort(); + console.log( + `[go] Starting Vite on random port ${port} (attempt ${attempt}/${maxRandomVitePortAttempts})...`, + ); + + try { + return await startDevServerOnPort(port); + } catch (error) { + lastError = error; + if (!error?.portConflict) { + throw error; + } + + console.error( + `[go] Vite could not hold port ${port} through startup. Retrying with a new random port...`, + ); + } + } + + throw lastError ?? new Error("Unable to start Vite on a random port."); +}; + +const startBloomExe = (vitePort) => { + const args = [ + exeScriptPath, + "--repo-root", + repoRoot, + "--vite-port", + String(vitePort), + ]; + + if (options.httpPort) { + args.push("--http-port", String(options.httpPort)); + } + + if (options.cdpPort) { + args.push("--cdp-port", String(options.cdpPort)); + } + + const child = spawn(process.execPath, args, { + cwd: browserUIRoot, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + }); + + children.push(child); + pipeChildOutput(child, "[exe] "); + + child.on("error", (error) => { + if (isShuttingDown) { + return; + } + + console.error(`[go] Failed to start Bloom exe flow: ${error.message}`); + void shutdown(1); + }); + + child.on("exit", (code, signal) => { + if (isShuttingDown) { + return; + } + + const detail = signal ? `signal ${signal}` : `code ${code ?? 0}`; + console.error(`[go] Bloom exe flow exited with ${detail}.`); + void shutdown(code ?? 1); + }); +}; + +const main = async () => { + console.log( + "[go] Launching Vite first and waiting for it to go quiet before starting Bloom.", + ); + const dev = await startDevServer(); + console.log( + `[go] Vite is reachable and quiet on port ${dev.port}. Starting Bloom...`, + ); + startBloomExe(dev.port); +}; + +main().catch((error) => { + console.error(`[go] ${error.message}`); + void shutdown(1); +}); diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index b035cddbe45c..ddeff068f172 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -540,6 +540,8 @@ static int Main(string[] args1) { if (StartupUsesExplicitPorts) { + // Explicit HTTP/CDP ports are the intentional multi-instance path. + // Since this is a developer-only situation, we won't worry about the possibility of multiple instances writing on the same collection settings or whatever. Logger.WriteEvent( $"Bypassing Bloom's single-instance token because explicit ports were requested. httpPort={StartupHttpPort.Value}, cdpPort={StartupCdpPort.Value}" ); diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index a6f4e18763f0..f9c3a0f6cc4e 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -42,5 +42,32 @@ out var errorMessage Assert.That(errorMessage, Does.Contain("must not overlap the reserved HTTP block")); Assert.That(remainingArgs, Is.Empty); } + + [Test] + public void ParseStartupPortArguments_RejectsDuplicatePortArguments() + { + var remainingArgs = Program.ParseStartupPortArguments( + new[] { "--http-port", "19089", "--http-port", "19091" }, + out var errorMessage + ); + + Assert.That(errorMessage, Is.EqualTo("Bloom only accepts one --http-port argument.")); + Assert.That(remainingArgs, Is.Empty); + } + + [Test] + public void ParseStartupPortArguments_RejectsOutOfRangePorts() + { + var remainingArgs = Program.ParseStartupPortArguments( + new[] { "--vite-port", "70000" }, + out var errorMessage + ); + + Assert.That( + errorMessage, + Is.EqualTo("Bloom requires --vite-port to be an integer from 1 to 65535.") + ); + Assert.That(remainingArgs, Is.Empty); + } } } From ca63b5d46a7e396b58a1cbc65c4261807805ce5f Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 14 Mar 2026 09:55:35 -0600 Subject: [PATCH 5/9] Address multiple-instance PR feedback --- .../bloom-automation/bloomProcessCommon.mjs | 41 ++++++++++--------- src/BloomExe/Program.cs | 18 ++++++-- src/BloomExe/web/BloomServer.cs | 2 +- src/BloomTests/ProgramTests.cs | 22 ++++++++++ 4 files changed, 60 insertions(+), 23 deletions(-) diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index 21480d1ea34a..2aca6b54e71d 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -131,25 +131,28 @@ const tryAcquireBloomPortLeaseFile = (portPlan) => { for (let attempt = 0; attempt < 2; attempt++) { try { const fd = openSync(leasePath, "wx"); - writeFileSync( - fd, - JSON.stringify( - { - ownerPid: process.pid, - httpPort: portPlan.httpPort, - cdpPort: portPlan.cdpPort, - createdAt: new Date().toISOString(), - }, - null, - 2, - ), - ); - closeSync(fd); - return { - path: leasePath, - ownerPid: process.pid, - portPlan, - }; + try { + writeFileSync( + fd, + JSON.stringify( + { + ownerPid: process.pid, + httpPort: portPlan.httpPort, + cdpPort: portPlan.cdpPort, + createdAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + return { + path: leasePath, + ownerPid: process.pid, + portPlan, + }; + } finally { + closeSync(fd); + } } catch (error) { if (error?.code !== "EEXIST") { throw error; diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index ddeff068f172..585adf25db36 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -109,7 +109,19 @@ static class Program internal static int? StartupVitePort { get; private set; } internal static string StartupLabel { get; private set; } internal static bool StartupUsesExplicitPorts => - StartupHttpPort.HasValue && StartupCdpPort.HasValue; + StartupHttpPort.HasValue || StartupCdpPort.HasValue || StartupVitePort.HasValue; + + internal static string StartupRequestedPortSummary => + string.Join( + ", ", + new[] + { + StartupHttpPort.HasValue ? $"httpPort={StartupHttpPort.Value}" : null, + StartupCdpPort.HasValue ? $"cdpPort={StartupCdpPort.Value}" : null, + StartupVitePort.HasValue ? $"vitePort={StartupVitePort.Value}" : null, + } + .Where(value => value != null) + ); [STAThread] [HandleProcessCorruptedStateExceptions] @@ -540,10 +552,10 @@ static int Main(string[] args1) { if (StartupUsesExplicitPorts) { - // Explicit HTTP/CDP ports are the intentional multi-instance path. + // Explicit startup ports are the intentional multi-instance path. // Since this is a developer-only situation, we won't worry about the possibility of multiple instances writing on the same collection settings or whatever. Logger.WriteEvent( - $"Bypassing Bloom's single-instance token because explicit ports were requested. httpPort={StartupHttpPort.Value}, cdpPort={StartupCdpPort.Value}" + $"Bypassing Bloom's single-instance token because explicit ports were requested. {StartupRequestedPortSummary}" ); } else if ((Control.ModifierKeys & Keys.Control) == Keys.Control) diff --git a/src/BloomExe/web/BloomServer.cs b/src/BloomExe/web/BloomServer.cs index 89661aa63ddf..e73fd6e18e80 100644 --- a/src/BloomExe/web/BloomServer.cs +++ b/src/BloomExe/web/BloomServer.cs @@ -1467,7 +1467,7 @@ public virtual void EnsureListening() const int kStartingPort = 8089; const int kNumberOfPortsToTry = 10; bool success = false; - const int kNumberOfPortsWeNeed = 2; //one for http, one for peakLevel webSocket + const int kNumberOfPortsWeNeed = ReservedPortBlockLength; //Note: while this will find a port for the http, it does not actually know if the accompanying //ports are available. It just assume they are. diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index f9c3a0f6cc4e..ce110cd7b72d 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -31,6 +31,28 @@ out var errorMessage Assert.That(remainingArgs, Is.EqualTo(new[] { @"C:\Temp\Example.bloomcollection" })); } + [Test] + public void ParseStartupPortArguments_UsesAnyExplicitPortToBypassSingleInstance() + { + Program.ParseStartupPortArguments(new[] { "--http-port", "19089" }, out var httpErrorMessage); + + Assert.That(httpErrorMessage, Is.Null); + Assert.That(Program.StartupUsesExplicitPorts, Is.True); + Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("httpPort=19089")); + + Program.ParseStartupPortArguments(new[] { "--cdp-port", "19092" }, out var cdpErrorMessage); + + Assert.That(cdpErrorMessage, Is.Null); + Assert.That(Program.StartupUsesExplicitPorts, Is.True); + Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("cdpPort=19092")); + + Program.ParseStartupPortArguments(new[] { "--vite-port", "15173" }, out var viteErrorMessage); + + Assert.That(viteErrorMessage, Is.Null); + Assert.That(Program.StartupUsesExplicitPorts, Is.True); + Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("vitePort=15173")); + } + [Test] public void ParseStartupPortArguments_RejectsCdpPortInsideReservedHttpBlock() { From f094681aaa0f1b6d4ab5acb761fd6993051ec17f Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 14 Mar 2026 09:57:02 -0600 Subject: [PATCH 6/9] Apply formatter after PR feedback fixes --- src/BloomExe/Program.cs | 11 +++++------ src/BloomTests/ProgramTests.cs | 15 ++++++++++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/BloomExe/Program.cs b/src/BloomExe/Program.cs index 585adf25db36..f3a63c301346 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -115,12 +115,11 @@ static class Program string.Join( ", ", new[] - { - StartupHttpPort.HasValue ? $"httpPort={StartupHttpPort.Value}" : null, - StartupCdpPort.HasValue ? $"cdpPort={StartupCdpPort.Value}" : null, - StartupVitePort.HasValue ? $"vitePort={StartupVitePort.Value}" : null, - } - .Where(value => value != null) + { + StartupHttpPort.HasValue ? $"httpPort={StartupHttpPort.Value}" : null, + StartupCdpPort.HasValue ? $"cdpPort={StartupCdpPort.Value}" : null, + StartupVitePort.HasValue ? $"vitePort={StartupVitePort.Value}" : null, + }.Where(value => value != null) ); [STAThread] diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs index ce110cd7b72d..cec03fd175b4 100644 --- a/src/BloomTests/ProgramTests.cs +++ b/src/BloomTests/ProgramTests.cs @@ -34,19 +34,28 @@ out var errorMessage [Test] public void ParseStartupPortArguments_UsesAnyExplicitPortToBypassSingleInstance() { - Program.ParseStartupPortArguments(new[] { "--http-port", "19089" }, out var httpErrorMessage); + Program.ParseStartupPortArguments( + new[] { "--http-port", "19089" }, + out var httpErrorMessage + ); Assert.That(httpErrorMessage, Is.Null); Assert.That(Program.StartupUsesExplicitPorts, Is.True); Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("httpPort=19089")); - Program.ParseStartupPortArguments(new[] { "--cdp-port", "19092" }, out var cdpErrorMessage); + Program.ParseStartupPortArguments( + new[] { "--cdp-port", "19092" }, + out var cdpErrorMessage + ); Assert.That(cdpErrorMessage, Is.Null); Assert.That(Program.StartupUsesExplicitPorts, Is.True); Assert.That(Program.StartupRequestedPortSummary, Is.EqualTo("cdpPort=19092")); - Program.ParseStartupPortArguments(new[] { "--vite-port", "15173" }, out var viteErrorMessage); + Program.ParseStartupPortArguments( + new[] { "--vite-port", "15173" }, + out var viteErrorMessage + ); Assert.That(viteErrorMessage, Is.Null); Assert.That(Program.StartupUsesExplicitPorts, Is.True); From 7bb5c87ed9f8d577b6523cd659c7b3253349e951 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 14 Mar 2026 10:08:20 -0600 Subject: [PATCH 7/9] Revert VS Code workspace config changes --- .vscode/settings.json | 10 +--------- .vscode/tasks.json | 12 ------------ 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 0a17430c5da7..07a34c23f284 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -68,13 +68,5 @@ "Winforms", "xmatter" ], - "chat.useNestedAgentsMdFiles": true, - "workbench.colorCustomizations": { - "statusBar.background": "#215732", - "statusBar.foreground": "#e7e7e7", - "statusBarItem.hoverBackground": "#2f7c47", - "statusBarItem.remoteBackground": "#215732", - "statusBarItem.remoteForeground": "#e7e7e7" - }, - "peacock.color": "#215732" + "chat.useNestedAgentsMdFiles": true } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b02b4b3530ff..090c48023787 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -36,18 +36,6 @@ "${workspaceFolder}/Bloom.sln" ], "problemMatcher": "$msCompile" - }, - { - "label": "bloom-exe-run", - "type": "shell", - "command": "dotnet", - "args": [ - "run", - "--project", - "${workspaceFolder}/src/BloomExe/BloomExe.csproj" - ], - "isBackground": true, - "group": "build" } ] } From 59171d7e76b4cc5999e57f3dc6275e6fc965ee13 Mon Sep 17 00:00:00 2001 From: Hatton Date: Sat, 14 Mar 2026 10:21:02 -0600 Subject: [PATCH 8/9] add top level `go.sh`, have it turn off feedback --- ReadMe.md | 22 +++++----------------- go.sh | 4 ++++ src/BloomBrowserUI/scripts/go.mjs | 7 ++++++- 3 files changed, 15 insertions(+), 18 deletions(-) create mode 100644 go.sh diff --git a/ReadMe.md b/ReadMe.md index 07d23b3c7286..c5bd53a99da1 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -6,8 +6,6 @@ Internally, Bloom is a hybrid. It started as a C#/WinForms app with an embedded # Building -#### Install Dependencies - 1. Install [volta](https://docs.volta.sh/guide/getting-started) globally on your computer. This will provide you with the correct node and yarn. You may need to restart your computer for the installation to take effect. 2. Install other dependencies: @@ -16,27 +14,17 @@ Internally, Bloom is a hybrid. It started as a C#/WinForms app with an embedded ./init.sh ``` -#### Build the front-end (browser) part - -In `/src/BloomBrowserUI`, run `yarn dev` for a hot-reloading dev server. Alternatively, you can run `yarn build` to generate the needed files once. - -#### Build the back-end (.NET) part - -##### In VS Code: - -Make sure you have the C# Devkit extensions installed. Run and Debug through the gui or F5. - -##### In Visual Studio: +3. Run a hot-reloading server for the front end and a "watch" run of the back end. +```bash +./go.sh +``` -1. Open Bloom.sln in Visual Studio -2. Build the "WebView2PdfMaker" project -3. Run the "BloomExe" project # Developing ### Go Dark -We don't want developer and tester runs (and crashes) polluting our statistics. On Windows, add the environment variable "feedback" with value "off". +We don't want developer and tester runs (and crashes) polluting our statistics. Add the environment variable "feedback" with value "off". ### Set up formatting diff --git a/go.sh b/go.sh new file mode 100644 index 000000000000..ef93d629015a --- /dev/null +++ b/go.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec node ./src/BloomBrowserUI/scripts/go.mjs "$@" \ No newline at end of file diff --git a/src/BloomBrowserUI/scripts/go.mjs b/src/BloomBrowserUI/scripts/go.mjs index 11b09b2b7d9b..cee68afba011 100644 --- a/src/BloomBrowserUI/scripts/go.mjs +++ b/src/BloomBrowserUI/scripts/go.mjs @@ -1,3 +1,5 @@ +/* eslint-env node */ +/* global AbortSignal, clearTimeout, console, fetch, process, setTimeout */ import { spawn } from "node:child_process"; import net from "node:net"; import path from "node:path"; @@ -9,6 +11,7 @@ const browserUIRoot = path.resolve(__dirname, ".."); const repoRoot = path.resolve(browserUIRoot, "..", ".."); const devScriptPath = path.join(browserUIRoot, "scripts", "dev.mjs"); const exeScriptPath = path.join(repoRoot, "scripts", "watchBloomExe.mjs"); +process.env.feedback = "off"; const startupQuietMs = 1500; const viteHealthTimeoutMs = 15000; const viteHealthPollMs = 250; @@ -299,7 +302,9 @@ const terminateChild = (child) => try { child.kill("SIGTERM"); - } catch {} + } catch (error) { + void error; + } setTimeout(finish, 250); }, gracefulShutdownMs); From ef02624f2efdc431b6effeed7f25d5b6cecd1ad1 Mon Sep 17 00:00:00 2001 From: Hatton Date: Mon, 23 Mar 2026 13:20:57 -0600 Subject: [PATCH 9/9] Fix Bloom automation port scanning and CRLF output --- .../bloom-automation/bloomProcessCommon.mjs | 5 ++- src/BloomBrowserUI/scripts/go.mjs | 39 +++++++++++++++---- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/skills/bloom-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs index 2aca6b54e71d..d8f0ab327c7f 100644 --- a/.github/skills/bloom-automation/bloomProcessCommon.mjs +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -13,7 +13,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const standardBloomStartingHttpPort = 8089; -const standardBloomPortIncrement = 2; +const standardBloomReservedPortBlockLength = 3; const standardBloomPortCount = 10; // Automation launches reserve a predictable block so concurrent Blooms can avoid // each other's HTTP, websocket, and CDP endpoints. @@ -74,7 +74,8 @@ export const getStandardBloomHttpPorts = () => Array.from( { length: standardBloomPortCount }, (_, index) => - standardBloomStartingHttpPort + index * standardBloomPortIncrement, + standardBloomStartingHttpPort + + index * standardBloomReservedPortBlockLength, ); export const getDefaultRepoRoot = () => diff --git a/src/BloomBrowserUI/scripts/go.mjs b/src/BloomBrowserUI/scripts/go.mjs index cee68afba011..16e61d068a7d 100644 --- a/src/BloomBrowserUI/scripts/go.mjs +++ b/src/BloomBrowserUI/scripts/go.mjs @@ -125,13 +125,34 @@ const createPrefixedWriter = (prefix, target, onText) => { let buffered = ""; const flushLines = (text) => { - buffered += text.replace(/\r/g, "\n"); - const lines = buffered.split("\n"); - buffered = lines.pop() ?? ""; + buffered += text; + let lineStart = 0; + + for (let index = 0; index < buffered.length; index++) { + const current = buffered[index]; + if (current === "\n") { + target.write(`${prefix}${buffered.slice(lineStart, index)}\n`); + lineStart = index + 1; + continue; + } + + if (current !== "\r") { + continue; + } + + if (index === buffered.length - 1) { + break; + } + + target.write(`${prefix}${buffered.slice(lineStart, index)}\n`); + if (buffered[index + 1] === "\n") { + index++; + } - for (const line of lines) { - target.write(`${prefix}${line}\n`); + lineStart = index + 1; } + + buffered = buffered.slice(lineStart); }; return { @@ -141,11 +162,15 @@ const createPrefixedWriter = (prefix, target, onText) => { flushLines(text); }, flush: () => { - if (!buffered) { + const remainingLine = buffered.endsWith("\r") + ? buffered.slice(0, -1) + : buffered; + if (!remainingLine) { + buffered = ""; return; } - target.write(`${prefix}${buffered}\n`); + target.write(`${prefix}${remainingLine}\n`); buffered = ""; }, };