1use super::args::{
7 CertifyCommand, CheckpointCommand, Cli, Commands, DaemonCommand, IrCommand, KnowledgeCommand,
8 KnowledgeInvalidationState, McpClient, ProfileCommand, ResultCommand, SnapshotCommand,
9 TaskCommand, WorkflowAuthoringCommand,
10};
11use crate::browser::policy::{BrowserPolicy, PolicyCapability};
12use crate::browser::profile::ProfileManager;
13use crate::browser::session::{
14 ActionKind, BatchStep, BrowserResult, BrowserSession, CheckpointV1, Cookie,
15 KnowledgeConfidence, KnowledgeStore, Locator, PdfOptions, ReconciliationOptions,
16 SemanticIntentExecutionRequest, SemanticIntentRequest, SemanticObservationLevel,
17 SessionOptions, SessionSnapshotStore, StructuredExtractionRequest, VerificationPredicate,
18 VisualCaptureOptions, WaitCondition, WorkflowAuthoringFormat, WorkflowCheckpoint,
19 WorkflowDefinition, WorkflowDiagnosticSeverity, WorkflowRecordingSession, compile_workflow,
20 default_knowledge_store_path, diff_workflows, format_workflow_yaml, preview_workflow,
21 record_semantic_events,
22};
23use crate::capabilities::GlassCapabilityManifest;
24use crate::protocol::{
25 GLASS_PROTOCOL_VERSION, GlassRequest, TASK_COMPILE_OPERATION, TASK_VALIDATE_OPERATION,
26 WEB_IR_CONTINUITY_OPERATION, WEB_IR_INSPECT_OPERATION, WEB_IR_VALIDATE_OPERATION,
27};
28use crate::reliability::{
29 ReliabilityFixtureManifest, ReliabilityReplayBundle, ReliabilityScenario,
30 ReliabilityScenarioObservation, build_reliability_scorecard,
31};
32use crate::reliability_runner::{ReliabilityRunOptions, run_reliability_scenario};
33use crate::results::{ResponseMode, ResultStore, project_and_store};
34use base64::Engine;
35use serde::Serialize;
36use serde_json::Value;
37use std::collections::BTreeMap;
38use std::io::Read;
39use std::path::Path;
40use std::process::Stdio;
41use std::time::Duration;
42use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
43
44pub async fn dispatch(cli: Cli) -> BrowserResult<()> {
47 let policy = policy_from_cli(&cli)?;
48 if cli.experimental_extensions {
49 eprintln!(concat!(
50 "warning: experimental extensions are enabled; extension code is untrusted, ",
51 "sandbox support is required, and behavior may break"
52 ));
53 }
54 if cli.mcp {
55 return crate::mcp::server::run_mcp_server(&cli).await;
56 }
57
58 match &cli.command {
59 Some(Commands::InstallChromium { update }) => {
60 let path = crate::browser::chrome::download_chromium(*update).await?;
61 println!("Chrome for Testing installed at {}", path.display());
62 return Ok(());
63 }
64 Some(Commands::Capabilities) => {
65 print_json(
66 &GlassCapabilityManifest::for_policy_with_experimental_extensions(
67 &policy,
68 cli.experimental_extensions,
69 ),
70 )?;
71 return Ok(());
72 }
73 Some(Commands::Daemon { action }) => {
74 dispatch_daemon(action).await?;
75 return Ok(());
76 }
77 Some(Commands::Doctor { json }) => {
78 dispatch_doctor(&cli, &policy, *json).await?;
79 return Ok(());
80 }
81 Some(Commands::McpConfig { client, print }) => {
82 dispatch_mcp_config(*client, *print)?;
83 return Ok(());
84 }
85 Some(Commands::Certify { action }) if !matches!(action, CertifyCommand::Run { .. }) => {
86 dispatch_certify(action)?;
87 return Ok(());
88 }
89 Some(Commands::Profiles { action }) => {
90 policy.require(PolicyCapability::PersistentProfile)?;
91 dispatch_profiles(action.as_ref())?;
92 return Ok(());
93 }
94 Some(Commands::DeleteProfile { name }) => {
95 policy.require(PolicyCapability::PersistentProfile)?;
96 ProfileManager::new().delete_profile(name)?;
97 println!("deleted profile {name}");
98 return Ok(());
99 }
100 Some(Commands::Knowledge { action }) => {
101 policy.require(PolicyCapability::PersistentProfile)?;
102 dispatch_knowledge(action, cli.knowledge_store.as_deref(), &cli.profile)?;
103 return Ok(());
104 }
105 Some(Commands::Snapshot { action }) if !matches!(action, SnapshotCommand::Create) => {
106 dispatch_snapshot(action, &cli.profile)?;
107 return Ok(());
108 }
109 Some(Commands::Result { action }) => {
110 dispatch_result(action)?;
111 return Ok(());
112 }
113 Some(Commands::Workflow {
114 action: Some(action),
115 input: None,
116 }) => {
117 dispatch_workflow_authoring(action)?;
118 return Ok(());
119 }
120 Some(Commands::Task { action }) => {
121 dispatch_task(action)?;
122 return Ok(());
123 }
124 Some(Commands::Ir { action }) => {
125 dispatch_ir(action)?;
126 return Ok(());
127 }
128 Some(Commands::Tui) | None if cli.prompt.is_none() => {
129 return crate::tui::app::run_tui(&cli).await;
130 }
131 _ => {}
132 }
133
134 let options = SessionOptions {
135 port: cli.port,
136 chrome_path: cli.chrome_path.clone(),
137 profile: cli.profile.clone(),
138 incognito: cli.incognito,
139 attach: cli.attach,
140 target_id: cli.target_id.clone(),
141 frame_id: cli.frame_id.clone(),
142 headed: cli.headed,
143 interaction_mode: cli.interaction,
144 audit: cli.audit,
145 policy: None,
146 };
147 let session = BrowserSession::start_with_policy(&options, policy).await?;
148 let result = if let Some(prompt) = &cli.prompt {
149 run_prompt(&session, prompt, cli.response_mode).await
150 } else if let Some(command) = &cli.command {
151 run_command(&session, command, cli.response_mode).await
152 } else {
153 Ok(())
154 };
155 if let Err(error) = &result
156 && cli.trace_on_error
157 {
158 let trace = session
159 .failure_trace_for(
160 cli_trace_action(cli.command.as_ref(), cli.prompt.as_deref()),
161 error.to_string(),
162 )
163 .await;
164 eprintln!("{}", serde_json::to_string(&trace)?);
165 }
166 let close_result = session.close().await;
167 result?;
168 close_result
169}
170
171async fn dispatch_daemon(action: &DaemonCommand) -> BrowserResult<()> {
172 match action {
173 DaemonCommand::Start { socket, status } => {
174 print_json(&crate::daemon::start(socket.as_deref(), status.as_deref()).await?)?;
175 }
176 DaemonCommand::Status { socket, status } => {
177 print_json(&crate::daemon::status(
178 socket.as_deref(),
179 status.as_deref(),
180 )?)?;
181 }
182 DaemonCommand::Stop { socket, status } => {
183 crate::daemon::stop(socket.as_deref(), status.as_deref())?;
184 print_json(&serde_json::json!({"status": "stopped"}))?;
185 }
186 DaemonCommand::Doctor { socket, status } => {
187 print_json(&crate::daemon::doctor(
188 socket.as_deref(),
189 status.as_deref(),
190 )?)?;
191 }
192 DaemonCommand::Logs { status } => {
193 print_json(&crate::daemon::logs(status.as_deref())?)?;
194 }
195 DaemonCommand::AcknowledgeRecovery {
196 status,
197 request_ids,
198 } => {
199 print_json(&crate::daemon::acknowledge_recovery(
200 status.as_deref(),
201 request_ids,
202 )?)?;
203 }
204 DaemonCommand::Serve { socket, status } => {
205 crate::daemon::serve(socket, status).await?;
206 }
207 }
208 Ok(())
209}
210
211async fn dispatch_doctor(cli: &Cli, policy: &BrowserPolicy, json: bool) -> BrowserResult<()> {
212 let executable = std::env::current_exe()
213 .ok()
214 .map(|path| path.display().to_string());
215 let chrome_path =
216 crate::browser::chrome::resolve_chrome_path(None).map(|path| path.display().to_string());
217 let profile_path = ProfileManager::new().profile_dir(&cli.profile);
218 let runtime_path = default_result_store_path();
219 let profile_writable = path_is_writable(profile_path.parent());
220 let runtime_writable = path_is_writable(runtime_path.parent());
221 let browser_version = chrome_path.as_deref().and_then(|path| {
222 std::process::Command::new(path)
223 .arg("--version")
224 .output()
225 .ok()
226 .filter(|output| output.status.success())
227 .and_then(|output| String::from_utf8(output.stdout).ok())
228 .map(|version| version.trim().to_string())
229 });
230 let mcp_initialized = probe_mcp_initialization(executable.as_deref()).await;
231 let (daemon_socket, daemon_status) = crate::daemon::default_paths();
232 let daemon = crate::daemon::doctor(Some(&daemon_socket), Some(&daemon_status))?;
233 let profiles = ProfileManager::new().list_profiles().unwrap_or_default();
234 let knowledge_path = cli
235 .knowledge_store
236 .clone()
237 .unwrap_or_else(|| default_knowledge_store_path(&cli.profile));
238 let knowledge_exists = knowledge_path.is_file();
239 let manifest = GlassCapabilityManifest::for_policy_with_experimental_extensions(
240 policy,
241 cli.experimental_extensions,
242 );
243 let platform_supported = manifest.constraints.platform != "unsupported";
244 let config_root = dirs::config_dir();
245 let config_writable = path_is_writable(config_root.as_deref());
246 let browser_available = chrome_path.is_some();
247 let status = if platform_supported
248 && browser_available
249 && config_writable
250 && profile_writable
251 && runtime_writable
252 && mcp_initialized
253 {
254 "ready"
255 } else {
256 "degraded"
257 };
258 let findings = serde_json::json!([
259 {
260 "severity": "info",
261 "code": "runtime.executable",
262 "message": executable.as_deref().unwrap_or("unable to resolve executable path"),
263 "remediation": null
264 },
265 {
266 "severity": if browser_available { "info" } else { "warning" },
267 "code": if browser_available { "browser.found" } else { "browser.missing" },
268 "message": if browser_available { "Chrome or Chromium was discovered" } else { "Chrome or Chromium was not discovered" },
269 "remediation": if browser_available { Value::Null } else { Value::String("install Chromium or pass --chrome-path".into()) }
270 },
271 {
272 "severity": if browser_version.is_some() { "info" } else { "warning" },
273 "code": if browser_version.is_some() { "browser.version" } else { "browser.versionUnavailable" },
274 "message": browser_version.as_deref().unwrap_or("browser version could not be queried"),
275 "remediation": if browser_version.is_some() { Value::Null } else { Value::String("verify the browser executable is runnable".into()) }
276 },
277 {
278 "severity": if profile_writable { "info" } else { "warning" },
279 "code": if profile_writable { "runtime.profileWritable" } else { "runtime.profileNotWritable" },
280 "message": profile_path.display().to_string(),
281 "remediation": if profile_writable { Value::Null } else { Value::String("choose a writable profile parent directory".into()) }
282 },
283 {
284 "severity": if runtime_writable { "info" } else { "warning" },
285 "code": if runtime_writable { "runtime.artifactStoreWritable" } else { "runtime.artifactStoreNotWritable" },
286 "message": runtime_path.display().to_string(),
287 "remediation": if runtime_writable { Value::Null } else { Value::String("choose a writable cache directory".into()) }
288 },
289 {
290 "severity": if config_writable { "info" } else { "warning" },
291 "code": if config_writable { "runtime.configWritable" } else { "runtime.configNotWritable" },
292 "message": if config_writable { "Glass configuration directory is writable" } else { "Glass configuration directory is unavailable or not writable" },
293 "remediation": if config_writable { Value::Null } else { Value::String("choose a writable HOME/XDG config directory".into()) }
294 },
295 {
296 "severity": "info",
297 "code": "mcp.stdoutClean",
298 "message": "MCP uses stdout for protocol frames and diagnostics use stderr",
299 "remediation": null
300 },
301 {
302 "severity": if mcp_initialized { "info" } else { "warning" },
303 "code": if mcp_initialized { "mcp.initialized" } else { "mcp.initializationFailed" },
304 "message": if mcp_initialized { "MCP initialize completed with clean stdout" } else { "MCP initialize did not return a valid response" },
305 "remediation": if mcp_initialized { Value::Null } else { Value::String("run the installed executable with --mcp and inspect stderr".into()) }
306 },
307 {
308 "severity": "info",
309 "code": "capability.manifest",
310 "message": "capability statuses are included in the report",
311 "remediation": null
312 }
313 ]);
314 let report = serde_json::json!({
315 "status": status,
316 "version": env!("CARGO_PKG_VERSION"),
317 "platform": manifest.constraints.platform,
318 "executable": executable,
319 "browser": {
320 "family": manifest.constraints.browser_family,
321 "version": browser_version,
322 "chromeAvailable": browser_available,
323 "chromePath": chrome_path,
324 "cdpPort": cli.port,
325 "cdpReachable": crate::browser::chrome::check_chrome_health(cli.port).await,
326 },
327 "profile": {"path": profile_path, "writable": profile_writable},
328 "runtime": {"artifactStore": runtime_path, "writable": runtime_writable},
329 "daemon": daemon,
330 "profiles": {"count": profiles.len(), "names": profiles},
331 "policy": {
332 "preset": manifest.constraints.policy,
333 "capabilities": manifest.capabilities,
334 "capabilityStatuses": manifest.capability_statuses,
335 },
336 "knowledgeStore": {"path": knowledge_path, "exists": knowledge_exists},
337 "extensions": {
338 "enabled": manifest.capabilities.get("extensions").copied().unwrap_or(false),
339 "status": manifest.capability_statuses.get("extensions"),
340 "loader": "disabled",
341 },
342 "findings": findings,
343 });
344 if json {
345 print_json(&report)?;
346 } else {
347 println!("Glass doctor: {status}");
348 for finding in report["findings"].as_array().into_iter().flatten() {
349 println!(
350 "{} {}: {}",
351 finding["severity"].as_str().unwrap_or("info"),
352 finding["code"].as_str().unwrap_or("runtime.unknown"),
353 finding["message"].as_str().unwrap_or("unknown")
354 );
355 }
356 }
357 Ok(())
358}
359
360fn path_is_writable(path: Option<&Path>) -> bool {
361 let Some(path) = path else {
362 return false;
363 };
364 let candidate = if path.exists() {
365 path.to_path_buf()
366 } else {
367 path.ancestors()
368 .find(|ancestor| ancestor.exists())
369 .unwrap_or(path)
370 .to_path_buf()
371 };
372 candidate.is_dir()
373 && std::fs::metadata(&candidate)
374 .map(|metadata| !metadata.permissions().readonly())
375 .unwrap_or(false)
376}
377
378async fn probe_mcp_initialization(executable: Option<&str>) -> bool {
379 let Some(executable) = executable else {
380 return false;
381 };
382 let mut child = match tokio::process::Command::new(executable)
383 .arg("--mcp")
384 .stdin(Stdio::piped())
385 .stdout(Stdio::piped())
386 .stderr(Stdio::null())
387 .spawn()
388 {
389 Ok(child) => child,
390 Err(_) => return false,
391 };
392 let request = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"glass-doctor","version":"0.2.2"}}}"#;
393 let Some(mut stdin) = child.stdin.take() else {
394 let _ = child.kill().await;
395 return false;
396 };
397 if stdin.write_all(request).await.is_err() || stdin.write_all(b"\n").await.is_err() {
398 let _ = child.kill().await;
399 return false;
400 }
401 drop(stdin);
402 let Some(stdout) = child.stdout.take() else {
403 let _ = child.kill().await;
404 return false;
405 };
406 let mut reader = BufReader::new(stdout);
407 let mut line = String::new();
408 let success = tokio::time::timeout(Duration::from_secs(2), reader.read_line(&mut line))
409 .await
410 .ok()
411 .and_then(Result::ok)
412 .is_some_and(|bytes| bytes > 0 && line.contains("\"result\""));
413 let _ = child.kill().await;
414 success
415}
416
417fn dispatch_mcp_config(client: McpClient, _print: bool) -> BrowserResult<()> {
418 let executable = std::env::current_exe()?;
419 let command = executable.display().to_string();
420 let value = match client {
421 McpClient::Generic => serde_json::json!({
422 "command": command,
423 "args": ["--mcp"]
424 }),
425 McpClient::ClaudeCode => serde_json::json!({
426 "mcpServers": {"glass": {"command": command, "args": ["--mcp"]}}
427 }),
428 McpClient::Codex => serde_json::json!({
429 "mcp_servers": {"glass": {"command": command, "args": ["--mcp"]}}
430 }),
431 };
432 print_json(&value)?;
433 Ok(())
434}
435
436fn dispatch_result(action: &ResultCommand) -> BrowserResult<()> {
437 let root = default_result_store_path();
438 let store = ResultStore::new(root);
439 match action {
440 ResultCommand::Show { result_id, section } => {
441 let artifact = store.load(result_id)?;
442 let value = if let Some(section) = section {
443 artifact
444 .details
445 .get(section)
446 .cloned()
447 .ok_or_else(|| format!("result section not found: {section}"))?
448 } else {
449 serde_json::to_value(artifact)?
450 };
451 print_json(&value)?;
452 }
453 ResultCommand::Purge { older_than } => {
454 let age = parse_result_age(older_than)?;
455 print_json(&serde_json::json!({
456 "removed": store.purge_older_than(age)?,
457 "olderThan": older_than,
458 }))?;
459 }
460 }
461 Ok(())
462}
463
464fn default_result_store_path() -> std::path::PathBuf {
465 dirs::cache_dir()
466 .unwrap_or_else(std::env::temp_dir)
467 .join("glass")
468 .join("results")
469}
470
471fn parse_result_age(value: &str) -> BrowserResult<Duration> {
472 let (number, suffix) = value.split_at(
473 value
474 .find(|character: char| !character.is_ascii_digit())
475 .unwrap_or(value.len()),
476 );
477 let amount: u64 = number
478 .parse()
479 .map_err(|_| "older-than must be a positive duration such as 7d or 24h")?;
480 if amount == 0 {
481 return Err("older-than must be positive".into());
482 }
483 let seconds = match suffix {
484 "s" => amount,
485 "m" => amount.saturating_mul(60),
486 "h" => amount.saturating_mul(60 * 60),
487 "d" => amount.saturating_mul(24 * 60 * 60),
488 _ => return Err("older-than must end in s, m, h, or d".into()),
489 };
490 Ok(Duration::from_secs(seconds))
491}
492
493fn cli_trace_action(command: Option<&Commands>, prompt: Option<&str>) -> ActionKind {
494 if let Some(prompt) = prompt {
495 let lower = prompt.trim().to_ascii_lowercase();
496 return if lower.starts_with("double click ") {
497 ActionKind::DoubleClick
498 } else if lower.starts_with("click ") {
499 ActionKind::Click
500 } else if lower.starts_with("type ") {
501 ActionKind::Type
502 } else {
503 ActionKind::Click
504 };
505 }
506 match command {
507 Some(Commands::DoubleClick { .. }) => ActionKind::DoubleClick,
508 Some(Commands::ClickExpectPopup { .. }) => ActionKind::ClickExpectPopup,
509 Some(Commands::Click { .. })
510 | Some(Commands::Preflight { .. })
511 | Some(Commands::ClickAt { .. }) => ActionKind::Click,
512 Some(Commands::Hover { .. }) => ActionKind::Hover,
513 Some(Commands::Drag { .. }) => ActionKind::Drag,
514 Some(Commands::Type { .. }) => ActionKind::Type,
515 Some(Commands::Key { .. }) => ActionKind::KeyPress,
516 Some(Commands::KeyDown { .. }) => ActionKind::KeyDown,
517 Some(Commands::KeyUp { .. }) => ActionKind::KeyUp,
518 Some(Commands::Shortcut { .. }) => ActionKind::Shortcut,
519 Some(Commands::Clear { .. }) => ActionKind::Clear,
520 Some(Commands::Check { .. }) => ActionKind::Check,
521 Some(Commands::Uncheck { .. }) => ActionKind::Uncheck,
522 Some(Commands::Select { .. }) => ActionKind::Select,
523 Some(Commands::Upload { .. }) => ActionKind::Upload,
524 Some(Commands::Scroll { .. }) => ActionKind::Scroll,
525 _ => ActionKind::Click,
526 }
527}
528
529fn dispatch_profiles(action: Option<&ProfileCommand>) -> BrowserResult<()> {
530 let manager = ProfileManager::new();
531 match action {
532 None | Some(ProfileCommand::List) => {
533 let profiles = manager.list_profiles()?;
534 if profiles.is_empty() {
535 println!("no saved profiles");
536 } else {
537 for profile in profiles {
538 println!("{profile}");
539 }
540 }
541 }
542 Some(ProfileCommand::Create { name }) => {
543 manager.create_profile(name)?;
544 println!("created profile {name}");
545 }
546 Some(ProfileCommand::Delete { name }) => {
547 manager.delete_profile(name)?;
548 println!("deleted profile {name}");
549 }
550 }
551 Ok(())
552}
553
554fn dispatch_certify(action: &CertifyCommand) -> BrowserResult<()> {
555 match action {
556 CertifyCommand::Run { .. } => {
557 unreachable!("browser-backed reliability runs are handled after startup")
558 }
559 CertifyCommand::Plan { scenario, fixture } => {
560 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
561 let fixture =
562 ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture)?)?;
563 let plan = scenario.execution_plan(&fixture)?;
564 print_json(&serde_json::json!({
565 "status": "valid",
566 "plan": plan,
567 }))?;
568 }
569 CertifyCommand::Release {
570 version,
571 scenarios,
572 observations,
573 replays,
574 } => {
575 let scenario_value = read_json_input(Some(scenarios))?;
576 let scenarios: Vec<ReliabilityScenario> = if scenario_value.is_array() {
577 serde_json::from_value(scenario_value)?
578 } else {
579 vec![serde_json::from_value(scenario_value)?]
580 };
581 let observations: Vec<ReliabilityScenarioObservation> =
582 serde_json::from_value(read_json_input(Some(observations))?)?;
583 let replays_validated = if let Some(replays) = replays {
584 let replay_value = read_json_input(Some(replays))?;
585 let replay_values: Vec<Value> = if replay_value.is_array() {
586 serde_json::from_value(replay_value)?
587 } else {
588 vec![replay_value]
589 };
590 let mut replay_by_id = BTreeMap::new();
591 for replay_value in replay_values {
592 let scenario_id = replay_value
593 .get("scenarioId")
594 .and_then(Value::as_str)
595 .ok_or("replay bundle is missing scenarioId")?
596 .to_string();
597 let scenario = scenarios
598 .iter()
599 .find(|scenario| scenario.id == scenario_id)
600 .ok_or_else(|| {
601 format!("replay references unknown scenario {scenario_id}")
602 })?;
603 let bundle = ReliabilityReplayBundle::from_value(replay_value, scenario)?;
604 if replay_by_id.insert(scenario_id.clone(), bundle).is_some() {
605 return Err(format!("duplicate replay for scenario {scenario_id}").into());
606 }
607 }
608 if replay_by_id.len() != scenarios.len() {
609 return Err("replay evidence must cover every scenario".into());
610 }
611 let observations_by_id: BTreeMap<_, _> = observations
612 .iter()
613 .map(|observation| (observation.scenario_id.as_str(), observation))
614 .collect();
615 for (scenario_id, replay) in &replay_by_id {
616 let observation =
617 observations_by_id
618 .get(scenario_id.as_str())
619 .ok_or_else(|| {
620 format!("replay has no matching observation for {scenario_id}")
621 })?;
622 if serde_json::to_value(&replay.observation)?
623 != serde_json::to_value(observation)?
624 {
625 return Err(format!("replay observation differs for {scenario_id}").into());
626 }
627 }
628 true
629 } else {
630 false
631 };
632 let scorecard = build_reliability_scorecard(&scenarios, &observations)?;
633 let certified = scorecard.certified;
634 print_json(&serde_json::json!({
635 "status": if certified { "certified" } else { "blocked" },
636 "version": version,
637 "tool": {"name": "glass", "version": env!("CARGO_PKG_VERSION")},
638 "replaysValidated": replays_validated,
639 "gate": &scorecard.gate,
640 "scorecard": &scorecard,
641 }))?;
642 if !certified {
643 return Err("reliability certification blocked".into());
644 }
645 }
646 CertifyCommand::Replay { scenario, input } => {
647 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
648 let bundle =
649 ReliabilityReplayBundle::from_value(read_json_input(Some(input))?, &scenario)?;
650 print_json(&serde_json::json!({
651 "status": "valid",
652 "scenarioId": &bundle.scenario_id,
653 "replayHash": bundle.content_hash(&scenario)?,
654 }))?;
655 }
656 CertifyCommand::ReplayDiff {
657 scenario,
658 before,
659 after,
660 } => {
661 let scenario = ReliabilityScenario::from_value(read_json_input(Some(scenario))?)?;
662 let before =
663 ReliabilityReplayBundle::from_value(read_json_input(Some(before))?, &scenario)?;
664 let after =
665 ReliabilityReplayBundle::from_value(read_json_input(Some(after))?, &scenario)?;
666 let comparison = before.compare(&after, &scenario)?;
667 print_json(&serde_json::json!({
668 "status": if comparison.equivalent { "equivalent" } else { "changed" },
669 "comparison": comparison,
670 }))?;
671 }
672 }
673 Ok(())
674}
675
676async fn dispatch_certify_run(
677 session: &BrowserSession,
678 scenario_path: &std::path::Path,
679 fixture_path: &std::path::Path,
680 url: &str,
681 workflow_root: &std::path::Path,
682 inputs_path: Option<&std::path::Path>,
683 output: Option<&std::path::Path>,
684) -> BrowserResult<()> {
685 let scenario = ReliabilityScenario::from_json(&std::fs::read_to_string(scenario_path)?)?;
686 let fixture = ReliabilityFixtureManifest::from_json(&std::fs::read_to_string(fixture_path)?)?;
687 let inputs: BTreeMap<String, Value> = match inputs_path {
688 Some(path) => serde_json::from_str(&std::fs::read_to_string(path)?)
689 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
690 None => BTreeMap::new(),
691 };
692 session.navigate(url).await?;
693 let evidence = run_reliability_scenario(
694 session,
695 &scenario,
696 &fixture,
697 &ReliabilityRunOptions {
698 workflow_root: workflow_root.to_path_buf(),
699 inputs,
700 },
701 )
702 .await?;
703 let value = serde_json::json!({
704 "observation": evidence.observation,
705 "replay": evidence.replay,
706 });
707 if let Some(output) = output {
708 tokio::fs::write(output, serde_json::to_vec_pretty(&value)?).await?;
709 }
710 print_json(&value)?;
711 Ok(())
712}
713
714fn dispatch_knowledge(
715 action: &KnowledgeCommand,
716 explicit_path: Option<&std::path::Path>,
717 profile: &str,
718) -> BrowserResult<()> {
719 ProfileManager::validate_name(profile)?;
720 let path = explicit_path
721 .map(std::path::Path::to_path_buf)
722 .unwrap_or_else(|| default_knowledge_store_path(profile));
723 let mut store = KnowledgeStore::open(path)?;
724 match action {
725 KnowledgeCommand::List => print_json(store.snapshot())?,
726 KnowledgeCommand::Show { record_id } => {
727 let record = store
728 .get(record_id)
729 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
730 print_json(record)?;
731 }
732 KnowledgeCommand::Explain { record_id } => {
733 let record = store
734 .get(record_id)
735 .ok_or_else(|| format!("knowledge record not found: {record_id}"))?;
736 print_json(&serde_json::json!({
737 "recordId": &record.record_id,
738 "kind": record.kind,
739 "confidence": record.confidence,
740 "scope": &record.scope,
741 "source": &record.source,
742 "invalidation": &record.invalidation,
743 "history": &record.history,
744 "contentHash": record.content_hash()?,
745 "assessment": "requires a fresh observation; stored knowledge is never an authorization",
746 }))?;
747 }
748 KnowledgeCommand::Stats => print_json(&store.stats()?)?,
749 KnowledgeCommand::Export { output } => {
750 let canonical = store.snapshot().to_canonical_json()?;
751 if let Some(output) = output {
752 std::fs::write(output, canonical)?;
753 println!("exported knowledge to {}", output.display());
754 } else {
755 println!("{canonical}");
756 }
757 }
758 KnowledgeCommand::Import { input } => {
759 let snapshot = serde_json::from_value(read_json_input(Some(input))?)
760 .map_err(|error| format!("invalid knowledge snapshot: {error}"))?;
761 store.replace_snapshot(snapshot)?;
762 print_json(&store.stats()?)?;
763 }
764 KnowledgeCommand::Invalidate {
765 record_id,
766 state,
767 reason,
768 observed_at,
769 } => {
770 let next = match state {
771 KnowledgeInvalidationState::Stale => KnowledgeConfidence::Stale,
772 KnowledgeInvalidationState::Contradicted => KnowledgeConfidence::Contradicted,
773 KnowledgeInvalidationState::Quarantined => KnowledgeConfidence::Quarantined,
774 };
775 let change = store.transition(
776 record_id,
777 next,
778 reason
779 .clone()
780 .unwrap_or_else(|| "caller invalidated record".into()),
781 observed_at
782 .clone()
783 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()),
784 false,
785 )?;
786 print_json(&change)?;
787 }
788 KnowledgeCommand::Purge { origin } => print_json(&store.purge_origin(origin)?)?,
789 }
790 Ok(())
791}
792
793fn dispatch_snapshot(action: &SnapshotCommand, profile: &str) -> BrowserResult<()> {
794 ProfileManager::validate_name(profile)?;
795 let store = SessionSnapshotStore::new(crate::browser::session::default_session_snapshot_path(
796 profile,
797 ));
798 match action {
799 SnapshotCommand::Create => unreachable!("snapshot creation requires a browser session"),
800 SnapshotCommand::List => print_json(&store.list()?)?,
801 SnapshotCommand::Inspect { snapshot_id } => print_json(&store.load(snapshot_id)?)?,
802 SnapshotCommand::Diff { from, to } => print_json(&store.diff(from, to)?)?,
803 SnapshotCommand::Purge => print_json(&serde_json::json!({"removed": store.purge()?}))?,
804 }
805 Ok(())
806}
807
808fn dispatch_task(action: &TaskCommand) -> BrowserResult<()> {
809 match action {
810 TaskCommand::Validate { input } => {
811 let request = read_task_request(input, TASK_VALIDATE_OPERATION)?;
812 let result = crate::protocol::validate_task_result(&request)?;
813 print_json(&result)?;
814 }
815 TaskCommand::Compile {
816 input,
817 output,
818 explain,
819 } => {
820 let request = read_task_request(input, TASK_COMPILE_OPERATION)?;
821 let task = request.decode_task_compile()?.task;
822 let result = crate::protocol::compile_task_result(&request)?;
823 if let Some(output) = output {
824 std::fs::write(output, serde_json::to_vec_pretty(&result.plan)?)?;
825 println!("compiled task to {}", output.display());
826 } else {
827 print_json(&result.plan)?;
828 }
829 if *explain {
830 eprintln!("{}", explain_task(&task, &result.plan)?);
831 }
832 }
833 }
834 Ok(())
835}
836
837fn read_task_request(path: &Path, operation: &str) -> BrowserResult<GlassRequest> {
838 let source = std::fs::read_to_string(path)?;
839 let task: Value = serde_json::from_str(&source)?;
840 let request = GlassRequest {
841 protocol_version: GLASS_PROTOCOL_VERSION,
842 request_id: format!("cli-{}", operation.replace('.', "-")),
843 correlation_id: None,
844 session_id: None,
845 mutation_lease: None,
846 operation: operation.into(),
847 payload: serde_json::json!({"task": task}),
848 deadline_ms: None,
849 };
850 request.validate()?;
851 Ok(request)
852}
853
854fn dispatch_ir(action: &IrCommand) -> BrowserResult<()> {
855 match action {
856 IrCommand::Validate { input } => {
857 let request = read_web_ir_request(input, WEB_IR_VALIDATE_OPERATION)?;
858 let result = crate::protocol::web_ir_validate_result(&request)?;
859 print_json(&result)?;
860 }
861 IrCommand::Inspect { input } => {
862 let request = read_web_ir_request(input, WEB_IR_INSPECT_OPERATION)?;
863 let result = crate::protocol::web_ir_inspect_result(&request)?;
864 print_json(&result)?;
865 }
866 IrCommand::Diff {
867 before,
868 after,
869 summary,
870 } => {
871 let before = read_web_ir_draft(before)?;
872 let after = read_web_ir_draft(after)?;
873 let diff = before.diff(&after)?;
874 if *summary {
875 print_json(&crate::protocol::WebIrDiffResult::from_diff(&diff))?;
876 } else {
877 print_json(&diff)?;
878 }
879 }
880 IrCommand::Continuity {
881 before,
882 after,
883 entity_id,
884 } => {
885 let request = read_web_ir_continuity_request(before, after, entity_id)?;
886 let result = crate::protocol::web_ir_continuity_result(&request)?;
887 print_json(&result)?;
888 }
889 IrCommand::Canonical { input } => {
890 let draft = read_web_ir_draft(input)?;
891 println!("{}", draft.to_canonical_json()?);
892 }
893 }
894 Ok(())
895}
896
897fn read_web_ir_request(path: &Path, operation: &str) -> BrowserResult<GlassRequest> {
898 let source = std::fs::read_to_string(path)?;
899 let draft: Value = serde_json::from_str(&source)?;
900 let request = GlassRequest {
901 protocol_version: GLASS_PROTOCOL_VERSION,
902 request_id: format!("cli-{}", operation.replace('.', "-")),
903 correlation_id: None,
904 session_id: None,
905 mutation_lease: None,
906 operation: operation.into(),
907 payload: serde_json::json!({"draft": draft}),
908 deadline_ms: None,
909 };
910 request.validate()?;
911 Ok(request)
912}
913
914fn read_web_ir_continuity_request(
915 before: &Path,
916 after: &Path,
917 entity_id: &str,
918) -> BrowserResult<GlassRequest> {
919 let before_source = std::fs::read_to_string(before)?;
920 let after_source = std::fs::read_to_string(after)?;
921 let request = GlassRequest {
922 protocol_version: GLASS_PROTOCOL_VERSION,
923 request_id: "cli-web-ir-continuity".into(),
924 correlation_id: None,
925 session_id: None,
926 mutation_lease: None,
927 operation: WEB_IR_CONTINUITY_OPERATION.into(),
928 payload: serde_json::json!({
929 "before": serde_json::from_str::<Value>(&before_source)?,
930 "after": serde_json::from_str::<Value>(&after_source)?,
931 "entityId": entity_id,
932 }),
933 deadline_ms: None,
934 };
935 request.validate()?;
936 Ok(request)
937}
938
939fn read_web_ir_draft(path: &Path) -> BrowserResult<crate::web_ir::GlassWebIrDraft> {
940 let source = std::fs::read_to_string(path)?;
941 let draft: crate::web_ir::GlassWebIrDraft = serde_json::from_str(&source)?;
942 draft.validate()?;
943 Ok(draft)
944}
945
946fn explain_task(
947 task: &crate::task_protocol::GlassTask,
948 plan: &crate::task_compiler::TaskExecutionPlan,
949) -> BrowserResult<String> {
950 let task_name = serde_json::to_string(&task.task)?
951 .trim_matches('"')
952 .to_owned();
953 let risk = serde_json::to_string(&task.risk)?
954 .trim_matches('"')
955 .to_owned();
956 let steps = plan
957 .steps
958 .iter()
959 .map(|step| {
960 let operation = serde_json::to_string(&step.operation)
961 .expect("task plan operations are always serializable")
962 .trim_matches('"')
963 .to_owned();
964 let inputs = if step.input_names.is_empty() {
965 String::new()
966 } else {
967 format!(
968 " inputNames={}",
969 serde_json::to_string(&step.input_names)
970 .expect("task input names are always serializable")
971 )
972 };
973 format!(
974 " {}. {}{} confirmationRequired={}",
975 step.ordinal, operation, inputs, step.requires_confirmation
976 )
977 })
978 .collect::<Vec<_>>()
979 .join("\n");
980 Ok(format!(
981 "task: {task_name}\nscope: {}\nrisk: {risk}\nconfirmationRequired: {}\nsteps:\n{steps}\npostconditions: {}\n",
982 serde_json::to_string(&task.scope)?,
983 plan.confirmation_required,
984 serde_json::to_string(&plan.postconditions)?
985 ))
986}
987
988fn dispatch_workflow_authoring(action: &WorkflowAuthoringCommand) -> BrowserResult<()> {
989 match action {
990 WorkflowAuthoringCommand::Templates { name, output } => {
991 let names = [
992 "account-search",
993 "checkout-submit",
994 "profile-update",
995 "report-download",
996 "support-ticket",
997 ];
998 let Some(name) = name else {
999 print_json(&names)?;
1000 return Ok(());
1001 };
1002 let source = workflow_template(name)
1003 .ok_or_else(|| format!("unknown workflow template: {name}"))?;
1004 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
1005 if document
1006 .diagnostics
1007 .iter()
1008 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1009 {
1010 return Err(format!("workflow template {name} failed compilation").into());
1011 }
1012 if let Some(output) = output {
1013 std::fs::write(output, source)?;
1014 } else {
1015 print!("{source}");
1016 }
1017 }
1018 WorkflowAuthoringCommand::Init { name, output } => {
1019 let template = match name.as_str() {
1020 "search" => "account-search",
1021 "form-submit" => "checkout-submit",
1022 "paginated-extraction" => "report-download",
1023 "authenticated-session" => "profile-update",
1024 "dialog-and-download" => "support-ticket",
1025 _ => return Err(format!("unknown workflow starter: {name}").into()),
1026 };
1027 let source = workflow_template(template)
1028 .ok_or_else(|| format!("missing workflow starter: {template}"))?;
1029 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml)?;
1030 if document
1031 .diagnostics
1032 .iter()
1033 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1034 {
1035 return Err(format!("workflow starter {name} failed compilation").into());
1036 }
1037 if let Some(output) = output {
1038 std::fs::write(output, source)?;
1039 } else {
1040 print!("{source}");
1041 }
1042 }
1043 WorkflowAuthoringCommand::Compile { input, output } => {
1044 let source = std::fs::read_to_string(input)?;
1045 let document = compile_workflow(&source, authoring_format(input))?;
1046 if let Some(output) = output {
1047 std::fs::write(output, &document.canonical_json)?;
1048 println!("compiled workflow to {}", output.display());
1049 } else {
1050 println!("{}", document.canonical_json);
1051 }
1052 if document
1053 .diagnostics
1054 .iter()
1055 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error)
1056 {
1057 return Err("workflow compilation produced error diagnostics".into());
1058 }
1059 }
1060 WorkflowAuthoringCommand::Format { input, output } => {
1061 let source = std::fs::read_to_string(input)?;
1062 let document = compile_workflow(&source, authoring_format(input))?;
1063 let formatted = format_workflow_yaml(&document.definition)?;
1064 if let Some(output) = output {
1065 std::fs::write(output, formatted)?;
1066 println!("formatted workflow to {}", output.display());
1067 } else {
1068 print!("{formatted}");
1069 }
1070 }
1071 WorkflowAuthoringCommand::Preview { input } => {
1072 let source = std::fs::read_to_string(input)?;
1073 let document = compile_workflow(&source, authoring_format(input))?;
1074 let preview = preview_workflow(&document.definition)?;
1075 print_json(&serde_json::json!({
1076 "preview": preview,
1077 "diagnostics": document.diagnostics,
1078 }))?;
1079 }
1080 WorkflowAuthoringCommand::Diff { before, after } => {
1081 let before_source = std::fs::read_to_string(before)?;
1082 let after_source = std::fs::read_to_string(after)?;
1083 let before_document = compile_workflow(&before_source, authoring_format(before))?;
1084 let after_document = compile_workflow(&after_source, authoring_format(after))?;
1085 let diff = diff_workflows(&before_document.definition, &after_document.definition)?;
1086 print_json(&serde_json::json!({
1087 "diff": diff,
1088 "beforeDiagnostics": before_document.diagnostics,
1089 "afterDiagnostics": after_document.diagnostics,
1090 }))?;
1091 }
1092 WorkflowAuthoringCommand::Record { input, output } => {
1093 let value = read_json_input(input.as_ref())?;
1094 let session: WorkflowRecordingSession = serde_json::from_value(value)?;
1095 let draft = record_semantic_events(session)?;
1096 let serialized = serde_json::to_string_pretty(&draft)?;
1097 if let Some(output) = output {
1098 std::fs::write(output, serialized)?;
1099 println!("recorded workflow draft to {}", output.display());
1100 } else {
1101 println!("{serialized}");
1102 }
1103 }
1104 WorkflowAuthoringCommand::Validate { input } => {
1105 let source = std::fs::read_to_string(input)?;
1106 let document = compile_workflow(&source, authoring_format(input))?;
1107 print_json(&document)?;
1108 }
1109 WorkflowAuthoringCommand::Lint {
1110 input,
1111 warnings_as_errors,
1112 } => {
1113 let source = std::fs::read_to_string(input)?;
1114 let document = compile_workflow(&source, authoring_format(input))?;
1115 let failed = document.diagnostics.iter().any(|diagnostic| {
1116 diagnostic.severity == WorkflowDiagnosticSeverity::Error
1117 || (*warnings_as_errors
1118 && diagnostic.severity == WorkflowDiagnosticSeverity::Warning)
1119 });
1120 print_json(&document.diagnostics)?;
1121 if failed {
1122 return Err("workflow lint failed".into());
1123 }
1124 }
1125 }
1126 Ok(())
1127}
1128
1129fn authoring_format(path: &std::path::Path) -> WorkflowAuthoringFormat {
1130 if path
1131 .extension()
1132 .and_then(|extension| extension.to_str())
1133 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
1134 {
1135 WorkflowAuthoringFormat::Json
1136 } else {
1137 WorkflowAuthoringFormat::Yaml
1138 }
1139}
1140
1141fn workflow_template(name: &str) -> Option<&'static str> {
1142 Some(match name {
1143 "account-search" => include_str!("templates/account-search.yaml"),
1144 "checkout-submit" => include_str!("templates/checkout-submit.yaml"),
1145 "profile-update" => include_str!("templates/profile-update.yaml"),
1146 "report-download" => include_str!("templates/report-download.yaml"),
1147 "support-ticket" => include_str!("templates/support-ticket.yaml"),
1148 _ => return None,
1149 })
1150}
1151async fn run_command(
1152 session: &BrowserSession,
1153 command: &Commands,
1154 response_mode: ResponseMode,
1155) -> BrowserResult<()> {
1156 match command {
1157 Commands::Certify {
1158 action:
1159 CertifyCommand::Run {
1160 scenario,
1161 fixture,
1162 url,
1163 workflow_root,
1164 inputs,
1165 output,
1166 },
1167 } => {
1168 dispatch_certify_run(
1169 session,
1170 scenario,
1171 fixture,
1172 url,
1173 workflow_root,
1174 inputs.as_deref(),
1175 output.as_deref(),
1176 )
1177 .await?;
1178 }
1179 Commands::Snapshot {
1180 action: SnapshotCommand::Create,
1181 } => {
1182 let observation = session
1183 .semantic_observe(SemanticObservationLevel::Structured)
1184 .await?;
1185 let snapshot = crate::browser::session::SessionSnapshot::from_observation(
1186 session.profile.clone(),
1187 observation,
1188 );
1189 let store = SessionSnapshotStore::new(
1190 crate::browser::session::default_session_snapshot_path(&session.profile),
1191 );
1192 store.save(&snapshot)?;
1193 print_json(&snapshot)?;
1194 }
1195 Commands::Capabilities
1196 | Commands::Daemon { .. }
1197 | Commands::Doctor { .. }
1198 | Commands::McpConfig { .. }
1199 | Commands::Result { .. }
1200 | Commands::Certify { .. }
1201 | Commands::Knowledge { .. }
1202 | Commands::Snapshot { .. }
1203 | Commands::Task { .. } => {
1204 unreachable!("offline commands are handled before browser startup")
1205 }
1206 Commands::Navigate {
1207 url,
1208 timeout_ms,
1209 expected_revision,
1210 } => {
1211 if let Some(expected_revision) = expected_revision {
1212 print_json(
1213 &session
1214 .navigate_with_revision(
1215 url,
1216 Duration::from_millis(*timeout_ms),
1217 *expected_revision,
1218 )
1219 .await?,
1220 )?;
1221 } else {
1222 let page = session
1223 .navigate_with_deadline(url, Duration::from_millis(*timeout_ms))
1224 .await?;
1225 print_json(&page)?;
1226 }
1227 }
1228 Commands::Click {
1229 target,
1230 expected_revision,
1231 } => {
1232 if let Some(expected_revision) = expected_revision {
1233 print_json(
1234 &session
1235 .click_with_revision(target, *expected_revision)
1236 .await?,
1237 )?;
1238 } else {
1239 print_json(&session.click(target).await?)?;
1240 }
1241 }
1242 Commands::Preflight { target, action } => {
1243 print_json(&session.preflight_with_action(target, *action).await)?;
1244 }
1245 Commands::ClickAt { x, y } => {
1246 print_json(&session.click_at(*x, *y).await?)?;
1247 }
1248 Commands::ClickExpectPopup {
1249 target,
1250 expected_revision,
1251 } => {
1252 print_json(
1253 &session
1254 .click_expect_popup_with_revision(target, *expected_revision)
1255 .await?,
1256 )?;
1257 }
1258 Commands::DoubleClick {
1259 target,
1260 expected_revision,
1261 } => {
1262 print_json(
1263 &session
1264 .double_click_with_revision(target, *expected_revision)
1265 .await?,
1266 )?;
1267 }
1268 Commands::Hover { target } => print_json(&session.hover(target).await?)?,
1269 Commands::Drag {
1270 source,
1271 destination,
1272 expected_revision,
1273 } => {
1274 print_json(
1275 &session
1276 .drag_with_revision(source, destination, *expected_revision)
1277 .await?,
1278 )?;
1279 }
1280 Commands::Type {
1281 text,
1282 target,
1283 expected_revision,
1284 } => {
1285 print_json(
1286 &session
1287 .type_text_with_expected_revision(text, target.as_deref(), *expected_revision)
1288 .await?,
1289 )?;
1290 }
1291 Commands::Key {
1292 key,
1293 expected_revision,
1294 } => print_json(
1295 &session
1296 .key_press_with_revision(key, *expected_revision)
1297 .await?,
1298 )?,
1299 Commands::KeyDown {
1300 key,
1301 expected_revision,
1302 } => print_json(
1303 &session
1304 .key_down_with_revision(key, *expected_revision)
1305 .await?,
1306 )?,
1307 Commands::KeyUp {
1308 key,
1309 expected_revision,
1310 } => print_json(
1311 &session
1312 .key_up_with_revision(key, *expected_revision)
1313 .await?,
1314 )?,
1315 Commands::Shortcut {
1316 shortcut,
1317 expected_revision,
1318 } => print_json(
1319 &session
1320 .shortcut_with_revision(shortcut, *expected_revision)
1321 .await?,
1322 )?,
1323 Commands::Clear {
1324 target,
1325 expected_revision,
1326 } => print_json(
1327 &session
1328 .clear_with_revision(target, *expected_revision)
1329 .await?,
1330 )?,
1331 Commands::Check {
1332 target,
1333 expected_revision,
1334 } => print_json(
1335 &session
1336 .check_with_revision(target, *expected_revision)
1337 .await?,
1338 )?,
1339 Commands::Uncheck {
1340 target,
1341 expected_revision,
1342 } => print_json(
1343 &session
1344 .uncheck_with_revision(target, *expected_revision)
1345 .await?,
1346 )?,
1347 Commands::Select {
1348 target,
1349 value,
1350 expected_revision,
1351 } => {
1352 print_json(
1353 &session
1354 .select_option_with_revision(target, value, *expected_revision)
1355 .await?,
1356 )?;
1357 }
1358 Commands::Upload {
1359 target,
1360 files,
1361 expected_revision,
1362 } => {
1363 print_json(
1364 &session
1365 .upload_files_with_revision(target, files, *expected_revision)
1366 .await?,
1367 )?;
1368 }
1369 Commands::Screenshot {
1370 output,
1371 format,
1372 quality,
1373 scale,
1374 full_page,
1375 clip,
1376 target,
1377 } => {
1378 let output = session
1379 .policy()
1380 .require_output_path(std::path::Path::new(output))?;
1381 let capture = session
1382 .capture_visual(&VisualCaptureOptions {
1383 format: *format,
1384 quality: *quality,
1385 scale: *scale,
1386 clip: *clip,
1387 full_page: *full_page,
1388 target: target.clone(),
1389 })
1390 .await?;
1391 let mut source = base64::read::DecoderReader::new(
1392 capture.data.as_bytes(),
1393 &base64::engine::general_purpose::STANDARD,
1394 );
1395 let mut file = std::fs::File::create(&output)?;
1396 std::io::copy(&mut source, &mut file)?;
1397 println!("wrote {}", output.display());
1398 print_json(&capture.metadata)?;
1399 }
1400 Commands::Text => println!("{}", session.text().await?),
1401 Commands::Dom => print_json(&session.deep_dom().await?)?,
1402 Commands::Observe {
1403 deep_dom,
1404 screenshot,
1405 form_values,
1406 semantic_level,
1407 region,
1408 } => {
1409 if let Some(level_name) = semantic_level {
1410 if *deep_dom || *screenshot || *form_values {
1411 return Err(
1412 "semantic observation cannot be combined with deep DOM, screenshot, or form values"
1413 .into(),
1414 );
1415 }
1416 let level = parse_semantic_level(level_name)?;
1417 if let Some(region_id) = region {
1418 let page = session.semantic_observe(level).await?;
1419 print_json(
1420 &session
1421 .semantic_expand_region(region_id, page.revision, level)
1422 .await?,
1423 )?;
1424 } else {
1425 print_json(&session.semantic_observe(level).await?)?;
1426 }
1427 return Ok(());
1428 }
1429 let context = match (*deep_dom, *screenshot, *form_values) {
1430 (false, false, false) => session.observe().await?,
1431 (true, false, false) => session.observe_with_dom().await?,
1432 (false, true, false) => session.observe_with_screenshot().await?,
1433 (true, true, false) => session.observe_with_dom_and_screenshot().await?,
1434 (false, false, true) => session.observe_with_form_values().await?,
1435 _ => return Err("form values can only be combined with compact observe".into()),
1436 };
1437 print_json_mode(&context, response_mode)?;
1438 }
1439 Commands::InspectPage => print_json_mode(&session.inspect_page().await?, response_mode)?,
1440 Commands::FindTarget { input } => {
1441 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1442 &read_json_input(Some(input))?,
1443 )?)?;
1444 print_json_mode(&session.find_target(&request).await?, response_mode)?;
1445 }
1446 Commands::ActAndVerify {
1447 input,
1448 predicate,
1449 timeout_ms,
1450 } => {
1451 let request = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1452 &read_json_input(Some(input))?,
1453 )?)?;
1454 let predicate = predicate
1455 .as_deref()
1456 .map(serde_json::from_str::<VerificationPredicate>)
1457 .transpose()?;
1458 print_json_mode(
1459 &session
1460 .act_and_verify(&request, predicate, Duration::from_millis(*timeout_ms))
1461 .await?,
1462 response_mode,
1463 )?;
1464 }
1465 Commands::ExtractStructured { input } => {
1466 let request: StructuredExtractionRequest =
1467 serde_json::from_value(read_json_input(Some(input))?)?;
1468 print_json_mode(&session.extract_structured(&request).await?, response_mode)?;
1469 }
1470 Commands::RecoverRun { execution_id } => {
1471 print_json_mode(&session.recover_run(execution_id)?, response_mode)?;
1472 }
1473 Commands::Scroll {
1474 dx,
1475 dy,
1476 expected_revision,
1477 } => {
1478 print_json(
1479 &session
1480 .scroll_with_revision(*dx, *dy, *expected_revision)
1481 .await?,
1482 )?;
1483 }
1484 Commands::Wait {
1485 condition,
1486 timeout_ms,
1487 } => {
1488 print_json(
1489 &session
1490 .wait(
1491 WaitCondition::parse(condition)?,
1492 Duration::from_millis(*timeout_ms),
1493 )
1494 .await?,
1495 )?;
1496 }
1497 Commands::Diagnostics { duration_ms } => print_json(
1498 &session
1499 .diagnostics(Duration::from_millis(*duration_ms))
1500 .await?,
1501 )?,
1502 Commands::AcceptDialog => {
1503 session.accept_dialog().await?;
1504 print_json(&serde_json::json!({"dialog": "accepted"}))?;
1505 }
1506 Commands::DismissDialog => {
1507 session.dismiss_dialog().await?;
1508 print_json(&serde_json::json!({"dialog": "dismissed"}))?;
1509 }
1510 Commands::DismissConsent => print_json(&session.dismiss_consent().await?)?,
1511 Commands::Download {
1512 destination,
1513 timeout_ms,
1514 } => print_json(
1515 &session
1516 .wait_for_download(destination, Duration::from_millis(*timeout_ms))
1517 .await?,
1518 )?,
1519 Commands::Targets => print_json(&session.list_targets().await?)?,
1520 Commands::NewTarget { url } => print_json(&session.create_target(url).await?)?,
1521 Commands::SelectTarget { id } => print_json(&session.select_target(id).await?)?,
1522 Commands::CloseTarget { id } => {
1523 session.close_target(id).await?;
1524 print_json(&serde_json::json!({"closed": id}))?;
1525 }
1526 Commands::Frames => print_json(&session.list_frames().await?)?,
1527 Commands::SelectFrame { id } => print_json(&session.select_frame(id).await?)?,
1528 Commands::Evaluate { expression } => {
1529 print_json(&session.evaluate(expression).await?)?;
1530 }
1531 Commands::Cookies => print_json(&session.cookies().await?)?,
1532 Commands::ExportCookies { output } => {
1533 let cookies = session.cookies().await?;
1534 let bytes = serde_json::to_vec_pretty(&cookies)?;
1535 tokio::fs::write(output, bytes).await?;
1536 println!("cookies exported to {}", output.display());
1537 }
1538 Commands::ImportCookies { input } => {
1539 const MAX_COOKIE_FILE_BYTES: u64 = 512 * 1024;
1540 let metadata = tokio::fs::metadata(input).await?;
1541 if metadata.len() > MAX_COOKIE_FILE_BYTES {
1542 return Err(format!(
1543 "cookie file exceeds the {}-byte limit",
1544 MAX_COOKIE_FILE_BYTES
1545 )
1546 .into());
1547 }
1548 let bytes = tokio::fs::read(input).await?;
1549 let cookies: Vec<Cookie> = serde_json::from_slice(&bytes)?;
1550 session.set_cookies(&cookies).await?;
1551 println!("{} cookies imported", cookies.len());
1552 }
1553 Commands::Pdf { output, background } => {
1554 let mut opts = PdfOptions::letter();
1555 if *background {
1556 opts.print_background = Some(true);
1557 }
1558 let data = session.print_to_pdf(&opts).await?;
1559 let bytes = base64::engine::general_purpose::STANDARD.decode(&data)?;
1560 tokio::fs::write(&output, &bytes).await?;
1561 println!("PDF saved to {output} ({} bytes)", bytes.len());
1562 }
1563 Commands::FillForm {
1564 fields,
1565 expected_revision,
1566 } => {
1567 let parsed: Vec<serde_json::Value> = serde_json::from_str(fields)?;
1568 let field_refs: Vec<(String, String)> = parsed
1569 .iter()
1570 .map(|v| {
1571 (
1572 v["target"].as_str().unwrap_or("").to_string(),
1573 v["value"].as_str().unwrap_or("").to_string(),
1574 )
1575 })
1576 .collect();
1577 let field_slices: Vec<(&str, &str)> = field_refs
1578 .iter()
1579 .map(|(t, v)| (t.as_str(), v.as_str()))
1580 .collect();
1581 print_json(
1582 &session
1583 .fill_form_with_expected_revision(&field_slices, *expected_revision)
1584 .await?,
1585 )?;
1586 }
1587 Commands::Batch {
1588 input,
1589 atomic,
1590 mode,
1591 expected_revision,
1592 } => {
1593 let payload = read_json_input(input.as_ref())?;
1594 let steps_value = payload.get("steps").cloned().unwrap_or(payload);
1595 let steps: Vec<BatchStep> = serde_json::from_value(steps_value)
1596 .map_err(|error| format!("invalid batch document: {error}"))?;
1597 print_json(
1598 &session
1599 .run_batch_with_mode(&steps, *atomic, *mode, *expected_revision)
1600 .await?,
1601 )?;
1602 }
1603 Commands::Workflow {
1604 action: None,
1605 input,
1606 } => {
1607 let payload = read_json_input(input.as_ref())?;
1608 let workflow_value = payload
1609 .get("workflow")
1610 .cloned()
1611 .unwrap_or_else(|| payload.clone());
1612 let inputs_value = payload
1613 .get("inputs")
1614 .cloned()
1615 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
1616 let workflow = WorkflowDefinition::from_value(workflow_value)
1617 .map_err(|error| format!("invalid workflow: {error}"))?;
1618 let inputs: BTreeMap<String, Value> = serde_json::from_value(inputs_value)
1619 .map_err(|error| format!("invalid workflow inputs: {error}"))?;
1620 print_json(&session.run_workflow(&workflow, &inputs).await?)?;
1621 }
1622 Commands::Workflow {
1623 action: Some(_), ..
1624 } => unreachable!("offline workflow authoring commands are handled before browser startup"),
1625 Commands::WorkflowResume {
1626 workflow,
1627 checkpoint,
1628 inputs,
1629 } => {
1630 let workflow = WorkflowDefinition::from_value(read_json_input(Some(workflow))?)
1631 .map_err(|error| format!("invalid workflow: {error}"))?;
1632 let checkpoint: WorkflowCheckpoint =
1633 serde_json::from_value(read_json_input(Some(checkpoint))?)
1634 .map_err(|error| format!("invalid workflow checkpoint: {error}"))?;
1635 let inputs: BTreeMap<String, Value> = match inputs {
1636 Some(path) => serde_json::from_value(read_json_input(Some(path))?)
1637 .map_err(|error| format!("invalid workflow inputs: {error}"))?,
1638 None => BTreeMap::new(),
1639 };
1640 print_json(
1641 &session
1642 .resume_workflow(&workflow, &inputs, &checkpoint)
1643 .await?,
1644 )?;
1645 }
1646 Commands::ResolveIntent { input } => {
1647 let request = SemanticIntentRequest::from_json(&serde_json::to_string(
1648 &read_json_input(input.as_ref())?,
1649 )?)?;
1650 print_json(&session.resolve_intent(&request).await?)?;
1651 }
1652 Commands::ExecuteIntent { input } => {
1653 let execution = SemanticIntentExecutionRequest::from_json(&serde_json::to_string(
1654 &read_json_input(input.as_ref())?,
1655 )?)?;
1656 print_json(&session.execute_intent(&execution).await?)?;
1657 }
1658 Commands::Verify {
1659 predicate,
1660 timeout_ms,
1661 } => {
1662 let predicate: VerificationPredicate = serde_json::from_str(predicate)
1663 .map_err(|error| format!("invalid verification predicate: {error}"))?;
1664 print_json(
1665 &session
1666 .verify(predicate, Duration::from_millis(*timeout_ms))
1667 .await?,
1668 )?;
1669 }
1670 Commands::ReconcileRefs {
1671 from_revision,
1672 hints,
1673 scope,
1674 refs,
1675 } => {
1676 let options = ReconciliationOptions {
1677 hints: hints
1678 .iter()
1679 .map(|hint| Locator::parse(hint))
1680 .collect::<BrowserResult<Vec<_>>>()?,
1681 scope_ref: scope.clone(),
1682 };
1683 print_json(
1684 &session
1685 .reconcile_references_with_options(*from_revision, refs, &options)
1686 .await?,
1687 )?;
1688 }
1689 Commands::ObserveDelta => {
1690 print_json(&session.observe_delta().await?)?;
1691 }
1692 Commands::Checkpoint { action } => match action {
1693 CheckpointCommand::Export => print_json(&session.export_checkpoint().await?)?,
1694 CheckpointCommand::Import { input } => {
1695 let checkpoint: CheckpointV1 =
1696 serde_json::from_value(read_json_input(input.as_ref())?)
1697 .map_err(|error| format!("invalid checkpoint: {error}"))?;
1698 session.import_checkpoint(&checkpoint).await?;
1699 print_json(&serde_json::json!({"status": "checkpoint_imported"}))?;
1700 }
1701 },
1702 Commands::ClipboardRead => {
1703 let text = session.clipboard_read().await?;
1704 println!("{text}");
1705 }
1706 Commands::ClipboardWrite { text } => {
1707 session.clipboard_write(text).await?;
1708 println!("Text written to clipboard");
1709 }
1710 Commands::Tui
1711 | Commands::Ir { .. }
1712 | Commands::InstallChromium { .. }
1713 | Commands::Profiles { .. }
1714 | Commands::DeleteProfile { .. } => {
1715 unreachable!("handled before starting a browser session")
1716 }
1717 }
1718 Ok(())
1719}
1720
1721fn parse_semantic_level(value: &str) -> BrowserResult<SemanticObservationLevel> {
1722 match value {
1723 "summary" => Ok(SemanticObservationLevel::Summary),
1724 "interactive" => Ok(SemanticObservationLevel::Interactive),
1725 "structured" => Ok(SemanticObservationLevel::Structured),
1726 "detailed" => Ok(SemanticObservationLevel::Detailed),
1727 "raw" => Ok(SemanticObservationLevel::Raw),
1728 _ => Err("expected summary, interactive, structured, detailed, or raw".into()),
1729 }
1730}
1731
1732fn read_json_input(path: Option<&std::path::PathBuf>) -> BrowserResult<serde_json::Value> {
1733 let mut input = String::new();
1734 match path {
1735 Some(path) => std::fs::File::open(path)?.read_to_string(&mut input)?,
1736 None => std::io::stdin().read_to_string(&mut input)?,
1737 };
1738 Ok(serde_json::from_str(&input)?)
1739}
1740
1741pub(crate) fn policy_from_cli(cli: &Cli) -> BrowserResult<BrowserPolicy> {
1742 Ok(BrowserPolicy::new(
1743 cli.policy,
1744 std::env::current_dir()?,
1745 cli.policy_allow.iter().copied(),
1746 cli.policy_confirm.iter().copied(),
1747 )?
1748 .with_host_rules(
1749 cli.policy_allow_host.iter().cloned(),
1750 cli.policy_deny_host.iter().cloned(),
1751 )?
1752 .with_confirmation_tokens(cli.policy_confirm_once.iter().copied())?)
1753}
1754
1755async fn run_prompt(
1756 session: &BrowserSession,
1757 prompt: &str,
1758 response_mode: ResponseMode,
1759) -> BrowserResult<()> {
1760 let trimmed = prompt.trim();
1761 let lower = trimmed.to_lowercase();
1762
1763 for prefix in ["navigate to ", "go to ", "open "] {
1764 if lower.starts_with(prefix) {
1765 let page = session.navigate(trimmed[prefix.len()..].trim()).await?;
1766 print_json_mode(&page, response_mode)?;
1767 return Ok(());
1768 }
1769 }
1770 if let Some(rest) = lower.strip_prefix("click ") {
1771 let target = &trimmed[trimmed.len() - rest.len()..];
1772 print_json_mode(
1773 &session.click(target.trim_matches('"')).await?,
1774 response_mode,
1775 )?;
1776 return Ok(());
1777 }
1778 if let Some(rest) = lower.strip_prefix("double click ") {
1779 let target = &trimmed[trimmed.len() - rest.len()..];
1780 print_json_mode(
1781 &session.double_click(target.trim_matches('"')).await?,
1782 response_mode,
1783 )?;
1784 return Ok(());
1785 }
1786 if let Some(rest) = lower.strip_prefix("type ") {
1787 let text = &trimmed[trimmed.len() - rest.len()..];
1788 print_json_mode(
1789 &session.type_text(text.trim_matches('"'), None).await?,
1790 response_mode,
1791 )?;
1792 return Ok(());
1793 }
1794 if lower.starts_with("screenshot") {
1795 let output = trimmed
1796 .split_once(char::is_whitespace)
1797 .map(|(_, value)| value.trim())
1798 .filter(|value| !value.is_empty())
1799 .unwrap_or("screenshot.png");
1800 let output = session
1801 .policy()
1802 .require_output_path(std::path::Path::new(output))?;
1803 std::fs::write(&output, session.screenshot_png().await?)?;
1804 println!("wrote {}", output.display());
1805 return Ok(());
1806 }
1807 if matches!(
1808 lower.as_str(),
1809 "text" | "get text" | "page text" | "get content"
1810 ) {
1811 println!("{}", session.text().await?);
1812 return Ok(());
1813 }
1814 if matches!(lower.as_str(), "dom" | "snapshot" | "get dom") {
1815 print_json_mode(&session.deep_dom().await?, response_mode)?;
1816 return Ok(());
1817 }
1818 if matches!(lower.as_str(), "observe" | "context") {
1819 print_json_mode(&session.observe().await?, response_mode)?;
1820 return Ok(());
1821 }
1822
1823 print_json_mode(&session.evaluate(trimmed).await?, response_mode)?;
1824 Ok(())
1825}
1826
1827fn print_json_mode<T: Serialize + ?Sized>(value: &T, mode: ResponseMode) -> BrowserResult<()> {
1828 let value = serde_json::to_value(value)?;
1829 let projected = project_and_store(value, mode, "cli", default_result_store_path())?;
1830 println!("{}", compact_json(&projected)?);
1831 Ok(())
1832}
1833
1834fn print_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<()> {
1835 println!("{}", compact_json(value)?);
1836 Ok(())
1837}
1838
1839fn compact_json<T: Serialize + ?Sized>(value: &T) -> BrowserResult<String> {
1840 let mut value = serde_json::to_value(value)?;
1841 let payload = serde_json::to_vec(&value)?;
1842 let payload_bytes = payload.len();
1843 if let Some(object) = value.as_object_mut() {
1844 object.insert(
1845 "contextCost".to_string(),
1846 serde_json::json!({
1847 "payloadBytes": payload_bytes,
1848 "estimatedTokens": payload_bytes.div_ceil(4)
1849 }),
1850 );
1851 }
1852 Ok(serde_json::to_string(&value)?)
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use super::*;
1858 use serde_json::json;
1859
1860 #[test]
1861 fn structured_cli_output_is_compact_json() {
1862 let output = compact_json(&json!({
1863 "page": {"title": "Glass", "url": "https://example.com"},
1864 "items": [1, 2]
1865 }))
1866 .unwrap();
1867
1868 let parsed = serde_json::from_str::<serde_json::Value>(&output).unwrap();
1869 assert!(!output.contains('\n'));
1870 assert_eq!(parsed["items"], json!([1, 2]));
1871 assert!(parsed["contextCost"]["payloadBytes"].as_u64().unwrap() > 0);
1872 assert!(parsed["contextCost"]["estimatedTokens"].as_u64().unwrap() > 0);
1873 }
1874
1875 #[test]
1876 fn path_writability_accepts_missing_child_under_writable_parent() {
1877 let root = std::env::temp_dir().join(format!("glass-doctor-test-{}", std::process::id()));
1878 std::fs::create_dir_all(&root).unwrap();
1879 let missing_child = root.join("future").join("artifact");
1880
1881 assert!(path_is_writable(Some(&missing_child)));
1882
1883 std::fs::remove_dir_all(root).unwrap();
1884 }
1885
1886 #[test]
1887 fn path_writability_rejects_missing_path_without_parent() {
1888 assert!(!path_is_writable(None));
1889 }
1890
1891 #[test]
1892 fn workflow_starter_templates_compile_without_coordinate_actions() {
1893 for name in [
1894 "account-search",
1895 "checkout-submit",
1896 "profile-update",
1897 "report-download",
1898 "support-ticket",
1899 ] {
1900 let source = workflow_template(name).unwrap();
1901 let document = compile_workflow(source, WorkflowAuthoringFormat::Yaml).unwrap();
1902 assert!(
1903 !document
1904 .diagnostics
1905 .iter()
1906 .any(|diagnostic| diagnostic.severity == WorkflowDiagnosticSeverity::Error),
1907 "{name} diagnostics: {:?}",
1908 document.diagnostics
1909 );
1910 assert!(!source.contains("clickAt"));
1911 }
1912 }
1913 #[test]
1914 fn task_explanation_contains_metadata_without_input_values() {
1915 let task = crate::task_protocol::GlassTask::from_json(
1916 r#"{
1917 "schemaVersion": 1,
1918 "task": "form.fill",
1919 "scope": {"regionName": "Shipping address"},
1920 "inputs": {"city": "Kuching-secret"},
1921 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1922 "risk": "localMutation"
1923 }"#,
1924 )
1925 .unwrap();
1926 let plan = crate::task_compiler::compile_task(&task).unwrap();
1927
1928 let explanation = explain_task(&task, &plan).unwrap();
1929
1930 assert!(explanation.contains("task: form.fill"));
1931 assert!(explanation.contains("scope: {\"regionName\":\"Shipping address\"}"));
1932 assert!(explanation.contains("inputNames=[\"city\"]"));
1933 assert!(!explanation.contains("Kuching-secret"));
1934 }
1935 #[test]
1936 fn cli_task_requests_use_canonical_protocol_helpers() {
1937 let path = std::env::temp_dir().join(format!(
1938 "glass-cli-task-protocol-{}.json",
1939 std::process::id()
1940 ));
1941 std::fs::write(
1942 &path,
1943 r#"{
1944 "schemaVersion": 1,
1945 "task": "region.extract",
1946 "scope": {"regionName": "Checkout"},
1947 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1948 "risk": "readOnly"
1949 }"#,
1950 )
1951 .unwrap();
1952
1953 let validate = read_task_request(&path, TASK_VALIDATE_OPERATION).unwrap();
1954 assert_eq!(validate.operation, TASK_VALIDATE_OPERATION);
1955 assert!(crate::protocol::validate_task_result(&validate).is_ok());
1956
1957 let compile = read_task_request(&path, TASK_COMPILE_OPERATION).unwrap();
1958 assert_eq!(compile.operation, TASK_COMPILE_OPERATION);
1959 assert!(crate::protocol::compile_task_result(&compile).is_ok());
1960
1961 std::fs::write(
1962 &path,
1963 r#"{
1964 "schemaVersion": 1,
1965 "task": "form.fill",
1966 "scope": {"regionName": "Checkout"},
1967 "limits": {"maxActions": 4, "timeoutMs": 2000, "maxItems": 16},
1968 "risk": "readOnly"
1969 }"#,
1970 )
1971 .unwrap();
1972 let invalid = read_task_request(&path, TASK_COMPILE_OPERATION).unwrap();
1973 assert!(matches!(
1974 crate::protocol::compile_task_result(&invalid),
1975 Err(crate::protocol::ProtocolError::TaskCompilation(_))
1976 ));
1977
1978 std::fs::remove_file(path).unwrap();
1979 }
1980 #[test]
1981 fn cli_web_ir_requests_use_canonical_protocol_helpers() {
1982 let fixture: Value =
1983 serde_json::from_str(include_str!("../../tests/fixtures/protocol-golden-v1.json"))
1984 .unwrap();
1985 let draft = fixture["requests"]
1986 .as_array()
1987 .unwrap()
1988 .iter()
1989 .find(|request| request["operation"] == WEB_IR_INSPECT_OPERATION)
1990 .and_then(|request| request["payload"]["draft"].as_object())
1991 .cloned()
1992 .map(Value::Object)
1993 .unwrap();
1994 let path = std::env::temp_dir().join(format!(
1995 "glass-cli-web-ir-protocol-{}.json",
1996 std::process::id()
1997 ));
1998 std::fs::write(&path, serde_json::to_vec(&draft).unwrap()).unwrap();
1999
2000 let validate = read_web_ir_request(&path, WEB_IR_VALIDATE_OPERATION).unwrap();
2001 assert_eq!(validate.operation, WEB_IR_VALIDATE_OPERATION);
2002 assert!(crate::protocol::web_ir_validate_result(&validate).is_ok());
2003
2004 let inspect = read_web_ir_request(&path, WEB_IR_INSPECT_OPERATION).unwrap();
2005 assert_eq!(inspect.operation, WEB_IR_INSPECT_OPERATION);
2006 assert!(crate::protocol::web_ir_inspect_result(&inspect).is_ok());
2007 let before = read_web_ir_draft(&path).unwrap();
2008 let diff = before.diff(&before).unwrap();
2009 let summary = crate::protocol::WebIrDiffResult::from_diff(&diff);
2010 assert_eq!(summary.from_revision, diff.from_revision);
2011 assert_eq!(summary.to_revision, diff.to_revision);
2012 assert_eq!(summary.entity_added_count, 0);
2013 assert_eq!(summary.entity_removed_count, 0);
2014 assert_eq!(summary.entity_changed_count, 0);
2015 assert_eq!(summary.relationship_added_count, 0);
2016 assert_eq!(summary.relationship_removed_count, 0);
2017
2018 let continuity = read_web_ir_continuity_request(&path, &path, "page").unwrap();
2019 assert_eq!(continuity.operation, WEB_IR_CONTINUITY_OPERATION);
2020 let result = crate::protocol::web_ir_continuity_result(&continuity).unwrap();
2021 assert_eq!(
2022 result.status,
2023 crate::web_ir::DraftEntityContinuityStatus::Unchanged
2024 );
2025
2026 std::fs::remove_file(path).unwrap();
2027 }
2028}