From 16260a0cf6b2c404181c10b3f6dd57d71a4030c0 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 13:33:21 -0600 Subject: [PATCH 1/4] Allow selecting ports for web and desktop development Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- README.md | 21 ++- docs/contributing.md | 24 ++- justfile | 14 +- scripts/desktop-dev.mjs | 46 ++++++ tests/integration/dev-commands.test.mjs | 191 ++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 scripts/desktop-dev.mjs create mode 100644 tests/integration/dev-commands.test.mjs diff --git a/README.md b/README.md index 7ba9378fb..37b847f67 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,21 @@ builds still require the [Tauri prerequisites](https://v2.tauri.app/start/prereq See [contributing](docs/contributing.md) for exact pins, registry settings, and the pinned pnpm package's Intel Mac limitation. -Browser servers prefer port 1430 and automatically use the next open port, so -`just web` can run from multiple worktrees. `just desktop` requires port 1430 -because its native window uses that fixed development URL. +Both commands forward arguments to their development tool (Vite or Tauri). +Select a port (default: 1430) with `just web --port 1431` or +`just desktop --port 1432`. Browser servers prefer the requested port and +use the next open port automatically; desktop requires the exact port to be free and keeps +Vite and the native window on the same URL. To run multiple desktop copies, +use a different port in each terminal/worktree: + +```sh +just desktop --port 1430 +# In another terminal/worktree: +just desktop --port 1431 +``` + +Ports do not isolate account credentials or native plugin data. For separate +plugin profiles, use the existing `BUZZODZ_PROFILE` setting described below. Without live opt-in they run the shell without relay identity access. `just iterate` applies formatting and runs fast checks plus the frontend build. `just scan` adds tests and native checks. [PR CI](.github/workflows/ci.yml) runs @@ -65,7 +77,8 @@ in the non-live shell/fixture state. ``` Open the Local URL printed by `just web`; parallel worktrees may use a port - above 1430. Stop the process using 1430 before starting `just desktop`. + above the requested port. If 1430 is busy, use `just desktop --port 1431` + (or another free port) instead of stopping the other copy. The broker reads the existing Keychain credential only after validating the public pin, refuses mismatches and never falls back to another credential. If it diff --git a/docs/contributing.md b/docs/contributing.md index 464929d33..60155e722 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -44,9 +44,18 @@ The pinned pnpm Hermit package supports Apple Silicon macOS but marks Intel macO on an unsupported platform; resolve that tooling gap first. Other platforms still need their own validation. -- `just web`: install locked dependencies and start Vite on port 1430 or the - next available port, allowing parallel browser development across worktrees. -- `just desktop`: install locked dependencies and start Tauri, which starts Vite. +- `just web [args...]`: install locked dependencies and forward arguments to Vite, + e.g. `just web --port 1431 --host 127.0.0.1`. Vite uses the requested port + (default: 1430) or the next available port, allowing parallel browser development. +- `just desktop [args...]`: install locked dependencies and forward arguments to + Tauri, e.g. `just desktop --port 1431 --no-watch`. The desktop adapter consumes + `--port N` or `--port=N` to set both Vite's port and Tauri's development URL; + Tauri's own `--port` is for its static-file server, not Vite. Without this flag, + the existing Tauri configuration is unchanged (port 1430). Desktop requires the + exact port to be free; an occupied port fails rather than opening another copy's + server. Other arguments, including runner/application arguments after `--`, pass + through unchanged. The selected port overrides these two settings in any supplied + `--config`; other config fields are preserved. - `just fullstack`: reserved, exits unsuccessfully with an explanation. It will eventually start local Docker services including the Buzz relay backend. - `just iterate`: install locked dependencies, format Rust, apply Biome safe @@ -66,9 +75,12 @@ isolated test buses, never use the desktop session bus or display real banners. Installs run on every invocation to account for branch and lockfile changes. pnpm reuses its shared package cache; no node_modules directory needs to be copied into a new worktree. Native dependencies are fetched by Cargo as needed. Initial -downloads and native compilation can take time. Desktop dev requires port 1430 -for its fixed native development URL. Browser dev prints its selected URL and can -use a later port when 1430 is occupied. +downloads and native compilation can take time. For parallel copies, run +`just desktop --port 1430` and `just desktop --port 1431` in separate +terminals/worktrees, or choose other free ports. Ports must be integers from 1 to 65535. Browser dev +prints its selected URL and can use a later port when the requested port is +occupied. Port selection does not isolate credentials or native plugin data; +use the existing `BUZZODZ_PROFILE` setting for separate plugin profiles. Both run the development broker with your identity when the public `BUZZ_DEV_VIEWER` pin is configured in `.env.local`, and start without live identity otherwise; see [the setup and Keychain requirements](../README.md#relay-channels). diff --git a/justfile b/justfile index b054b841a..9e1993cf3 100644 --- a/justfile +++ b/justfile @@ -8,13 +8,15 @@ default: install: pnpm install --frozen-lockfile -# Run the shared frontend in a browser. -web: install - pnpm dev +# Run the shared frontend in a browser; forward Vite arguments (e.g. --port 1431). +[positional-arguments] +web *args: install + pnpm dev "$@" -# Run the shared frontend in Tauri (requires native toolchain). -desktop: install - pnpm tauri dev +# Run Tauri; --port selects Vite's port, other arguments pass through to Tauri. +[positional-arguments] +desktop *args: install + node scripts/desktop-dev.mjs "$@" # Run buzzodz; forward arguments unchanged (Rust required). [positional-arguments] diff --git a/scripts/desktop-dev.mjs b/scripts/desktop-dev.mjs new file mode 100644 index 000000000..b33d47fe8 --- /dev/null +++ b/scripts/desktop-dev.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from "node:child_process"; + +const args = process.argv.slice(2); +const forwarded = []; +let port; +let index = 0; +for (; index < args.length && args[index] !== "--"; index++) { + const arg = args[index]; + if (arg === "--port" || arg.startsWith("--port=")) { + const value = + arg === "--port" ? args[++index] : arg.slice("--port=".length); + if ( + !/^[0-9]+$/.test(value ?? "") || + Number(value) < 1 || + Number(value) > 65535 + ) { + console.error("--port must be an integer between 1 and 65535."); + process.exit(1); + } + port = Number(value); + } else { + forwarded.push(arg); + } +} + +if (port !== undefined) { + // Tauri's own --port controls its static-file server, not our Vite server. + // Apply this last so a supplied config cannot separate the two dev endpoints. + forwarded.push( + "--config", + JSON.stringify({ + build: { + devUrl: `http://localhost:${port}`, + beforeDevCommand: `pnpm dev:desktop --port ${port}`, + }, + }), + ); +} +// Runner/application arguments after -- belong to Tauri, including any --port. +forwarded.push(...args.slice(index)); +const result = spawnSync("pnpm", ["tauri", "dev", ...forwarded], { + stdio: "inherit", +}); +if (result.error) console.error(result.error.message); +if (result.signal) process.kill(process.pid, result.signal); +process.exit(result.status ?? 1); diff --git a/tests/integration/dev-commands.test.mjs b/tests/integration/dev-commands.test.mjs new file mode 100644 index 000000000..b89a571c7 --- /dev/null +++ b/tests/integration/dev-commands.test.mjs @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, + symlinkSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +function recipe(name, ...args) { + const directory = mkdtempSync(path.join(tmpdir(), "buzz-dev-command-")); + const callsFile = path.join(directory, "calls.jsonl"); + try { + copyFileSync( + new URL("../../justfile", import.meta.url), + path.join(directory, "justfile"), + ); + mkdirSync(path.join(directory, "scripts")); + copyFileSync( + new URL("../../scripts/desktop-dev.mjs", import.meta.url), + path.join(directory, "scripts/desktop-dev.mjs"), + ); + // Run the real recipes and adapter, recording only the package-manager boundary. + symlinkSync(process.execPath, path.join(directory, "node")); + writeFileSync( + path.join(directory, "pnpm"), + `#!${process.execPath}\nrequire("node:fs").appendFileSync(process.env.BUZZ_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n");\n`, + { mode: 0o755 }, + ); + // Set PATH inside the recipe shell: Hermit proxies restore their own PATH. + const result = spawnSync( + "just", + [ + "--shell", + "env", + "--shell-arg", + `PATH=${directory}${path.delimiter}${process.env.PATH}`, + "--shell-arg", + "sh", + "--shell-arg", + "-cu", + "--justfile", + "justfile", + name, + ...args, + ], + { + cwd: directory, + env: { + ...process.env, + BUZZ_TEST_CALLS: callsFile, + }, + encoding: "utf8", + timeout: 10_000, + }, + ); + assert.ifError(result.error); + const calls = existsSync(callsFile) + ? readFileSync(callsFile, "utf8").trim().split("\n").map(JSON.parse) + : []; + return { ...result, calls }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} + +function launched(target, ...args) { + const result = recipe(target, ...args); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.calls[0], ["install", "--frozen-lockfile"]); + assert.equal(result.calls.length, 2); + return result.calls[1]; +} + +for (const target of ["web", "desktop"]) { + test(`${target} preserves the no-argument command`, () => { + assert.deepEqual( + launched(target), + target === "web" ? ["dev"] : ["tauri", "dev"], + ); + }); +} + +test("web forwards Vite arguments without reinterpreting or splitting them", () => { + const args = [ + "--port", + "1431", + "--host", + "127.0.0.1", + "--base", + "/two words/", + ]; + assert.deepEqual(launched("web", ...args), ["dev", ...args]); +}); + +for (const value of ["1431", "1", "65535", "01431"]) { + for (const args of [["--port", value], [`--port=${value}`]]) { + test(`desktop translates ${args.join(" ")} to matched endpoints`, () => { + const call = launched("desktop", ...args); + assert.deepEqual(call.slice(0, 3), ["tauri", "dev", "--config"]); + assert.equal(call.length, 4); + assert.deepEqual(JSON.parse(call[3]), { + build: { + devUrl: `http://localhost:${Number(value)}`, + beforeDevCommand: `pnpm dev:desktop --port ${Number(value)}`, + }, + }); + }); + } +} + +test("desktop preserves other options and applies port config last before --", () => { + const config = + '{"productName":"Two words","build":{"devUrl":"http://localhost:9999"}}'; + const runnerArgs = [ + "--", + "--features", + "feature", + "--", + "--port", + "app-port", + "$(pnpm injected)", + ]; + const call = launched( + "desktop", + "--port", + "1431", + "--config", + config, + "--no-watch", + "--port=1432", + ...runnerArgs, + ); + assert.deepEqual(call.slice(0, 5), [ + "tauri", + "dev", + "--config", + config, + "--no-watch", + ]); + assert.equal(call[5], "--config"); + assert.equal(JSON.parse(call[6]).build.devUrl, "http://localhost:1432"); + assert.equal( + JSON.parse(call[6]).build.beforeDevCommand, + "pnpm dev:desktop --port 1432", + ); + assert.deepEqual(call.slice(7), runnerArgs); +}); + +test("desktop forwards help and runner arguments without a port override", () => { + assert.deepEqual(launched("desktop", "--help"), ["tauri", "dev", "--help"]); + const args = ["--no-watch", "--", "--port", "application-port"]; + assert.deepEqual(launched("desktop", ...args), ["tauri", "dev", ...args]); +}); + +test("desktop rejects invalid or missing ports before launching Tauri", () => { + for (const args of [ + ["--port"], + ["--port", "--no-watch"], + ["--port="], + ...[ + "", + "0", + "65536", + "999999999999999999999999999999999999999999999", + "not-a-port", + "1431.5", + " 1431", + "1431; pnpm injected", + "1431'", + '1431"', + "$(pnpm injected)", + "1431\npnpm injected", + ].map((value) => ["--port", value]), + ]) { + const result = recipe("desktop", ...args); + assert.notEqual(result.status, 0, `Accepted ${JSON.stringify(args)}`); + assert.match( + result.stderr, + /--port must be an integer between 1 and 65535/, + ); + assert.deepEqual(result.calls, [["install", "--frozen-lockfile"]]); + } +}); From 982e7cd52f5ffff5350c6771a993292cf14123b9 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 13:41:13 -0600 Subject: [PATCH 2/4] Keep port config ahead of implicit Tauri runner arguments Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- docs/contributing.md | 6 +++-- scripts/desktop-dev.mjs | 4 +-- tests/integration/dev-commands.test.mjs | 34 +++++++++++++++++-------- 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/docs/contributing.md b/docs/contributing.md index 60155e722..8ed589b80 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -54,8 +54,10 @@ need their own validation. the existing Tauri configuration is unchanged (port 1430). Desktop requires the exact port to be free; an occupied port fails rather than opening another copy's server. Other arguments, including runner/application arguments after `--`, pass - through unchanged. The selected port overrides these two settings in any supplied - `--config`; other config fields are preserved. + through unchanged. Port configuration is prepended so Tauri parses it even with + implicit runner arguments. Explicit `--config` arguments merge afterward and can + override it; keep their development URL and frontend command consistent. Use `--` + before runner/application arguments if they contain their own `--port` flag. - `just fullstack`: reserved, exits unsuccessfully with an explanation. It will eventually start local Docker services including the Buzz relay backend. - `just iterate`: install locked dependencies, format Rust, apply Biome safe diff --git a/scripts/desktop-dev.mjs b/scripts/desktop-dev.mjs index b33d47fe8..c0131ad3d 100644 --- a/scripts/desktop-dev.mjs +++ b/scripts/desktop-dev.mjs @@ -25,8 +25,8 @@ for (; index < args.length && args[index] !== "--"; index++) { if (port !== undefined) { // Tauri's own --port controls its static-file server, not our Vite server. - // Apply this last so a supplied config cannot separate the two dev endpoints. - forwarded.push( + // Prepend: Tauri treats everything after a bare positional as runner args. + forwarded.unshift( "--config", JSON.stringify({ build: { diff --git a/tests/integration/dev-commands.test.mjs b/tests/integration/dev-commands.test.mjs index b89a571c7..4613f667f 100644 --- a/tests/integration/dev-commands.test.mjs +++ b/tests/integration/dev-commands.test.mjs @@ -116,7 +116,7 @@ for (const value of ["1431", "1", "65535", "01431"]) { } } -test("desktop preserves other options and applies port config last before --", () => { +test("desktop prepends port config and preserves user config and arguments", () => { const config = '{"productName":"Two words","build":{"devUrl":"http://localhost:9999"}}'; const runnerArgs = [ @@ -138,20 +138,18 @@ test("desktop preserves other options and applies port config last before --", ( "--port=1432", ...runnerArgs, ); - assert.deepEqual(call.slice(0, 5), [ - "tauri", - "dev", + assert.deepEqual(call.slice(0, 3), ["tauri", "dev", "--config"]); + assert.equal(JSON.parse(call[3]).build.devUrl, "http://localhost:1432"); + assert.equal( + JSON.parse(call[3]).build.beforeDevCommand, + "pnpm dev:desktop --port 1432", + ); + assert.deepEqual(call.slice(4), [ "--config", config, "--no-watch", + ...runnerArgs, ]); - assert.equal(call[5], "--config"); - assert.equal(JSON.parse(call[6]).build.devUrl, "http://localhost:1432"); - assert.equal( - JSON.parse(call[6]).build.beforeDevCommand, - "pnpm dev:desktop --port 1432", - ); - assert.deepEqual(call.slice(7), runnerArgs); }); test("desktop forwards help and runner arguments without a port override", () => { @@ -189,3 +187,17 @@ test("desktop rejects invalid or missing ports before launching Tauri", () => { assert.deepEqual(result.calls, [["install", "--frozen-lockfile"]]); } }); + +test("desktop config precedes Tauri's implicit runner-argument boundary", () => { + const call = launched( + "desktop", + "--port", + "1431", + "--runner", + "echo", + "hello", + ); + assert.deepEqual(call.slice(0, 3), ["tauri", "dev", "--config"]); + assert.equal(JSON.parse(call[3]).build.devUrl, "http://localhost:1431"); + assert.deepEqual(call.slice(4), ["--runner", "echo", "hello"]); +}); From 8e4e74acc39a234c475d60f046aca09d1e85c974 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 17:04:43 -0600 Subject: [PATCH 3/4] Isolate parallel terminal fixtures from passwd lookup races Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- src-tauri/src/terminal/tests.rs | 73 ++++++++++++++++++++++++++++++--- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/terminal/tests.rs b/src-tauri/src/terminal/tests.rs index 29b89a113..4e7456e8a 100644 --- a/src-tauri/src/terminal/tests.rs +++ b/src-tauri/src/terminal/tests.rs @@ -11,11 +11,26 @@ fn context() -> TerminalContext { relay_url: "wss://buzz.example.com".into(), } } +fn shell() -> CommandBuilder { + // portable-pty 0.9 uses non-reentrant getpwuid when constructing a command + // without inherited SHELL, and when spawning without explicit HOME/SHELL. + // Initialize once, then clone a complete fixture environment so parallel + // tests never share those password-database buffers or inherit user config. + static SHELL: std::sync::OnceLock = std::sync::OnceLock::new(); + SHELL + .get_or_init(|| { + let mut cmd = CommandBuilder::new("/bin/sh"); + cmd.env_clear(); + cmd.env("PATH", "/usr/bin:/bin"); + cmd.env("HOME", "/"); + cmd.env("SHELL", "/bin/sh"); + cmd + }) + .clone() +} fn script(script: &str) -> CommandBuilder { - let mut cmd = CommandBuilder::new("/bin/sh"); + let mut cmd = shell(); cmd.args(["-c", script]); - cmd.env_clear(); - cmd.env("PATH", "/usr/bin:/bin"); cmd } fn read_all(state: &Terminals, owner: &str, id: &str) -> Vec { @@ -37,6 +52,54 @@ fn read_all(state: &Terminals, owner: &str, id: &str) -> Vec { } } +#[test] +fn parallel_fixture_environment_probe() { + if std::env::var("BUZZ_TERMINAL_FIXTURE_PROBE").as_deref() != Ok("1") { + return; + } + let barrier = std::sync::Barrier::new(8); + std::thread::scope(|scope| { + for _ in 0..8 { + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + let cmd = script("printf '%s|%s|%s' \"$HOME\" \"$SHELL\" \"$PWD\""); + assert_eq!(cmd.get_env("HOME"), Some(std::ffi::OsStr::new("/"))); + assert_eq!(cmd.get_env("SHELL"), Some(std::ffi::OsStr::new("/bin/sh"))); + let state = Terminals::default(); + let owner = state.create_owner().unwrap(); + let id = state.spawn_with(&owner, |_| Ok(cmd), 80, 24).unwrap(); + let output = read_all(&state, &owner, &id); + state.close_owner(&owner).unwrap(); + assert_eq!(output, b"/|/bin/sh|/"); + }); + } + }); +} + +#[test] +fn parallel_fixtures_work_without_parent_home_or_shell() { + // A fresh process exercises first-use construction without mutating the + // environment shared by the other tests or pre-initializing the template. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "terminal::tests::parallel_fixture_environment_probe", + "--nocapture", + ]) + .env("BUZZ_TERMINAL_FIXTURE_PROBE", "1") + .env_remove("HOME") + .env_remove("SHELL") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn production_environment_probe() { if std::env::var("BUZZ_TERMINAL_PROBE").as_deref() != Ok("1") { @@ -322,10 +385,8 @@ fn foreground_job_receives_ctrl_c_and_close_stops_job_control_group() { .spawn_with( &owner, |_| { - let mut cmd = CommandBuilder::new("/bin/sh"); + let mut cmd = shell(); cmd.arg("-i"); - cmd.env_clear(); - cmd.env("PATH", "/usr/bin:/bin"); Ok(cmd) }, 80, From a5eb7da9eeb72c069f48937dfe53acda6dbcf5ab Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Mon, 21 Sep 2026 18:02:36 -0600 Subject: [PATCH 4/4] Await tooltip teardown before responsive layout checks Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- tests/browser/agent-activity.spec.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/browser/agent-activity.spec.mjs b/tests/browser/agent-activity.spec.mjs index 17dc83b1a..97b4405c7 100644 --- a/tests/browser/agent-activity.spec.mjs +++ b/tests/browser/agent-activity.spec.mjs @@ -258,6 +258,11 @@ for (const mode of ["light", "dark"]) { path: testInfo.outputPath(`activity-entry-${mode}-${width}.png`), }); await page.keyboard.press("Escape"); + // Escape starts Base UI's asynchronous unmount. The closing portal still + // has its wide-screen position and can overflow the next narrow viewport. + await expect( + page.getByRole("tooltip", { includeHidden: true }), + ).toHaveCount(0); } await entry.click();