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 e007f8ecf..76b0fe2a9 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -44,9 +44,20 @@ 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. 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. - To pause notifications in your local dev server, set `BUZZ_DEV_NOTIFICATIONS=0` in `.env.local` and restart the server. Only `0` pauses alerts and permission requests; removing the setting restores normal behavior. Saved preferences are @@ -70,9 +81,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..c0131ad3d --- /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. + // Prepend: Tauri treats everything after a bare positional as runner args. + forwarded.unshift( + "--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/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, 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(); diff --git a/tests/integration/dev-commands.test.mjs b/tests/integration/dev-commands.test.mjs new file mode 100644 index 000000000..4613f667f --- /dev/null +++ b/tests/integration/dev-commands.test.mjs @@ -0,0 +1,203 @@ +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 prepends port config and preserves user config and arguments", () => { + 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, 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, + ]); +}); + +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"]]); + } +}); + +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"]); +});