use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use freenet::test_utils::TestContext;
use freenet_macros::freenet_test;
const FIXTURE_KEY_NAME: &str = "smoke-fixture";
const PLAYWRIGHT_ENV: &str = "FREENET_PLAYWRIGHT";
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace layout: crates/core/../../ should resolve")
.to_path_buf()
}
fn target_dir() -> PathBuf {
std::env::var_os("CARGO_TARGET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| workspace_root().join("target"))
}
fn fdev_bin() -> PathBuf {
let debug = target_dir().join("debug").join("fdev");
if debug.exists() {
return debug;
}
let release = target_dir().join("release").join("fdev");
assert!(
release.exists(),
"fdev binary not found at {debug:?} or {release:?}. Build it first: \
`cargo build --bin fdev`."
);
release
}
fn playwright_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("playwright")
}
fn fixture_webapp_dir() -> PathBuf {
playwright_dir().join("fixture-webapp")
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
}
for d in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&d) {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn website_init(config_home: &Path) -> anyhow::Result<String> {
let output = Command::new(fdev_bin())
.env("XDG_CONFIG_HOME", config_home)
.args(["website", "init", FIXTURE_KEY_NAME])
.output()?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !output.status.success() {
anyhow::bail!(
"fdev website init failed: {:?}\nstdout: {stdout}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
}
let key = stdout
.lines()
.find_map(|l| {
let stripped = strip_ansi(l);
stripped
.strip_prefix("Your website contract key: ")
.map(|k| k.trim().to_string())
})
.ok_or_else(|| {
anyhow::anyhow!("could not parse contract key from fdev output:\n{stdout}")
})?;
anyhow::ensure!(
!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric()),
"parsed contract key has unexpected shape (ANSI/format drift?): {key:?}"
);
Ok(key)
}
fn website_publish_observed(config_home: &Path, ws_url: &str) {
let output = Command::new(fdev_bin())
.env("XDG_CONFIG_HOME", config_home)
.args(["--node-url", ws_url, "website", "publish"])
.arg(fixture_webapp_dir())
.args(["--key", FIXTURE_KEY_NAME])
.output()
.expect("spawn fdev website publish");
if output.status.success() {
tracing::info!(
"fdev website publish exited 0: {}",
String::from_utf8_lossy(&output.stdout).trim_end()
);
} else {
tracing::warn!(
"fdev website publish reported error (may still have landed): exit={:?}\nstderr: {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr).trim_end(),
);
}
}
async fn wait_for_shell(shell_url: &str, within: Duration) -> anyhow::Result<bool> {
let deadline = std::time::Instant::now() + within;
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()?;
while std::time::Instant::now() < deadline {
match client.get(shell_url).send().await {
Ok(resp) if resp.status().is_success() => {
let body = resp.text().await.unwrap_or_default();
if body.contains("id=\"app\"") && body.contains("freenetBridge") {
return Ok(true);
}
tracing::debug!("shell 200 but body not ready yet ({} bytes)", body.len());
}
Ok(resp) => {
tracing::debug!("shell not ready: HTTP {}", resp.status());
}
Err(e) => {
tracing::debug!("shell request errored (node still warming?): {e}");
}
}
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(false)
}
fn playwright_enabled() -> bool {
matches!(
std::env::var(PLAYWRIGHT_ENV).ok().as_deref(),
Some("1") | Some("true")
)
}
fn run_playwright(shell_url: &str) -> anyhow::Result<()> {
let dir = playwright_dir();
anyhow::ensure!(
dir.join("node_modules").exists(),
"Playwright deps not installed. Run `npm ci` in {dir:?} (CI does this) \
before setting {PLAYWRIGHT_ENV}=1."
);
let status = Command::new("npx")
.current_dir(&dir)
.env("FREENET_SHELL_URL", shell_url)
.args(["playwright", "test"])
.status()
.map_err(|e| anyhow::anyhow!("failed to spawn `npx playwright test` in {dir:?}: {e}"))?;
anyhow::ensure!(
status.success(),
"Playwright suite failed (exit {status:?})"
);
Ok(())
}
#[freenet_test(
health_check_readiness = true,
nodes = ["gateway"],
timeout_secs = 600,
startup_wait_secs = 30,
tokio_flavor = "multi_thread",
tokio_worker_threads = 4,
)]
async fn shell_smoke_via_playwright(ctx: &mut TestContext) -> TestResult {
let node = ctx.node("gateway")?;
let config_home = tempfile::tempdir()?;
let key = website_init(config_home.path())?;
tracing::info!("fixture website contract key: {key}");
let ws_url = node.ws_url();
website_publish_observed(config_home.path(), &ws_url);
let shell_url = format!(
"http://{}:{}/v1/contract/web/{}/",
node.ip, node.ws_port, key
);
assert!(
wait_for_shell(&shell_url, Duration::from_secs(120)).await?,
"shell never became ready at {shell_url} — the fixture contract did not \
publish + unpack within the deadline"
);
tracing::info!("shell ready at {shell_url}");
if !playwright_enabled() {
tracing::warn!(
"{PLAYWRIGHT_ENV} not set: published the fixture and confirmed the \
shell serves, but skipping the browser suite. Set {PLAYWRIGHT_ENV}=1 \
(and install Playwright deps) to run it."
);
return Ok(());
}
let shell_url_for_pw = shell_url.clone();
tokio::task::spawn_blocking(move || run_playwright(&shell_url_for_pw)).await??;
Ok(())
}