1use std::fmt::Write as _;
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::fs::{self, OpenOptions};
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12const DEFAULT_WORKFLOW_TIMEOUT_MS: u64 = 600_000;
13const DEFAULT_VERIFY_TIMEOUT_MS: u64 = 1_800_000;
14const DEFAULT_WEBSITE_BOOT_TIMEOUT_MS: u64 = 120_000;
15const DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS: u64 = 5_000;
16const DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS: u64 = 30_000;
17const WEBSITE_LOG_TAIL_BYTES: u64 = 4_096;
18
19pub async fn run_workspace_workflow(args: &Value) -> Result<String, String> {
20 let root = require_project_workspace_root()?;
21 let workflow = required_string(args, "workflow")?;
22
23 match workflow {
24 "website_start" => start_website_server(args, &root).await,
25 "website_probe" => probe_website_server(args, &root).await,
26 "website_validate" => validate_website_server(args, &root).await,
27 "website_status" => website_server_status(args, &root).await,
28 "website_stop" => stop_website_server(args, &root).await,
29 _ => {
30 let invocation = WorkspaceInvocation::from_args(args, &root)?;
31 let output = crate::tools::shell::execute_command_in_dir(
32 &invocation.command,
33 &root,
34 invocation.timeout_ms,
35 false,
36 1000000,
37 )
38 .await?;
39
40 Ok(format!(
41 "Workspace workflow: {}\nWorkspace root: {}\nCommand: {}\n\n{}",
42 invocation.workflow_label,
43 root.display(),
44 invocation.command,
45 output.trim()
46 ))
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52struct WorkspaceInvocation {
53 workflow_label: String,
54 command: String,
55 timeout_ms: u64,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59struct WebsiteLaunchPlan {
60 mode: String,
61 script: String,
62 command: String,
63 url: String,
64 framework_hint: String,
65 boot_timeout_ms: u64,
66 request_timeout_ms: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70struct WebsiteServerState {
71 label: String,
72 mode: String,
73 script: String,
74 command: String,
75 url: String,
76 framework_hint: String,
77 pid: u32,
78 log_path: String,
79 workspace_root: String,
80 started_at_epoch_ms: u64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84struct WebsiteProbeSummary {
85 url: String,
86 status: u16,
87 content_type: Option<String>,
88 title: Option<String>,
89 body_preview: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93struct WebsiteResponseSnapshot {
94 summary: WebsiteProbeSummary,
95 body: String,
96}
97
98impl WorkspaceInvocation {
99 fn from_args(args: &Value, root: &Path) -> Result<Self, String> {
100 let workflow = required_string(args, "workflow")?;
101 let timeout_ms = args
102 .get("timeout_ms")
103 .and_then(|value| value.as_u64())
104 .unwrap_or(default_timeout_ms(workflow));
105
106 let command = match workflow {
107 "build" => default_command_for_action(root, "build")?,
108 "test" => default_command_for_action(root, "test")?,
109 "lint" => default_command_for_action(root, "lint")?,
110 "fix" => default_command_for_action(root, "fix")?,
111 "package_script" => build_package_script_command(root, required_string(args, "name")?)?,
112 "task" => format!("task {}", required_string(args, "name")?),
113 "just" => format!("just {}", required_string(args, "name")?),
114 "make" => format!("make {}", required_string(args, "name")?),
115 "script_path" => build_script_path_command(root, required_string(args, "path")?)?,
116 "command" => required_string(args, "command")?.to_string(),
117 other => {
118 return Err(format!(
119 "Unknown workflow '{}'. Use one of: build, test, lint, fix, package_script, task, just, make, script_path, command, website_start, website_probe, website_validate, website_status, website_stop.",
120 other
121 ))
122 }
123 };
124
125 Ok(Self {
126 workflow_label: workflow.to_string(),
127 command,
128 timeout_ms,
129 })
130 }
131}
132
133fn require_project_workspace_root() -> Result<PathBuf, String> {
134 Ok(crate::tools::file_ops::workspace_root())
135}
136
137fn required_string<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
138 args.get(key)
139 .and_then(|value| value.as_str())
140 .map(str::trim)
141 .filter(|value| !value.is_empty())
142 .ok_or_else(|| format!("Missing required argument: '{}'", key))
143}
144
145fn optional_string<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
146 args.get(key)
147 .and_then(|value| value.as_str())
148 .map(str::trim)
149 .filter(|value| !value.is_empty())
150}
151
152fn optional_string_vec(args: &Value, key: &str) -> Vec<String> {
153 args.get(key)
154 .and_then(|value| value.as_array())
155 .into_iter()
156 .flat_map(|items| items.iter())
157 .filter_map(|value| value.as_str())
158 .map(str::trim)
159 .filter(|value| !value.is_empty())
160 .map(ToOwned::to_owned)
161 .collect()
162}
163
164fn default_timeout_ms(workflow: &str) -> u64 {
165 match workflow {
166 "build" | "test" | "lint" | "fix" => DEFAULT_VERIFY_TIMEOUT_MS,
167 "website_start" => DEFAULT_WEBSITE_BOOT_TIMEOUT_MS,
168 "website_probe" | "website_status" => DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS,
169 "website_validate" => DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS,
170 _ => DEFAULT_WORKFLOW_TIMEOUT_MS,
171 }
172}
173
174fn default_command_for_action(root: &Path, action: &str) -> Result<String, String> {
175 let profile = crate::agent::workspace_profile::load_workspace_profile(root)
176 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root));
177
178 match action {
179 "build" => profile
180 .build_hint
181 .ok_or_else(|| missing_workspace_command_message(action, root)),
182 "test" => profile
183 .test_hint
184 .ok_or_else(|| missing_workspace_command_message(action, root)),
185 "lint" => detect_lint_command(root),
186 "fix" => detect_fix_command(root),
187 other => Err(format!("Unsupported workspace action '{}'.", other)),
188 }
189}
190
191fn missing_workspace_command_message(action: &str, root: &Path) -> String {
192 format!(
193 "Hematite could not infer a `{}` command for the locked workspace at {}. Add a workspace verify profile in `.hematite/settings.json`, or ask for an explicit command/script instead.",
194 action,
195 root.display()
196 )
197}
198
199fn detect_lint_command(root: &Path) -> Result<String, String> {
200 if root.join("Cargo.toml").exists() {
201 Ok("cargo clippy --all-targets --all-features -- -D warnings".to_string())
202 } else if root.join("package.json").exists() {
203 Ok(format!(
204 "{} run lint --if-present",
205 detect_node_package_manager(root)
206 ))
207 } else {
208 Err(missing_workspace_command_message("lint", root))
209 }
210}
211
212fn detect_fix_command(root: &Path) -> Result<String, String> {
213 if root.join("Cargo.toml").exists() {
214 Ok("cargo fmt".to_string())
215 } else if root.join("package.json").exists() {
216 Ok(format!(
217 "{} run fix --if-present",
218 detect_node_package_manager(root)
219 ))
220 } else {
221 Err(missing_workspace_command_message("fix", root))
222 }
223}
224
225fn build_package_script_command(root: &Path, name: &str) -> Result<String, String> {
226 let package = read_package_json(root)?;
227 let has_script = package
228 .get("scripts")
229 .and_then(|value| value.get(name))
230 .is_some();
231 if !has_script {
232 return Err(format!(
233 "package.json does not define a script named `{}` in {}.",
234 name,
235 root.display()
236 ));
237 }
238
239 let package_manager = detect_node_package_manager(root);
240 let command = match package_manager.as_str() {
241 "yarn" => format!("yarn {}", name),
242 "bun" => format!("bun run {}", name),
243 manager => format!("{} run {}", manager, name),
244 };
245 Ok(command)
246}
247
248fn read_package_json(root: &Path) -> Result<Value, String> {
249 let package_json = root.join("package.json");
250 if !package_json.exists() {
251 return Err(format!(
252 "This workflow requires package.json in the locked workspace root ({}).",
253 root.display()
254 ));
255 }
256
257 let content = fs::read_to_string(&package_json)
258 .map_err(|e| format!("Failed to read {}: {}", package_json.display(), e))?;
259 serde_json::from_str(&content)
260 .map_err(|e| format!("Failed to parse {}: {}", package_json.display(), e))
261}
262
263fn package_scripts(package: &Value) -> Map<String, Value> {
264 package
265 .get("scripts")
266 .and_then(|value| value.as_object())
267 .cloned()
268 .unwrap_or_default()
269}
270
271fn build_script_path_command(root: &Path, relative_path: &str) -> Result<String, String> {
272 let candidate = root.join(relative_path);
273 let canonical_root = root
274 .canonicalize()
275 .map_err(|e| format!("Failed to resolve workspace root {}: {}", root.display(), e))?;
276 let canonical_path = candidate.canonicalize().map_err(|e| {
277 format!(
278 "Could not resolve script path `{}` from workspace root {}: {}",
279 relative_path,
280 root.display(),
281 e
282 )
283 })?;
284 if !canonical_path.starts_with(&canonical_root) {
285 return Err(format!(
286 "Script path `{}` resolves outside the locked workspace root {}.",
287 relative_path,
288 root.display()
289 ));
290 }
291
292 let display_path = normalize_relative_path(&canonical_path, &canonical_root)?;
293 let lower = display_path.to_ascii_lowercase();
294 if lower.ends_with(".ps1") {
295 Ok(format!(
296 "pwsh -ExecutionPolicy Bypass -File {}",
297 quote_command_arg(&display_path)
298 ))
299 } else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
300 Ok(format!("cmd /C {}", quote_command_arg(&display_path)))
301 } else if lower.ends_with(".sh") {
302 Ok(format!("bash {}", quote_command_arg(&display_path)))
303 } else if lower.ends_with(".py") {
304 Ok(format!("python {}", quote_command_arg(&display_path)))
305 } else if lower.ends_with(".js") || lower.ends_with(".cjs") || lower.ends_with(".mjs") {
306 Ok(format!("node {}", quote_command_arg(&display_path)))
307 } else {
308 Ok(display_path)
309 }
310}
311
312fn normalize_relative_path(path: &Path, root: &Path) -> Result<String, String> {
313 let relative = path
314 .strip_prefix(root)
315 .map_err(|e| format!("Failed to normalize script path: {}", e))?;
316 Ok(format!(
317 ".{}{}",
318 std::path::MAIN_SEPARATOR,
319 relative.display()
320 ))
321}
322
323fn quote_command_arg(value: &str) -> String {
324 format!("\"{}\"", value.replace('"', "\\\""))
325}
326
327fn detect_node_package_manager(root: &Path) -> String {
328 if root.join("pnpm-lock.yaml").exists() {
329 "pnpm".to_string()
330 } else if root.join("yarn.lock").exists() {
331 "yarn".to_string()
332 } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
333 "bun".to_string()
334 } else {
335 "npm".to_string()
336 }
337}
338
339async fn start_website_server(args: &Value, root: &Path) -> Result<String, String> {
340 let label = optional_string(args, "label").unwrap_or("default");
341 let state_path = website_state_path(root, label);
342 if let Some(existing) = load_website_server_state(&state_path)? {
343 if is_process_alive(existing.pid).await {
344 return Err(format!(
345 "A website server labeled `{}` is already running.\nURL: {}\nPID: {}\nLog: {}\nUse workflow=website_status or workflow=website_stop first.",
346 existing.label, existing.url, existing.pid, existing.log_path
347 ));
348 }
349 let _ = fs::remove_file(&state_path);
350 }
351
352 let plan = detect_website_launch_plan(args, root)?;
353 let runtime_dir = website_runtime_dir(root);
354 fs::create_dir_all(&runtime_dir)
355 .map_err(|e| format!("Failed to create {}: {}", runtime_dir.display(), e))?;
356 let log_path = website_log_path(root, label);
357 let stdout_log = OpenOptions::new()
358 .create(true)
359 .truncate(true)
360 .write(true)
361 .open(&log_path)
362 .map_err(|e| format!("Failed to create {}: {}", log_path.display(), e))?;
363 let stderr_log = stdout_log
364 .try_clone()
365 .map_err(|e| format!("Failed to clone log handle {}: {}", log_path.display(), e))?;
366
367 let mut command = build_shell_command(&plan.command).await;
368 command
369 .current_dir(root)
370 .stdout(Stdio::from(stdout_log))
371 .stderr(Stdio::from(stderr_log));
372
373 let sandbox_root = crate::tools::file_ops::hematite_dir().join("sandbox");
374 let _ = fs::create_dir_all(&sandbox_root);
375 command.env("HOME", &sandbox_root);
376 command.env("TMPDIR", &sandbox_root);
377 command.env("CI", "1");
378 command.env("BROWSER", "none");
379
380 let mut child = command
381 .spawn()
382 .map_err(|e| format!("Failed to start website server: {}", e))?;
383 let pid = child
384 .id()
385 .ok_or_else(|| "Website server started without a visible process id.".to_string())?;
386
387 let started_at_epoch_ms = SystemTime::now()
388 .duration_since(UNIX_EPOCH)
389 .map_err(|e| format!("Clock error: {}", e))?
390 .as_millis() as u64;
391
392 let state = WebsiteServerState {
393 label: label.to_string(),
394 mode: plan.mode.clone(),
395 script: plan.script.clone(),
396 command: plan.command.clone(),
397 url: plan.url.clone(),
398 framework_hint: plan.framework_hint.clone(),
399 pid,
400 log_path: log_path.display().to_string(),
401 workspace_root: root.display().to_string(),
402 started_at_epoch_ms,
403 };
404
405 let probe = wait_for_website_readiness(
406 &mut child,
407 &plan.url,
408 plan.boot_timeout_ms,
409 plan.request_timeout_ms,
410 &log_path,
411 )
412 .await
413 .inspect_err(|_message| {
414 let _ = fs::remove_file(&state_path);
415 })?;
416
417 save_website_server_state(&state_path, &state)?;
418
419 Ok(format!(
420 "Workspace workflow: website_start\nWorkspace root: {}\nMode: {}\nLabel: {}\nScript: {}\nCommand: {}\nFramework hint: {}\nURL: {}\nPID: {}\nLog: {}\n\nReady: HTTP {}{}\n{}",
421 root.display(),
422 state.mode,
423 state.label,
424 state.script,
425 state.command,
426 state.framework_hint,
427 state.url,
428 state.pid,
429 state.log_path,
430 probe.status,
431 probe
432 .title
433 .as_ref()
434 .map(|title| format!(" ({title})"))
435 .unwrap_or_default(),
436 format_probe_details(&probe)
437 ))
438}
439
440fn detect_website_launch_plan(args: &Value, root: &Path) -> Result<WebsiteLaunchPlan, String> {
441 let package = read_package_json(root)?;
442 let scripts = package_scripts(&package);
443 let runtime_contract = load_runtime_contract(root);
444 let mode = optional_string(args, "mode")
445 .unwrap_or("dev")
446 .to_ascii_lowercase();
447 let script = if let Some(explicit) = optional_string(args, "script") {
448 explicit.to_string()
449 } else {
450 detect_website_script_name(&scripts, &mode)?
451 };
452 if !scripts.contains_key(&script) {
453 return Err(format!(
454 "package.json does not define a website script named `{}` in {}.",
455 script,
456 root.display()
457 ));
458 }
459
460 let framework_hint = infer_website_framework(&package);
461 let port = args
462 .get("port")
463 .and_then(|value| value.as_u64())
464 .and_then(|value| u16::try_from(value).ok())
465 .or_else(|| infer_website_default_port(&package, &mode));
466 let host = optional_string(args, "host").unwrap_or("127.0.0.1");
467 let url = if let Some(explicit_url) = optional_string(args, "url") {
468 normalize_http_url(explicit_url)
469 } else if let Some(url_hint) = runtime_contract
470 .as_ref()
471 .and_then(|contract| contract.local_url_hint.clone())
472 {
473 url_hint
474 } else {
475 let inferred_port = port.unwrap_or(if mode == "preview" { 4173 } else { 3000 });
476 format!("http://{}:{}/", host, inferred_port)
477 };
478
479 Ok(WebsiteLaunchPlan {
480 mode,
481 script: script.clone(),
482 command: build_package_script_command(root, &script)?,
483 url,
484 framework_hint,
485 boot_timeout_ms: args
486 .get("timeout_ms")
487 .and_then(|value| value.as_u64())
488 .unwrap_or(DEFAULT_WEBSITE_BOOT_TIMEOUT_MS),
489 request_timeout_ms: args
490 .get("request_timeout_ms")
491 .and_then(|value| value.as_u64())
492 .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS),
493 })
494}
495
496fn detect_website_script_name(scripts: &Map<String, Value>, mode: &str) -> Result<String, String> {
497 let candidates = match mode {
498 "dev" => ["dev", "start", "serve"],
499 "preview" => ["preview", "serve", "start"],
500 "start" => ["start", "serve", "dev"],
501 other => {
502 return Err(format!(
503 "Unknown website mode `{}`. Use one of: dev, preview, start.",
504 other
505 ))
506 }
507 };
508
509 candidates
510 .iter()
511 .find(|candidate| scripts.contains_key(**candidate))
512 .map(|candidate| candidate.to_string())
513 .ok_or_else(|| {
514 format!(
515 "Could not infer a website {} script from package.json. Define one of [{}], or pass `script` explicitly.",
516 mode,
517 candidates.join(", ")
518 )
519 })
520}
521
522fn infer_website_framework(package: &Value) -> String {
523 let deps = dependency_names(package);
524 let script_text = {
525 let mut s = String::new();
526 for text in package_scripts(package)
527 .into_values()
528 .filter_map(|value| value.as_str().map(|t| t.to_ascii_lowercase()))
529 {
530 if !s.is_empty() {
531 s.push('\n');
532 }
533 s.push_str(&text);
534 }
535 s
536 };
537
538 if deps.contains("next") || script_text.contains("next ") {
539 "next".to_string()
540 } else if deps.contains("vite") || script_text.contains("vite") {
541 "vite".to_string()
542 } else if deps.contains("astro") || script_text.contains("astro ") {
543 "astro".to_string()
544 } else if deps.contains("@angular/core") || script_text.contains("ng serve") {
545 "angular".to_string()
546 } else if deps.contains("gatsby") || script_text.contains("gatsby ") {
547 "gatsby".to_string()
548 } else if deps.contains("react-scripts") || script_text.contains("react-scripts") {
549 "react-scripts".to_string()
550 } else if deps.contains("@sveltejs/kit") || script_text.contains("svelte-kit") {
551 "sveltekit".to_string()
552 } else if deps.contains("nuxt") || script_text.contains("nuxt ") {
553 "nuxt".to_string()
554 } else {
555 "generic-node-site".to_string()
556 }
557}
558
559fn infer_website_default_port(package: &Value, mode: &str) -> Option<u16> {
560 match infer_website_framework(package).as_str() {
561 "vite" | "sveltekit" => Some(if mode == "preview" { 4173 } else { 5173 }),
562 "astro" => Some(4321),
563 "gatsby" => Some(8000),
564 "angular" => Some(4200),
565 "next" | "react-scripts" | "nuxt" => Some(3000),
566 _ => None,
567 }
568}
569
570fn dependency_names(package: &Value) -> std::collections::BTreeSet<String> {
571 let mut deps = std::collections::BTreeSet::new();
572 for field in ["dependencies", "devDependencies", "peerDependencies"] {
573 if let Some(map) = package.get(field).and_then(|value| value.as_object()) {
574 for name in map.keys() {
575 deps.insert(name.to_ascii_lowercase());
576 }
577 }
578 }
579 deps
580}
581
582async fn wait_for_website_readiness(
583 child: &mut tokio::process::Child,
584 url: &str,
585 boot_timeout_ms: u64,
586 request_timeout_ms: u64,
587 log_path: &Path,
588) -> Result<WebsiteProbeSummary, String> {
589 let deadline = tokio::time::Instant::now() + Duration::from_millis(boot_timeout_ms);
590 let client = reqwest::Client::builder()
591 .timeout(Duration::from_millis(request_timeout_ms))
592 .redirect(reqwest::redirect::Policy::limited(5))
593 .build()
594 .map_err(|e| format!("Failed to build readiness probe client: {}", e))?;
595
596 loop {
597 let probe_error = match probe_website_once(&client, url).await {
598 Ok(summary) => return Ok(summary),
599 Err(err) => err,
600 };
601
602 match child.try_wait() {
603 Ok(Some(status)) => {
604 return Err(format!(
605 "Website server exited before it became ready (status: {}).\nLast probe error: {}\n{}",
606 status,
607 probe_error,
608 format_log_tail_for_path("Recent log tail", Some(log_path))
609 ));
610 }
611 Ok(None) => {}
612 Err(err) => {
613 return Err(format!("Failed to inspect website server status: {}", err));
614 }
615 }
616
617 if tokio::time::Instant::now() >= deadline {
618 let _ = child.kill().await;
619 return Err(format!(
620 "Website server did not become ready within {} ms.\nLast probe error: {}\n{}",
621 boot_timeout_ms,
622 probe_error,
623 format_log_tail_for_path("Recent log tail", Some(log_path))
624 ));
625 }
626
627 tokio::time::sleep(Duration::from_millis(750)).await;
628 }
629}
630
631async fn probe_website_server(args: &Value, root: &Path) -> Result<String, String> {
632 let label = optional_string(args, "label").unwrap_or("default");
633 let state = load_website_server_state(&website_state_path(root, label))?;
634 let (url, log_path) = if let Some(state) = state {
635 (state.url, Some(state.log_path))
636 } else if let Some(url) = optional_string(args, "url") {
637 (normalize_http_url(url), None)
638 } else {
639 return Err(format!(
640 "No tracked website server labeled `{}`. Pass `url` to probe an arbitrary local site, or start one with workflow=website_start.",
641 label
642 ));
643 };
644
645 let request_timeout_ms = args
646 .get("timeout_ms")
647 .and_then(|value| value.as_u64())
648 .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS);
649 let client = reqwest::Client::builder()
650 .timeout(Duration::from_millis(request_timeout_ms))
651 .redirect(reqwest::redirect::Policy::limited(5))
652 .build()
653 .map_err(|e| format!("Failed to build probe client: {}", e))?;
654 let probe = probe_website_once(&client, &url).await.map_err(|e| {
655 if let Some(path) = log_path.as_deref() {
656 format!("{}\n{}", e, format_log_tail("Recent log tail", Some(path)))
657 } else {
658 e
659 }
660 })?;
661
662 Ok(format!(
663 "Workspace workflow: website_probe\nWorkspace root: {}\nURL: {}\n\nHTTP {}{}\n{}",
664 root.display(),
665 probe.url,
666 probe.status,
667 probe
668 .title
669 .as_ref()
670 .map(|title| format!(" ({title})"))
671 .unwrap_or_default(),
672 format_probe_details(&probe)
673 ))
674}
675
676async fn validate_website_server(args: &Value, root: &Path) -> Result<String, String> {
677 let label = optional_string(args, "label").unwrap_or("default");
678 let (base_url, log_path) = resolve_website_target(args, root, label)?;
679 let routes = default_website_routes(args, root);
680 let asset_limit = args
681 .get("asset_limit")
682 .and_then(|value| value.as_u64())
683 .unwrap_or(8)
684 .min(24) as usize;
685 let request_timeout_ms = args
686 .get("timeout_ms")
687 .and_then(|value| value.as_u64())
688 .unwrap_or(DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS);
689 let client = reqwest::Client::builder()
690 .timeout(Duration::from_millis(request_timeout_ms))
691 .redirect(reqwest::redirect::Policy::limited(5))
692 .build()
693 .map_err(|e| format!("Failed to build validation client: {}", e))?;
694
695 let mut route_lines = Vec::with_capacity(routes.len());
696 let mut asset_lines = Vec::with_capacity(routes.len());
697 let mut issues = Vec::with_capacity(routes.len());
698 let mut assets = std::collections::BTreeSet::new();
699
700 for route in &routes {
701 let route_url = resolve_website_url(&base_url, route)?;
702 match fetch_website_snapshot(&client, &route_url).await {
703 Ok(snapshot) => {
704 let summary = &snapshot.summary;
705 route_lines.push(format!(
706 "- {} -> HTTP {}{}",
707 route,
708 summary.status,
709 summary
710 .title
711 .as_ref()
712 .map(|title| format!(" ({title})"))
713 .unwrap_or_default()
714 ));
715 let content_type = summary.content_type.as_deref().unwrap_or_default();
716 if content_type.contains("text/html") {
717 if summary.title.is_none() {
718 issues.push(format!("Route {} returned HTML without a <title>.", route));
719 }
720 for asset in extract_local_asset_urls(&route_url, &snapshot.body)
721 .into_iter()
722 .take(asset_limit)
723 {
724 assets.insert(asset);
725 }
726 }
727 }
728 Err(err) => {
729 issues.push(format!("Route {} failed validation: {}", route, err));
730 }
731 }
732 }
733
734 for asset_url in assets.iter().take(asset_limit) {
735 match probe_website_once(&client, asset_url).await {
736 Ok(summary) => asset_lines.push(format!(
737 "- {} -> HTTP {} ({})",
738 asset_url,
739 summary.status,
740 summary
741 .content_type
742 .as_deref()
743 .unwrap_or("unknown content type")
744 )),
745 Err(err) => issues.push(format!("Asset {} failed validation: {}", asset_url, err)),
746 }
747 }
748
749 let result = if issues.is_empty() { "PASS" } else { "FAIL" };
750 let mut out = format!(
751 "Workspace workflow: website_validate\nWorkspace root: {}\nBase URL: {}\nRoutes checked: {}\nAssets checked: {}\nResult: {}",
752 root.display(),
753 base_url,
754 routes.len(),
755 asset_lines.len(),
756 result
757 );
758 if !route_lines.is_empty() {
759 out.push_str("\n\nRoutes\n");
760 out.push_str(&route_lines.join("\n"));
761 }
762 if !asset_lines.is_empty() {
763 out.push_str("\n\nAssets\n");
764 out.push_str(&asset_lines.join("\n"));
765 }
766 if !issues.is_empty() {
767 out.push_str("\n\nIssues\n");
768 for (i, issue) in issues.iter().enumerate() {
769 if i > 0 {
770 out.push('\n');
771 }
772 out.push_str("- ");
773 out.push_str(issue);
774 }
775 }
776 if let Some(path) = log_path.as_deref() {
777 out.push_str("\n\n");
778 out.push_str(&format_log_tail("Recent log tail", Some(path)));
779 }
780 Ok(out)
781}
782
783fn resolve_website_target(
784 args: &Value,
785 root: &Path,
786 label: &str,
787) -> Result<(String, Option<String>), String> {
788 let state = load_website_server_state(&website_state_path(root, label))?;
789 if let Some(state) = state {
790 return Ok((state.url, Some(state.log_path)));
791 }
792 if let Some(url) = optional_string(args, "url") {
793 return Ok((normalize_http_url(url), None));
794 }
795 if let Some(url_hint) = load_runtime_contract(root).and_then(|contract| contract.local_url_hint)
796 {
797 return Ok((url_hint, None));
798 }
799 Err(format!(
800 "No tracked website server labeled `{}` and no explicit url. Start the site with workflow=website_start or pass `url`.",
801 label
802 ))
803}
804
805fn default_website_routes(args: &Value, root: &Path) -> Vec<String> {
806 let mut routes = optional_string_vec(args, "routes");
807 if !routes.is_empty() {
808 return normalize_route_hints(routes);
809 }
810 if let Some(contract) = load_runtime_contract(root) {
811 routes = contract.route_hints;
812 }
813 if routes.is_empty() {
814 routes.push("/".to_string());
815 }
816 normalize_route_hints(routes)
817}
818
819fn normalize_route_hints(routes: Vec<String>) -> Vec<String> {
820 let mut normalized = std::collections::BTreeSet::new();
821 for route in routes {
822 let trimmed = route.trim();
823 if trimmed.is_empty() {
824 continue;
825 }
826 if trimmed.starts_with("http://")
827 || trimmed.starts_with("https://")
828 || trimmed.starts_with('/')
829 {
830 normalized.insert(trimmed.to_string());
831 } else {
832 normalized.insert(format!("/{}", trimmed));
833 }
834 }
835 if normalized.is_empty() {
836 normalized.insert("/".to_string());
837 }
838 normalized.into_iter().collect()
839}
840
841fn resolve_website_url(base_url: &str, route: &str) -> Result<String, String> {
842 if route.starts_with("http://") || route.starts_with("https://") {
843 return Ok(route.to_string());
844 }
845 let base = reqwest::Url::parse(base_url)
846 .map_err(|e| format!("Invalid base URL {}: {}", base_url, e))?;
847 base.join(route).map(|url| url.to_string()).map_err(|e| {
848 format!(
849 "Failed to resolve route {} against {}: {}",
850 route, base_url, e
851 )
852 })
853}
854
855async fn website_server_status(args: &Value, root: &Path) -> Result<String, String> {
856 let label = optional_string(args, "label").unwrap_or("default");
857 let state_path = website_state_path(root, label);
858 let Some(state) = load_website_server_state(&state_path)? else {
859 return Err(format!(
860 "No tracked website server labeled `{}`. Start one with workflow=website_start.",
861 label
862 ));
863 };
864
865 let alive = is_process_alive(state.pid).await;
866 let request_timeout_ms = args
867 .get("timeout_ms")
868 .and_then(|value| value.as_u64())
869 .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS);
870 let client = reqwest::Client::builder()
871 .timeout(Duration::from_millis(request_timeout_ms))
872 .redirect(reqwest::redirect::Policy::limited(5))
873 .build()
874 .map_err(|e| format!("Failed to build status probe client: {}", e))?;
875 let probe = probe_website_once(&client, &state.url).await.ok();
876
877 let mut out = format!(
878 "Workspace workflow: website_status\nWorkspace root: {}\nLabel: {}\nMode: {}\nScript: {}\nCommand: {}\nFramework hint: {}\nURL: {}\nPID: {}\nAlive: {}\nLog: {}",
879 root.display(),
880 state.label,
881 state.mode,
882 state.script,
883 state.command,
884 state.framework_hint,
885 state.url,
886 state.pid,
887 if alive { "yes" } else { "no" },
888 state.log_path
889 );
890 if let Some(probe) = probe {
891 let _ = write!(
892 out,
893 "\n\nHTTP {}{}\n{}",
894 probe.status,
895 probe
896 .title
897 .as_ref()
898 .map(|title| format!(" ({title})"))
899 .unwrap_or_default(),
900 format_probe_details(&probe)
901 );
902 } else {
903 out.push_str("\n\nHTTP probe: unavailable");
904 }
905 out.push('\n');
906 out.push_str(&format_log_tail("Recent log tail", Some(&state.log_path)));
907 Ok(out)
908}
909
910async fn stop_website_server(args: &Value, root: &Path) -> Result<String, String> {
911 let label = optional_string(args, "label").unwrap_or("default");
912 let state_path = website_state_path(root, label);
913 let Some(state) = load_website_server_state(&state_path)? else {
914 return Err(format!(
915 "No tracked website server labeled `{}`. Nothing to stop.",
916 label
917 ));
918 };
919
920 let was_alive = is_process_alive(state.pid).await;
921 if was_alive {
922 kill_process(state.pid).await?;
923 }
924 let _ = fs::remove_file(&state_path);
925
926 Ok(format!(
927 "Workspace workflow: website_stop\nWorkspace root: {}\nLabel: {}\nPID: {}\nWas alive: {}\nURL: {}\nLog: {}\n\n{}",
928 root.display(),
929 state.label,
930 state.pid,
931 if was_alive { "yes" } else { "no" },
932 state.url,
933 state.log_path,
934 format_log_tail("Recent log tail", Some(&state.log_path))
935 ))
936}
937
938async fn probe_website_once(
939 client: &reqwest::Client,
940 url: &str,
941) -> Result<WebsiteProbeSummary, String> {
942 Ok(fetch_website_snapshot(client, url).await?.summary)
943}
944
945async fn fetch_website_snapshot(
946 client: &reqwest::Client,
947 url: &str,
948) -> Result<WebsiteResponseSnapshot, String> {
949 let response = client
950 .get(url)
951 .send()
952 .await
953 .map_err(|e| format!("HTTP probe failed for {}: {}", url, e))?;
954 let status = response.status();
955 let content_type = response
956 .headers()
957 .get(reqwest::header::CONTENT_TYPE)
958 .and_then(|value| value.to_str().ok())
959 .map(|value| value.to_string());
960 let body = response
961 .text()
962 .await
963 .map_err(|e| format!("Failed to read response body from {}: {}", url, e))?;
964 if !status.is_success() {
965 return Err(format!(
966 "HTTP probe returned {} for {}.",
967 status.as_u16(),
968 url
969 ));
970 }
971
972 Ok(WebsiteResponseSnapshot {
973 summary: WebsiteProbeSummary {
974 url: url.to_string(),
975 status: status.as_u16(),
976 content_type,
977 title: extract_html_title(&body),
978 body_preview: html_preview_text(&body),
979 },
980 body,
981 })
982}
983
984fn extract_html_title(body: &str) -> Option<String> {
985 use std::sync::OnceLock;
986 static RE: OnceLock<Regex> = OnceLock::new();
987 let re = RE
988 .get_or_init(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").expect("valid title regex"));
989 re.captures(body)
990 .and_then(|captures| captures.get(1).map(|value| value.as_str()))
991 .map(compact_whitespace)
992 .filter(|title| !title.is_empty())
993}
994
995fn html_preview_text(body: &str) -> String {
996 use std::sync::OnceLock;
997 static RE: OnceLock<Regex> = OnceLock::new();
998 let strip_re = RE.get_or_init(|| {
999 Regex::new(r"(?is)<script[^>]*>.*?</script>|<style[^>]*>.*?</style>|<[^>]+>")
1000 .expect("valid strip regex")
1001 });
1002 let stripped = strip_re.replace_all(body, " ");
1003 let compact = compact_whitespace(&stripped);
1004 compact.chars().take(240).collect()
1005}
1006
1007fn compact_whitespace(input: &str) -> String {
1008 let mut result = String::with_capacity(input.len());
1009 for (i, word) in input.split_whitespace().enumerate() {
1010 if i > 0 {
1011 result.push(' ');
1012 }
1013 result.push_str(word);
1014 }
1015 result
1016}
1017
1018fn format_probe_details(probe: &WebsiteProbeSummary) -> String {
1019 let mut lines = Vec::with_capacity(3);
1020 if let Some(content_type) = probe.content_type.as_deref() {
1021 lines.push(format!("Content-Type: {}", content_type));
1022 }
1023 if let Some(title) = probe.title.as_deref() {
1024 lines.push(format!("Title: {}", title));
1025 }
1026 if !probe.body_preview.is_empty() {
1027 lines.push(format!("Body preview: {}", probe.body_preview));
1028 }
1029 if lines.is_empty() {
1030 "(no probe details)".to_string()
1031 } else {
1032 lines.join("\n")
1033 }
1034}
1035
1036fn normalize_http_url(url: &str) -> String {
1037 let trimmed = url.trim();
1038 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
1039 trimmed.to_string()
1040 } else {
1041 format!("http://{}", trimmed)
1042 }
1043}
1044
1045fn extract_local_asset_urls(page_url: &str, body: &str) -> Vec<String> {
1046 let Ok(page) = reqwest::Url::parse(page_url) else {
1047 return Vec::new();
1048 };
1049 use std::sync::OnceLock;
1050 static RE: OnceLock<Regex> = OnceLock::new();
1051 let regex = RE.get_or_init(|| {
1052 Regex::new(r#"(?is)(?:src|href)=["']([^"'#]+)["']"#).expect("valid asset regex")
1053 });
1054 let mut assets = std::collections::BTreeSet::new();
1055 for captures in regex.captures_iter(body) {
1056 let Some(raw) = captures.get(1).map(|value| value.as_str().trim()) else {
1057 continue;
1058 };
1059 let lower = raw.to_ascii_lowercase();
1060 if lower.starts_with("http://")
1061 || lower.starts_with("https://")
1062 || lower.starts_with("data:")
1063 || lower.starts_with("mailto:")
1064 || lower.starts_with("tel:")
1065 || lower.starts_with("javascript:")
1066 {
1067 continue;
1068 }
1069 if !looks_like_static_asset(raw) {
1070 continue;
1071 }
1072 if let Ok(joined) = page.join(raw) {
1073 assets.insert(joined.to_string());
1074 }
1075 }
1076 assets.into_iter().collect()
1077}
1078
1079fn looks_like_static_asset(path: &str) -> bool {
1080 let lower = path.to_ascii_lowercase();
1081 [
1082 ".css",
1083 ".js",
1084 ".mjs",
1085 ".ico",
1086 ".png",
1087 ".jpg",
1088 ".jpeg",
1089 ".svg",
1090 ".webp",
1091 ".gif",
1092 ".woff",
1093 ".woff2",
1094 ".map",
1095 ".json",
1096 ".webmanifest",
1097 ]
1098 .iter()
1099 .any(|suffix| lower.contains(suffix))
1100}
1101
1102fn load_runtime_contract(root: &Path) -> Option<crate::agent::workspace_profile::RuntimeContract> {
1103 crate::agent::workspace_profile::load_workspace_profile(root)
1104 .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root))
1105 .runtime_contract
1106}
1107
1108fn website_runtime_dir(root: &Path) -> PathBuf {
1109 if crate::tools::file_ops::is_os_shortcut_directory(root) {
1110 crate::tools::file_ops::hematite_dir().join("website-runtime")
1111 } else {
1112 root.join(".hematite").join("website-runtime")
1113 }
1114}
1115
1116fn website_state_path(root: &Path, label: &str) -> PathBuf {
1117 website_runtime_dir(root).join(format!("{}.json", slugify_label(label)))
1118}
1119
1120fn website_log_path(root: &Path, label: &str) -> PathBuf {
1121 website_runtime_dir(root).join(format!("{}.log", slugify_label(label)))
1122}
1123
1124fn slugify_label(input: &str) -> String {
1125 let mut slug = String::with_capacity(input.len());
1126 let mut last_dash = false;
1127 for ch in input.chars() {
1128 let lower = ch.to_ascii_lowercase();
1129 if lower.is_ascii_alphanumeric() {
1130 slug.push(lower);
1131 last_dash = false;
1132 } else if !last_dash {
1133 slug.push('-');
1134 last_dash = true;
1135 }
1136 }
1137 let trimmed = slug.trim_matches('-');
1138 if trimmed.is_empty() {
1139 "default".to_string()
1140 } else {
1141 trimmed.to_string()
1142 }
1143}
1144
1145fn save_website_server_state(path: &Path, state: &WebsiteServerState) -> Result<(), String> {
1146 if let Some(parent) = path.parent() {
1147 fs::create_dir_all(parent)
1148 .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
1149 }
1150 let payload = serde_json::to_string_pretty(state)
1151 .map_err(|e| format!("Failed to encode website state: {}", e))?;
1152 fs::write(path, payload).map_err(|e| format!("Failed to write {}: {}", path.display(), e))
1153}
1154
1155fn load_website_server_state(path: &Path) -> Result<Option<WebsiteServerState>, String> {
1156 if !path.exists() {
1157 return Ok(None);
1158 }
1159 let raw = fs::read_to_string(path)
1160 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1161 let state = serde_json::from_str(&raw)
1162 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
1163 Ok(Some(state))
1164}
1165
1166fn format_log_tail(label: &str, path: Option<&str>) -> String {
1167 match path {
1168 Some(path) => match read_log_tail(Path::new(path)) {
1169 Ok(tail) if tail.is_empty() => format!("{}: (empty)", label),
1170 Ok(tail) => format!("{}:\n{}", label, tail),
1171 Err(err) => format!("{}: unavailable ({})", label, err),
1172 },
1173 None => format!("{}: unavailable", label),
1174 }
1175}
1176
1177fn format_log_tail_for_path(label: &str, path: Option<&Path>) -> String {
1178 match path {
1179 Some(path) => match read_log_tail(path) {
1180 Ok(tail) if tail.is_empty() => format!("{}: (empty)", label),
1181 Ok(tail) => format!("{}:\n{}", label, tail),
1182 Err(err) => format!("{}: unavailable ({})", label, err),
1183 },
1184 None => format!("{}: unavailable", label),
1185 }
1186}
1187
1188fn read_log_tail(path: &Path) -> Result<String, String> {
1189 let mut file =
1190 fs::File::open(path).map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
1191 let len = file
1192 .metadata()
1193 .map_err(|e| format!("failed to inspect {}: {}", path.display(), e))?
1194 .len();
1195 let start = len.saturating_sub(WEBSITE_LOG_TAIL_BYTES);
1196 file.seek(SeekFrom::Start(start))
1197 .map_err(|e| format!("failed to seek {}: {}", path.display(), e))?;
1198 let mut buffer = String::with_capacity(WEBSITE_LOG_TAIL_BYTES as usize);
1199 file.read_to_string(&mut buffer)
1200 .map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
1201 Ok(buffer.trim().to_string())
1202}
1203
1204async fn build_shell_command(command: &str) -> tokio::process::Command {
1205 #[cfg(target_os = "windows")]
1206 {
1207 let normalized = command
1208 .replace("/dev/null", "$null")
1209 .replace("1>/dev/null", "2>$null")
1210 .replace("2>/dev/null", "2>$null");
1211
1212 if which("pwsh").await {
1213 let mut cmd = tokio::process::Command::new("pwsh");
1214 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
1215 cmd
1216 } else {
1217 let mut cmd = tokio::process::Command::new("powershell");
1218 cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
1219 cmd
1220 }
1221 }
1222 #[cfg(not(target_os = "windows"))]
1223 {
1224 let mut cmd = tokio::process::Command::new("sh");
1225 cmd.args(["-c", command]);
1226 cmd
1227 }
1228}
1229
1230#[cfg(target_os = "windows")]
1231async fn which(name: &str) -> bool {
1232 #[cfg(target_os = "windows")]
1233 let check = format!("{}.exe", name);
1234 #[cfg(not(target_os = "windows"))]
1235 let check = name;
1236
1237 tokio::process::Command::new("where")
1238 .arg(check)
1239 .stdout(Stdio::null())
1240 .stderr(Stdio::null())
1241 .status()
1242 .await
1243 .map(|status| status.success())
1244 .unwrap_or(false)
1245}
1246
1247async fn is_process_alive(pid: u32) -> bool {
1248 #[cfg(target_os = "windows")]
1249 {
1250 tokio::process::Command::new("tasklist")
1251 .args(["/FI", &format!("PID eq {}", pid)])
1252 .stdout(Stdio::piped())
1253 .stderr(Stdio::null())
1254 .output()
1255 .await
1256 .ok()
1257 .map(|output| {
1258 let text = String::from_utf8_lossy(&output.stdout);
1259 text.lines().any(|line| {
1260 line.split_whitespace()
1261 .any(|token| token == pid.to_string())
1262 })
1263 })
1264 .unwrap_or(false)
1265 }
1266 #[cfg(not(target_os = "windows"))]
1267 {
1268 tokio::process::Command::new("kill")
1269 .args(["-0", &pid.to_string()])
1270 .stdout(Stdio::null())
1271 .stderr(Stdio::null())
1272 .status()
1273 .await
1274 .map(|status| status.success())
1275 .unwrap_or(false)
1276 }
1277}
1278
1279async fn kill_process(pid: u32) -> Result<(), String> {
1280 #[cfg(target_os = "windows")]
1281 {
1282 let output = tokio::process::Command::new("taskkill")
1283 .args(["/PID", &pid.to_string(), "/T", "/F"])
1284 .output()
1285 .await
1286 .map_err(|e| format!("Failed to stop PID {}: {}", pid, e))?;
1287 if output.status.success() {
1288 Ok(())
1289 } else {
1290 Err(format!(
1291 "Failed to stop PID {}: {}",
1292 pid,
1293 String::from_utf8_lossy(&output.stderr).trim()
1294 ))
1295 }
1296 }
1297 #[cfg(not(target_os = "windows"))]
1298 {
1299 let status = tokio::process::Command::new("kill")
1300 .args(["-TERM", &pid.to_string()])
1301 .status()
1302 .await
1303 .map_err(|e| format!("Failed to stop PID {}: {}", pid, e))?;
1304 if status.success() {
1305 Ok(())
1306 } else {
1307 Err(format!("Failed to stop PID {}.", pid))
1308 }
1309 }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314 use super::*;
1315
1316 fn write_package(root: &Path, json: &str) {
1317 fs::write(root.join("package.json"), json).unwrap();
1318 }
1319
1320 #[test]
1321 fn package_script_uses_detected_package_manager() {
1322 let package_root = std::env::temp_dir().join(format!(
1323 "hematite-workspace-workflow-node-{}",
1324 std::process::id()
1325 ));
1326 std::fs::create_dir_all(&package_root).unwrap();
1327 std::fs::write(
1328 package_root.join("package.json"),
1329 r#"{ "scripts": { "dev": "vite" } }"#,
1330 )
1331 .unwrap();
1332 std::fs::write(package_root.join("pnpm-lock.yaml"), "").unwrap();
1333
1334 let command = build_package_script_command(&package_root, "dev").unwrap();
1335 assert_eq!(command, "pnpm run dev");
1336
1337 let _ = std::fs::remove_file(package_root.join("package.json"));
1338 let _ = std::fs::remove_file(package_root.join("pnpm-lock.yaml"));
1339 let _ = std::fs::remove_dir(package_root);
1340 }
1341
1342 #[test]
1343 fn script_path_stays_inside_workspace_root() {
1344 let script_dir = std::env::temp_dir().join(format!(
1345 "hematite-workspace-workflow-scripts-{}",
1346 std::process::id()
1347 ));
1348 std::fs::create_dir_all(script_dir.join("scripts")).unwrap();
1349 std::fs::write(script_dir.join("scripts").join("dev.ps1"), "Write-Host hi").unwrap();
1350
1351 let command = build_script_path_command(&script_dir, "scripts/dev.ps1").unwrap();
1352 assert!(command.contains("pwsh -ExecutionPolicy Bypass -File"));
1353
1354 let _ = std::fs::remove_file(script_dir.join("scripts").join("dev.ps1"));
1355 let _ = std::fs::remove_dir(script_dir.join("scripts"));
1356 let _ = std::fs::remove_dir(script_dir);
1357 }
1358
1359 #[test]
1360 fn detect_website_launch_plan_prefers_dev_script_and_vite_port() {
1361 let dir = tempfile::tempdir().unwrap();
1362 write_package(
1363 dir.path(),
1364 r#"{
1365 "scripts": { "dev": "vite", "preview": "vite preview" },
1366 "devDependencies": { "vite": "^5.0.0" }
1367 }"#,
1368 );
1369 std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
1370
1371 let plan = detect_website_launch_plan(&serde_json::json!({}), dir.path()).unwrap();
1372 assert_eq!(plan.script, "dev");
1373 assert_eq!(plan.command, "pnpm run dev");
1374 assert_eq!(plan.framework_hint, "vite");
1375 assert_eq!(plan.url, "http://127.0.0.1:5173/");
1376 }
1377
1378 #[test]
1379 fn detect_website_launch_plan_honors_preview_mode() {
1380 let dir = tempfile::tempdir().unwrap();
1381 write_package(
1382 dir.path(),
1383 r#"{
1384 "scripts": { "preview": "vite preview" },
1385 "devDependencies": { "vite": "^5.0.0" }
1386 }"#,
1387 );
1388
1389 let plan =
1390 detect_website_launch_plan(&serde_json::json!({ "mode": "preview" }), dir.path())
1391 .unwrap();
1392 assert_eq!(plan.script, "preview");
1393 assert_eq!(plan.url, "http://127.0.0.1:4173/");
1394 }
1395
1396 #[test]
1397 fn extract_html_title_and_preview_are_clean() {
1398 let html = r#"
1399 <html>
1400 <head><title> Demo Site </title></head>
1401 <body><h1>Hello</h1><script>ignore()</script><p>Readable preview text.</p></body>
1402 </html>
1403 "#;
1404 assert_eq!(extract_html_title(html).as_deref(), Some("Demo Site"));
1405 let preview = html_preview_text(html);
1406 assert!(preview.contains("Hello"));
1407 assert!(preview.contains("Readable preview text."));
1408 assert!(!preview.contains("ignore()"));
1409 }
1410
1411 #[test]
1412 fn extract_local_asset_urls_resolves_relative_assets() {
1413 let html = r#"
1414 <html>
1415 <head>
1416 <link rel="stylesheet" href="/assets/app.css">
1417 <script src="./main.js"></script>
1418 </head>
1419 <body>
1420 <img src="images/logo.png">
1421 <a href="https://example.com">external</a>
1422 </body>
1423 </html>
1424 "#;
1425 let assets = extract_local_asset_urls("http://127.0.0.1:5173/about/", html);
1426 assert!(assets
1427 .iter()
1428 .any(|asset| asset == "http://127.0.0.1:5173/assets/app.css"));
1429 assert!(assets
1430 .iter()
1431 .any(|asset| asset == "http://127.0.0.1:5173/about/main.js"));
1432 assert!(assets
1433 .iter()
1434 .any(|asset| asset == "http://127.0.0.1:5173/about/images/logo.png"));
1435 assert!(!assets.iter().any(|asset| asset.contains("example.com")));
1436 }
1437
1438 #[test]
1439 fn normalize_route_hints_deduplicates_and_prefixes_slashes() {
1440 let routes = normalize_route_hints(vec![
1441 "".to_string(),
1442 "pricing".to_string(),
1443 "/pricing".to_string(),
1444 "/".to_string(),
1445 ]);
1446 assert_eq!(routes, vec!["/".to_string(), "/pricing".to_string()]);
1447 }
1448
1449 #[tokio::test]
1450 async fn probe_website_once_reads_local_title() {
1451 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1452 let addr = listener.local_addr().unwrap();
1453 std::thread::spawn(move || {
1454 if let Ok((mut stream, _)) = listener.accept() {
1455 use std::io::Read;
1456 let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<html><head><title>Probe Test</title></head><body>hello</body></html>";
1457 let mut request = [0_u8; 1024];
1458 let _ = stream.read(&mut request);
1459 use std::io::Write;
1460 let _ = stream.write_all(response);
1461 }
1462 });
1463
1464 let client = reqwest::Client::builder()
1465 .timeout(Duration::from_secs(2))
1466 .build()
1467 .unwrap();
1468 let probe = probe_website_once(&client, &format!("http://{}/", addr))
1469 .await
1470 .unwrap();
1471 assert_eq!(probe.status, 200);
1472 assert_eq!(probe.title.as_deref(), Some("Probe Test"));
1473 assert!(probe.body_preview.contains("hello"));
1474 }
1475}