Files
actionbook__actionbook/packages/cli/tests/e2e/windows_daemon.rs
Junliang Feng 11e71d9d03 feat(daemon): add Windows TCP localhost IPC support (ACT-897) (#508)
* test(daemon): add Windows daemon red coverage (ACT-897)

Add tests and CI configuration for Windows daemon TCP transport:
- config.rs: test_actionbook_home_uses_userprofile_when_home_unset — cross-platform
  red test; fails until USERPROFILE fallback is implemented
- server.rs: port_path() stub (path-only) + #[cfg(all(test,windows))] unit tests for
  is_daemon_running, send_sigterm, is_pid_alive Windows behaviour
- tests/e2e/windows_daemon.rs: 3 e2e tests ignored on non-Windows, run on Windows CI
- .github/workflows/e2e-windows.yml: windows-latest CI job for unit + e2e tests

* feat(daemon): add Windows TCP localhost IPC support (ACT-897)

Unix behaviour is unchanged (UnixListener/UnixStream, flock).
Windows now uses TCP localhost instead of the missing Unix socket:

server.rs:
- run_daemon(): bind TcpListener on 127.0.0.1:0, write ephemeral port to
  daemon.port; use fs2::FileExt for cross-platform exclusive file locking
- is_daemon_running(): TCP connect-probe against daemon.port (Windows)
- send_sigterm(): taskkill /F /PID (Windows)
- is_pid_alive(): delegates to is_daemon_running() TCP probe (Windows)
- handle_connection_inner(): generic AsyncRead+AsyncWrite handler shared by
  both platforms; Unix/Windows handle_connection() call it with the
  appropriate stream type
- port_path(): daemon.port path (used both platforms for path derivation)

client.rs:
- DaemonClient (Windows): TcpStream-based connect/send_action
- auto_start_daemon_windows(): spawns daemon with DETACHED_PROCESS |
  CREATE_NEW_PROCESS_GROUP flags so it survives parent exit
- wait/restart/cleanup helpers mirroring the Unix equivalents

config.rs:
- actionbook_home(): fall back to USERPROFILE when HOME is not set
  (standard Windows home directory convention)

Cargo.toml:
- Add fs2 = "0.4" for cross-platform LockFileEx file locking

* style: apply cargo fmt to windows_daemon tests

* fix: serialize USERPROFILE test with config test_lock to prevent env var race

The test_actionbook_home_uses_userprofile_when_home_unset test manipulates
ACTIONBOOK_HOME and HOME env vars without acquiring the shared test_lock,
causing intermittent failures in other config tests that run concurrently.

* fix: gate Unix-only kill() FFI behind #[cfg(unix)] for Windows build

- start.rs: wrap orphan Chrome kill(pid, 0)/kill(pid, 9) in #[cfg(unix)],
  add #[cfg(windows)] equivalent using taskkill
- chrome_reaper.rs: gate Duration/Instant imports behind #[cfg(unix)]
  since they're only used in the Unix SIGTERM graceful shutdown path

* fix: make drop_session_entry test cross-platform for Windows CI

The test used Unix-only `sleep` and `kill -0` commands. Use `ping` for
a long-lived process on Windows and `tasklist` for liveness checks.

* ci: limit Windows e2e to lifecycle, tab management, and daemon tests

Run only windows_daemon, browser_lifecycle, and tab_management test
modules on Windows CI to avoid timeouts from tests not yet adapted
for Windows. Run sequentially with --test-threads=1.

* ci: narrow Windows e2e to windows_daemon + tab_management

browser_lifecycle tests hang on Windows (close_kills_chrome_process
stuck for 30min). These tests exercise daemon lifecycle edge cases
not yet adapted for Windows. Narrow scope to windows_daemon and
tab_management which cover core IPC and browser operations.

* ci: narrow Windows e2e to windows_daemon tests only

tab_management tests require full Chrome browser interaction and hang
on Windows CI. Only run our Windows-specific daemon IPC tests which
validate the TCP localhost approach without Chrome dependencies.

* fix: avoid pipe-inheritance hang in e2e harness on Windows

On Windows, assert_cmd pipes stdout/stderr. When the CLI spawns a
detached daemon, the daemon inherits those pipe handles via
CreateProcess(bInheritHandles=TRUE). Even after the CLI exits, the
daemon still holds write handles, blocking the test's read_to_end()
forever.

Fix: redirect stdout/stderr to temp files on Windows instead of pipes.
Also make SoloEnv::drop() cross-platform (taskkill on Windows).

* fix: add wait-timeout dev-dependency for Windows e2e harness

The Windows-specific timeout code in the e2e harness uses
wait_timeout::ChildExt directly. It was previously only available
transitively via assert_cmd, which isn't sufficient on Windows.

* fix: add Windows Chrome discovery and fix e2e test subcommand

- find_chrome(): Add Windows paths (Program Files, LOCALAPPDATA) and
  use `where` instead of `which` for PATH lookup on Windows
- Fix session_status_works_over_tcp test: use 'status' subcommand
  (not 'session-status' which doesn't exist)

* fix: use test env's ACTIONBOOK_HOME for daemon.port path assertion

The port file test was checking the global ~/.actionbook/daemon.port
but the test daemon writes to SoloEnv's isolated temp directory.
2026-04-12 10:06:24 +08:00

96 lines
2.9 KiB
Rust

//! Windows daemon e2e tests — skipped on non-Windows platforms.
//!
//! These tests verify that `browser start` and `browser close` work correctly
//! using TCP localhost transport (the Windows IPC path).
//!
//! Run on Windows CI with:
//! set RUN_E2E_TESTS=true
//! cargo test --test e2e windows_daemon -- --test-threads=1 --nocapture
use crate::harness;
/// On Windows, the daemon must start using TCP localhost (no Unix sockets).
/// Verify that a browser session can be created and destroyed.
#[test]
#[cfg_attr(
not(windows),
ignore = "Windows daemon transport tests only run on Windows"
)]
fn daemon_starts_and_connects_on_windows() {
if harness::skip() {
return;
}
let env = harness::SoloEnv::new();
let out = env.headless(&["browser", "start", "--set-session-id", "win-test"], 15);
harness::assert_success(&out, "browser start on Windows");
let out = env.headless(&["browser", "close", "--session", "win-test"], 10);
harness::assert_success(&out, "browser close on Windows");
}
/// The daemon port file (`daemon.port`) must exist after starting a session on Windows.
#[test]
#[cfg_attr(
not(windows),
ignore = "Windows daemon transport tests only run on Windows"
)]
fn daemon_port_file_exists_after_start() {
if harness::skip() {
return;
}
let env = harness::SoloEnv::new();
let out = env.headless(
&["browser", "start", "--set-session-id", "win-portfile"],
15,
);
harness::assert_success(&out, "browser start");
let port_path = std::path::Path::new(&env.actionbook_home).join("daemon.port");
assert!(
port_path.exists(),
"daemon.port file must exist after daemon start, path={}",
port_path.display()
);
let port_str = std::fs::read_to_string(&port_path).unwrap();
let port: u16 = port_str
.trim()
.parse()
.expect("daemon.port must contain a valid port number");
assert!(port > 0, "port must be non-zero");
// Verify daemon actually responds on that port
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
assert!(
std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok(),
"daemon must accept TCP connections on port {port}"
);
env.headless(&["browser", "close", "--session", "win-portfile"], 10);
}
/// Verify that `browser session-status` works on Windows (round-trip IPC via TCP).
#[test]
#[cfg_attr(
not(windows),
ignore = "Windows daemon transport tests only run on Windows"
)]
fn session_status_works_over_tcp() {
if harness::skip() {
return;
}
let env = harness::SoloEnv::new();
env.headless(&["browser", "start", "--set-session-id", "win-status"], 15);
let out = env.headless(&["browser", "status", "--session", "win-status"], 10);
harness::assert_success(&out, "status over TCP");
env.headless(&["browser", "close", "--session", "win-status"], 10);
}