diff --git a/.github/skills/bloom-automation/SKILL.md b/.github/skills/bloom-automation/SKILL.md new file mode 100644 index 000000000000..120bc0b4c34f --- /dev/null +++ b/.github/skills/bloom-automation/SKILL.md @@ -0,0 +1,251 @@ +--- +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 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`. +- 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/...` 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. +- 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 `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 +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/scripts/watchBloomExe.mjs" +node "$repo_root/scripts/watchBloomExe.mjs" --http-port 18089 --cdp-port 18092 +``` + +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 `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. +- 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 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. +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. +- 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 `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`. +- 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 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 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 +- 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-automation/bloomProcessCommon.mjs b/.github/skills/bloom-automation/bloomProcessCommon.mjs new file mode 100644 index 000000000000..d8f0ab327c7f --- /dev/null +++ b/.github/skills/bloom-automation/bloomProcessCommon.mjs @@ -0,0 +1,602 @@ +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 standardBloomReservedPortBlockLength = 3; +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; +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`; +const toPositiveInteger = (value) => { + const parsed = Number(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 }, + (_, index) => + standardBloomStartingHttpPort + + index * standardBloomReservedPortBlockLength, + ); + +export const getDefaultRepoRoot = () => + path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "..", + ); + +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"); + 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; + } + + 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 + : toTcpPort(requestedPorts.httpPort); + const explicitCdpPort = + requestedPorts.cdpPort === undefined + ? undefined + : toTcpPort(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 scripts/watchBloomExe.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; + } + + 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; +}; + +export const normalizeBloomInstanceInfo = (info, discoveredViaPort) => { + const httpPort = toTcpPort(info?.httpPort) ?? discoveredViaPort; + const cdpPort = toTcpPort(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 = []; + 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 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 fetchBloomInstanceInfo(port), + })), + ); + + return responses + .filter( + ({ instanceInfo }) => instanceInfo.reachable && !!instanceInfo.json, + ) + .map(({ port, instanceInfo }) => + normalizeBloomInstanceInfo(instanceInfo.json, port), + ) + .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-automation/bloomProcessStatus.mjs b/.github/skills/bloom-automation/bloomProcessStatus.mjs new file mode 100644 index 000000000000..31f4a3ddf831 --- /dev/null +++ b/.github/skills/bloom-automation/bloomProcessStatus.mjs @@ -0,0 +1,228 @@ +import { + buildProcessChain, + classifyProcesses, + fetchBloomInstanceInfo, + fetchJsonEndpoint, + findRunningStandardBloomInstances, + getDefaultRepoRoot, + getWindowsProcessSnapshot, + normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, +} 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 = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, i, "--cdp-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = requireTcpPortOption( + "--cdp-port", + 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(options.httpPort); + if (instanceInfo.reachable && instanceInfo.json) { + selectedRunningBloomInstance = normalizeBloomInstanceInfo( + instanceInfo.json, + 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:${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:${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, + requestedCdpPort: options.cdpPort, + 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/killBloomProcess.mjs b/.github/skills/bloom-automation/killBloomProcess.mjs new file mode 100644 index 000000000000..7c97dde452bc --- /dev/null +++ b/.github/skills/bloom-automation/killBloomProcess.mjs @@ -0,0 +1,198 @@ +import { + buildProcessChain, + classifyProcesses, + fetchBloomInstanceInfo, + getDefaultRepoRoot, + getWindowsProcessSnapshot, + killProcessIds, + normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, +} 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 = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = requireTcpPortOption( + "--http-port", + 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, + 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, + 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..92cbdf98d649 --- /dev/null +++ b/.github/skills/bloom-automation/switchWorkspaceTab.mjs @@ -0,0 +1,312 @@ +import { createRequire } from "node:module"; +import path from "node:path"; +import { + fetchBloomInstanceInfo, + findRunningStandardBloomInstance, + getDefaultRepoRoot, + normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, +} 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 = requireTcpPortOption( + "--http-port", + requireOptionValue(args, index, "--http-port"), + ); + index++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, index, "--cdp-port"), + ); + index++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + options.cdpPort = requireTcpPortOption( + "--cdp-port", + 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 response = await fetchBloomInstanceInfo(options.httpPort); + if (!response.reachable || !response.json) { + throw new Error( + `No Bloom instance reported common/instanceInfo on http://localhost:${options.httpPort}.`, + ); + } + + const instance = normalizeBloomInstanceInfo( + response.json, + options.httpPort, + ); + if (options.cdpPort) { + instance.cdpPort = 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 = 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-automation/webview2Targets.mjs b/.github/skills/bloom-automation/webview2Targets.mjs new file mode 100644 index 000000000000..f2500852c96a --- /dev/null +++ b/.github/skills/bloom-automation/webview2Targets.mjs @@ -0,0 +1,283 @@ +import { + fetchBloomInstanceInfo, + findRunningStandardBloomInstance, + normalizeBloomInstanceInfo, + requireOptionValue, + requireTcpPortOption, +} from "./bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + host: "localhost", + port: "9222", + json: false, + all: false, + runningBloom: false, + httpPort: undefined, + 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 === "--http-port") { + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); + 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 = 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++; + } + } + + 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 selectedInstance; + let lastError; + + while (true) { + try { + if (options.httpPort) { + const instanceInfo = await fetchBloomInstanceInfo( + 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, + 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( + "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: selectedInstance || 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/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/scripts/watchBloomExe.mjs b/scripts/watchBloomExe.mjs new file mode 100644 index 000000000000..e3dbd60647fd --- /dev/null +++ b/scripts/watchBloomExe.mjs @@ -0,0 +1,305 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + acquireBloomPortLease, + formatBloomPortPlan, + requireOptionValue, + requireTcpPortOption, + releaseBloomPortLease, + waitForBloomInstanceInfo, +} from "../.github/skills/bloom-automation/bloomProcessCommon.mjs"; + +const parseArgs = () => { + const args = process.argv.slice(2); + const options = { + repoRoot: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ), + httpPort: undefined, + cdpPort: undefined, + vitePort: undefined, + }; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--repo-root") { + options.repoRoot = requireOptionValue(args, i, "--repo-root"); + i++; + continue; + } + + if (arg === "--http-port") { + options.httpPort = requireTcpPortOption( + "--http-port", + requireOptionValue(args, i, "--http-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--http-port=")) { + options.httpPort = requireTcpPortOption( + "--http-port", + arg.slice("--http-port=".length), + ); + continue; + } + + if (arg === "--cdp-port") { + options.cdpPort = requireTcpPortOption( + "--cdp-port", + requireOptionValue(args, i, "--cdp-port"), + ); + i++; + continue; + } + + if (arg.startsWith("--cdp-port=")) { + 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), + ); + } + } + + return options; +}; + +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( + `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 = [ + "watch", + "run", + "--project", + projectPath, + "--", + "--http-port", + String(portPlan.httpPort), + "--cdp-port", + String(portPlan.cdpPort), + "--label", + 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, +}); + +console.log(`dotnet PID: ${child.pid}`); +console.log( + "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; +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 (bloomMonitor || !bloomProcessId) { + return; + } + + bloomMonitor = setInterval(() => { + if (isProcessRunning(bloomProcessId)) { + if ( + launchesUnderWatch && + bloomReadyAt && + Date.now() - bloomReadyAt >= shortLivedBloomMs + ) { + stopBloomMonitor(); + } + return; + } + + const runtimeMs = bloomReadyAt ? Date.now() - bloomReadyAt : undefined; + 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.`, + ); + } 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 && !launchesUnderWatch) { + 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) && + !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.`, + ); + 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/src/BloomBrowserUI/package.json b/src/BloomBrowserUI/package.json index 642e44986557..404ca6898c7d 100644 --- a/src/BloomBrowserUI/package.json +++ b/src/BloomBrowserUI/package.json @@ -10,7 +10,10 @@ "node": ">=22.12.0" }, "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/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..0acfa81346e5 --- /dev/null +++ b/src/BloomBrowserUI/react_components/component-tester/bloomExeCdp.ts @@ -0,0 +1,106 @@ +import { Browser, Frame, Page, chromium } from "./playwrightTest"; + +type WorkspaceTabId = "collection" | "edit" | "publish"; +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; + 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(workspaceTabsUrl); + if (!response.ok) { + throw new Error( + `workspace/tabs failed: ${response.status} ${response.statusText} for ${workspaceTabsUrl}`, + ); + } + + 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/BloomBrowserUI/scripts/go.mjs b/src/BloomBrowserUI/scripts/go.mjs new file mode 100644 index 000000000000..16e61d068a7d --- /dev/null +++ b/src/BloomBrowserUI/scripts/go.mjs @@ -0,0 +1,599 @@ +/* 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"; +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"); +process.env.feedback = "off"; +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; + 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++; + } + + lineStart = index + 1; + } + + buffered = buffered.slice(lineStart); + }; + + return { + write: (chunk) => { + const text = chunk.toString(); + onText?.(text); + flushLines(text); + }, + flush: () => { + const remainingLine = buffered.endsWith("\r") + ? buffered.slice(0, -1) + : buffered; + if (!remainingLine) { + buffered = ""; + return; + } + + target.write(`${prefix}${remainingLine}\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 (error) { + void error; + } + + 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/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 b625cb2e9d66..f3a63c301346 100644 --- a/src/BloomExe/Program.cs +++ b/src/BloomExe/Program.cs @@ -104,6 +104,23 @@ 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 int? StartupVitePort { get; private set; } + internal static string StartupLabel { get; private set; } + internal static bool StartupUsesExplicitPorts => + 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] @@ -147,6 +164,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 +189,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 +202,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 +213,7 @@ static int Main(string[] args1) var mainTask = CommandLine .Parser.Default.ParseArguments( - args1, + args, new[] { typeof(HydrateParameters), @@ -258,10 +287,19 @@ static int Main(string[] args1) return mainTask.Result; // we're done; this is safe once there is nothing being awaited. } - try + if (!ValidateStartupVitePort(out var startupViteErrorMessage)) { - var args = args1; + MessageBox.Show( + startupViteErrorMessage, + "Bloom", + MessageBoxButtons.OK, + MessageBoxIcon.Error + ); + return 1; + } + try + { if (SIL.PlatformUtilities.Platform.IsWindows) { OldVersionCheck(); @@ -511,7 +549,15 @@ static int Main(string[] args1) } else { - if ((Control.ModifierKeys & Keys.Control) == Keys.Control) + if (StartupUsesExplicitPorts) + { + // 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. {StartupRequestedPortSummary}" + ); + } + 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 +731,220 @@ public static void ReleaseBloomToken() UniqueToken.ReleaseToken(); } + internal static string[] ParseStartupPortArguments(string[] args, out string errorMessage) + { + errorMessage = null; + StartupHttpPort = null; + StartupCdpPort = null; + StartupVitePort = null; + StartupLabel = 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; + } + + 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]); + } + + 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 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, + 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 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/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/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/WebView2Browser.cs b/src/BloomExe/WebView2Browser.cs index 3b6138822a29..d72143a50d4b 100644 --- a/src/BloomExe/WebView2Browser.cs +++ b/src/BloomExe/WebView2Browser.cs @@ -26,6 +26,16 @@ namespace Bloom { public partial class WebView2Browser : Browser { + 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; @@ -286,9 +296,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/Workspace/WorkspaceView.cs b/src/BloomExe/Workspace/WorkspaceView.cs index de134e1f4aa2..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"); @@ -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..e73fd6e18e80 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. /// @@ -1457,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. @@ -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/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/BloomExe/web/controllers/CommonApi.cs b/src/BloomExe/web/controllers/CommonApi.cs index 2bac4ab3a618..723befd29902 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,32 @@ 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, + webSocketPort = BloomServer.WebSocketPort, + 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, diff --git a/src/BloomTests/ProgramTests.cs b/src/BloomTests/ProgramTests.cs new file mode 100644 index 000000000000..cec03fd175b4 --- /dev/null +++ b/src/BloomTests/ProgramTests.cs @@ -0,0 +1,104 @@ +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", + "--vite-port", + "15173", + "--label=my-cool-feature", + @"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(Program.StartupVitePort, Is.EqualTo(15173)); + Assert.That(Program.StartupLabel, Is.EqualTo("my-cool-feature")); + 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() + { + 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); + } + + [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); + } + } +}