Skip to content
982 linesCodeBlameRaw
1//! What the runner is doing, and when it is not running or is failing, why
2//! and how to fix it: the one catalogue of those words. The tray shows a
3//! diagnosis's title, the settings window all of it with its buttons, and
4//! the site shows it as the app sends it, word for word, on its Runners
5//! page (and keeps it, so a person's other computers are listed with
6//! theirs). The site adds nothing of its own: what it says of a computer
7//! whose g1t is closed is `closed` here, sent with every report.
8//!
9//! Each action is an id the settings window and the site turn into a
10//! button; bridge.rs and settings.js do what it says.
11//!
12//! The sandbox (launcher.rs) says why something is wrong in its own
13//! sentences. Those this catalogue knows (WSL not installed, virtualization
14//! off, Windows Sandbox off, a release without the sandbox's images, …)
15//! are said in its words, with a fix and its buttons; any other is shown as
16//! the launcher wrote it. With the sandbox, each kind of job it runs (Linux
17//! jobs, Windows jobs) is a card of its own (`capabilities`), with its own
18//! state, cause, fix and Start or Stop, since one can run while the other
19//! cannot, or is stopped. The whole runner's diagnosis then only says what
20//! the cards don't (`in_capabilities` is false): signed out, no workspace,
21//! not allowed, no launcher, the whole sandbox down.
22
23use serde::Serialize;
24
25use super::launcher::{self, State, Status, Why};
26
27/// Every state the runner can be in.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Code {
31 /// Running jobs is turned off in settings.
32 #[default]
33 Off,
34 /// The person pressed Stop.
35 Stopped,
36 /// Sandboxed, the person stopped every kind of job there is here.
37 KindsStopped,
38 Starting,
39 /// Waiting for the site to make a registration token.
40 Registering,
41 /// Nobody is signed in to g1t in the app.
42 SignedOut,
43 /// Signed in, owning no workspace: nowhere to register.
44 NoWorkspace,
45 /// The person may not add runners to the workspace.
46 NotAllowed,
47 RegisterFailed,
48 Idle,
49 Busy,
50 NoDocker,
51 /// The `g1t-runner` here is from before its sandbox launcher.
52 NoLauncher,
53 /// Windows, without WSL, which holds the sandbox's Linux VM.
54 NoWsl,
55 /// WSL set to mirrored networking, which reaches this computer's localhost.
56 WslMirrored,
57 /// The computer's virtualization is turned off.
58 NoVirtualization,
59 /// macOS, without vfkit, which runs the sandbox's VM.
60 NoVfkit,
61 /// Linux, with neither Sysbox in Docker nor KVM.
62 NoSysbox,
63 /// The newest release is published without the sandbox's images yet.
64 WaitingForImage,
65 /// The sandbox's image couldn't be downloaded, or isn't the release's.
66 ImageFailed,
67 /// The sandbox can't be made here, for a reason in the launcher's words.
68 SandboxUnavailable,
69 /// The sandbox failed, in the launcher's words.
70 SandboxFailed,
71 /// The computer has a sandbox the app didn't make, and has room for one.
72 SandboxTaken,
73 Updating,
74 UpdateFailed,
75 Offline,
76 Crashed,
77}
78
79/// How a state should look: a dot's colour.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
81#[serde(rename_all = "snake_case")]
82pub enum Tone {
83 Ok,
84 Working,
85 Off,
86 Warning,
87 Problem,
88}
89
90/// A one-click fix.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
92pub struct Action {
93 pub id: &'static str,
94 pub label: &'static str,
95}
96
97#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
98pub struct Diagnosis {
99 pub code: Code,
100 pub tone: Tone,
101 /// One line: the tray's, and the heading everywhere else.
102 pub title: String,
103 /// Why, in a sentence.
104 pub cause: Option<String>,
105 /// What to do about it.
106 pub fix: Option<String>,
107 pub actions: Vec<Action>,
108 /// With the sandbox: each kind of job it runs, as a card of its own.
109 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub capabilities: Vec<CapabilityLine>,
111 /// The cards say all of this, each for its kind of job: a page that
112 /// shows them shows no line of the whole runner's as well.
113 #[serde(skip_serializing_if = "is_false")]
114 pub in_capabilities: bool,
115}
116
117fn is_false(value: &bool) -> bool {
118 !*value
119}
120
121/// How one kind of job is, as its card's label says it.
122#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
123#[serde(rename_all = "snake_case")]
124pub enum KindState {
125 /// Running a job.
126 Busy,
127 /// Waiting for jobs.
128 Idle,
129 Starting,
130 /// Downloading the sandbox's image, which `progress` says how far.
131 Downloading,
132 Updating,
133 /// Waiting for something g1t sees to by itself, such as the release's images.
134 Waiting,
135 /// Stopped: by the person (`enabled` is false), or with the whole runner.
136 Stopped,
137 /// This computer can't run it, and `cause` says why.
138 Unavailable,
139 /// It should run and doesn't: `cause` and `fix`.
140 Problem,
141}
142
143/// One kind of job the sandbox runs, `linux` or `windows` (or `macos`),
144/// and how it is: Linux jobs can run while Windows jobs cannot.
145#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
146pub struct CapabilityLine {
147 pub id: String,
148 /// `Linux jobs`.
149 pub label: String,
150 pub tone: Tone,
151 pub state: KindState,
152 /// Its state in a few words, for its label: `Waiting for jobs`, `Not available`.
153 pub title: String,
154 /// What it is, in a line: `A Linux VM of its own, each job in a fresh container`.
155 pub about: String,
156 /// What is wrong, in a few words, over its cause: `WSL isn't installed`.
157 pub headline: Option<String>,
158 pub cause: Option<String>,
159 pub fix: Option<String>,
160 pub actions: Vec<Action>,
161 /// On in settings: the person hasn't stopped it.
162 pub enabled: bool,
163 /// This computer can run it.
164 pub available: bool,
165 /// It can be started and stopped on its own here (the launcher can).
166 pub can_toggle: bool,
167 /// What it is running now.
168 pub job: Option<JobLine>,
169 /// How far its image has downloaded, in percent.
170 pub progress: Option<u8>,
171 /// How it keeps jobs from the computer: `nested-container`, `vm`.
172 pub isolation: Option<String>,
173 /// Its runner's name on g1t: `build-box-linux`.
174 pub runner: Option<String>,
175 /// Its runner's version.
176 pub version: Option<String>,
177}
178
179/// The job a kind of job is running, with where to see it on g1t.
180#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
181pub struct JobLine {
182 /// `workflow` or `agent`.
183 pub kind: String,
184 pub name: String,
185 pub repo: String,
186 /// The repository's Actions (or Agents) page, where its run is.
187 pub link: Option<String>,
188}
189
190/// What the words are filled in with.
191#[derive(Clone, Debug, Default)]
192pub struct Facts {
193 pub workspace: Option<String>,
194 /// How jobs run, as `sandboxed`, `in Docker`, `on this computer`.
195 pub backend: &'static str,
196 pub machine: String,
197 pub job: Option<String>,
198 pub detail: Option<String>,
199 pub exit_code: Option<i32>,
200 pub wait_seconds: Option<u64>,
201 /// The server's host: `g1t.sh`.
202 pub server: String,
203 /// The server's address, for links: `https://g1t.sh`.
204 pub site: String,
205 pub platform: String,
206 /// What the sandbox says to do, for a cause in its own words.
207 pub fix: Option<String>,
208 /// How the sandbox is, when jobs run sandboxed.
209 pub sandbox: Option<Status>,
210 /// The kinds of job the person stopped in settings.
211 pub off: Vec<String>,
212 /// The sandbox is up, or on its way: a kind of job that should run
213 /// and doesn't is failing, rather than stopped with everything else.
214 pub live: bool,
215 /// The kinds of job a launcher command is starting now.
216 pub starting: Vec<String>,
217 /// How far the sandbox's image has downloaded, while it does.
218 pub download: Option<launcher::Download>,
219 /// The person stopped the whole runner, or turned it off.
220 pub held: bool,
221}
222
223const START: Action = Action { id: "start", label: "Start" };
224const TURN_ON: Action = Action { id: "start", label: "Turn on" };
225const RETRY: Action = Action { id: "retry", label: "Retry" };
226const CHECK_NOW: Action = Action { id: "retry", label: "Check now" };
227const RESTART: Action = Action { id: "restart", label: "Restart" };
228const SIGN_IN: Action = Action { id: "sign_in", label: "Sign in" };
229const CHOOSE_WORKSPACE: Action = Action { id: "choose_workspace", label: "Choose workspace" };
230const NEW_WORKSPACE: Action = Action { id: "new_workspace", label: "Create a workspace" };
231const CHOOSE_BACKEND: Action = Action { id: "choose_backend", label: "Choose how jobs run" };
232const OPEN_DOCKER: Action = Action { id: "open_docker", label: "Open Docker" };
233const GET_DOCKER: Action = Action { id: "get_docker", label: "Get Docker" };
234const INSTALL_SYSBOX: Action = Action { id: "install_sysbox", label: "Install Sysbox" };
235const COPY_WSL_INSTALL: Action = Action { id: "copy_wsl_install", label: "Copy command" };
236const COPY_VFKIT_INSTALL: Action = Action { id: "copy_vfkit_install", label: "Copy command" };
237const WINDOWS_FEATURES: Action = Action { id: "windows_features", label: "Open Windows features" };
238const LEARN_MORE: Action = Action { id: "learn_more", label: "Learn more" };
239const VIEW_LOG: Action = Action { id: "view_log", label: "View log" };
240const REPORT: Action = Action { id: "report", label: "Report" };
241const VIEW_RUNNERS: Action = Action { id: "view_runners", label: "View on g1t" };
242
243fn some(text: impl Into<String>) -> Option<String> {
244 Some(text.into())
245}
246
247/// A wait, said: `30 seconds`, `5 minutes`.
248fn said_wait(seconds: u64) -> String {
249 match seconds {
250 0..=1 => "a moment".into(),
251 2..=89 => format!("{seconds} seconds"),
252 _ => format!("{} minutes", seconds.div_ceil(60)),
253 }
254}
255
256/// The catalogue.
257pub fn diagnose(code: Code, facts: &Facts) -> Diagnosis {
258 let workspace = facts.workspace.as_deref().unwrap_or("your workspace");
259 let detail = facts.detail.clone();
260 let (tone, title, cause, fix, actions): (Tone, String, Option<String>, Option<String>, Vec<Action>) = match code {
261 Code::Off => (
262 Tone::Off,
263 "Not running: turned off".into(),
264 some("Running jobs on this computer is turned off in g1t's settings."),
265 some("Turn it on to run your workspace's self-hosted jobs here while g1t is open."),
266 vec![TURN_ON],
267 ),
268 Code::Stopped => (
269 Tone::Off,
270 "Not running: stopped".into(),
271 some("You stopped the runner. It starts again when you press Start, or when g1t next opens."),
272 None,
273 vec![START],
274 ),
275 Code::KindsStopped => (
276 Tone::Off,
277 "Not running: every kind of job is stopped".into(),
278 some("You stopped each kind of job this computer can run."),
279 some("Start one to run jobs here again."),
280 vec![],
281 ),
282 Code::Starting => (Tone::Working, "Starting".into(), detail, None, vec![]),
283 Code::Registering => (
284 Tone::Working,
285 format!("Registering with {workspace}"),
286 some(format!("g1t is registering {} as a runner for {workspace}.", facts.machine)),
287 some("Keep g1t open and signed in; it takes a few seconds."),
288 vec![],
289 ),
290 Code::SignedOut => (
291 Tone::Warning,
292 "Not running: signed out".into(),
293 some("This computer isn't registered yet, and registering it needs you signed in to g1t."),
294 some("Sign in, as an owner of the workspace the runner is for."),
295 vec![SIGN_IN],
296 ),
297 Code::NoWorkspace => (
298 Tone::Warning,
299 "Not running: no workspace to run jobs for".into(),
300 some("Only a workspace's owners add runners to it, and you don't own one."),
301 some("Ask an owner of your workspace to run it, or create a workspace of your own."),
302 vec![NEW_WORKSPACE],
303 ),
304 Code::NotAllowed => (
305 Tone::Problem,
306 format!("Couldn't register with {workspace}"),
307 some(format!("You can't add runners to {workspace}: only its owners can.")),
308 some(format!("Ask an owner of {workspace} to make you one, or choose a workspace you own.")),
309 vec![CHOOSE_WORKSPACE],
310 ),
311 Code::RegisterFailed => (
312 Tone::Problem,
313 format!("Couldn't register with {workspace}"),
314 detail.or_else(|| some("g1t didn't make a registration for this computer.")),
315 some("g1t asks again by itself in a few minutes; Retry asks now. Its log says more."),
316 vec![RETRY, VIEW_LOG],
317 ),
318 Code::Idle => (
319 Tone::Ok,
320 "Waiting for jobs".into(),
321 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
322 None,
323 vec![VIEW_RUNNERS],
324 ),
325 Code::Busy => (
326 Tone::Working,
327 format!("Running {}", facts.job.as_deref().unwrap_or("a job")),
328 some(format!("Running {} for {workspace} on {}.", facts.backend, facts.machine)),
329 None,
330 vec![VIEW_RUNNERS],
331 ),
332 Code::NoDocker => {
333 let app = match facts.platform.as_str() {
334 "windows" | "macos" => "Docker Desktop",
335 _ => "Docker",
336 };
337 (
338 Tone::Problem,
339 "Not running: Docker isn't running".into(),
340 some(format!("Running jobs {} needs Docker, and Docker isn't running on this computer, or isn't installed.", facts.backend)),
341 some(format!("Start {app}, or install it. The runner starts by itself once Docker answers.")),
342 if app == "Docker Desktop" { vec![OPEN_DOCKER, GET_DOCKER] } else { vec![GET_DOCKER] },
343 )
344 }
345 Code::NoLauncher => (
346 Tone::Warning,
347 "Not running: this g1t-runner has no sandbox".into(),
348 some("The g1t-runner g1t downloaded is older than its sandbox launcher, so it can't make a sandbox."),
349 some("g1t looks for a newer one by itself every few minutes; Retry looks now. Or choose to run jobs in Docker or directly on this computer: neither is a sandbox, and settings say what each can reach."),
350 vec![RETRY, CHOOSE_BACKEND],
351 ),
352 Code::NoWsl => (
353 Tone::Problem,
354 "Not running: WSL isn't installed".into(),
355 some("Linux jobs run in a WSL distribution of the runner's own, and WSL isn't installed on this computer."),
356 some(format!("Run `{}` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included.", launcher::WSL_INSTALL)),
357 vec![COPY_WSL_INSTALL, LEARN_MORE],
358 ),
359 Code::WslMirrored => (
360 Tone::Problem,
361 "Not running: WSL uses mirrored networking".into(),
362 some("WSL is set to mirrored networking, in which Linux reaches this computer's own localhost, so the sandbox won't start."),
363 some("Remove networkingMode=mirrored from %USERPROFILE%\\.wslconfig, run `wsl --shutdown`, then Retry."),
364 vec![RETRY, LEARN_MORE],
365 ),
366 Code::NoVirtualization => (
367 Tone::Problem,
368 "Not running: virtualization is off".into(),
369 some("The sandbox is a small virtual machine, and this computer has virtualization turned off."),
370 some(if facts.platform == "windows" {
371 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, and Virtual Machine Platform in Windows features; then restart."
372 } else {
373 "Turn on virtualization (Intel VT-x or AMD-V) in this computer's firmware settings, then restart."
374 }),
375 if facts.platform == "windows" { vec![WINDOWS_FEATURES, LEARN_MORE] } else { vec![RETRY, LEARN_MORE] },
376 ),
377 Code::NoVfkit => (
378 Tone::Problem,
379 "Not running: vfkit isn't installed".into(),
380 some("Linux jobs run in a small VM that vfkit runs with Apple's Virtualization framework, and vfkit isn't installed."),
381 some(format!("Run `{}` in Terminal (macOS 13 or later). The runner starts by itself once it's there.", launcher::VFKIT_INSTALL)),
382 vec![COPY_VFKIT_INSTALL, LEARN_MORE],
383 ),
384 Code::NoSysbox => (
385 Tone::Problem,
386 "Not running: no Sysbox or KVM".into(),
387 some("The sandbox on Linux is the runner's image under Sysbox, or a VM under QEMU with KVM, and this computer has neither."),
388 some("Install Sysbox 0.7 or later, or turn on virtualization and install QEMU so /dev/kvm exists. The runner starts by itself once one is there."),
389 vec![INSTALL_SYSBOX, CHOOSE_BACKEND],
390 ),
391 Code::WaitingForImage => {
392 let (release, image) = detail.as_deref().and_then(launcher::missing_images).map_or((None, None), |(release, image)| (Some(release), image));
393 let release = release.map_or_else(|| "The newest g1t-runner".to_owned(), |release| format!("g1t-runner {release}"));
394 (
395 Tone::Warning,
396 "Waiting for the sandbox's image".into(),
397 some(match image {
398 Some(image) => format!("{release} is published without the sandbox's image for this kind of computer ({image}) yet, so Linux jobs can't start."),
399 None => format!("{release} is published without the sandbox's images yet, so Linux jobs can't start."),
400 }),
401 some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."),
402 vec![CHECK_NOW],
403 )
404 }
405 Code::ImageFailed => {
406 let said = detail.clone().unwrap_or_default().to_ascii_lowercase();
407 if said.contains("sha-256") || said.contains("signature") {
408 (
409 Tone::Problem,
410 "Not running: the sandbox's image didn't check out".into(),
411 some("The sandbox's image that downloaded isn't the one g1t's release signed, so it wasn't used."),
412 some(format!(
413 "g1t downloads it again by itself{}. If this keeps happening, something between this computer and {} is changing downloads, such as a proxy or antivirus: report it.",
414 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
415 facts.server
416 )),
417 vec![RETRY, REPORT],
418 )
419 } else {
420 (
421 Tone::Problem,
422 "Not running: the sandbox's image didn't download".into(),
423 some(match &detail {
424 Some(detail) => format!("The sandbox's image couldn't be downloaded: {detail}"),
425 None => "The sandbox's image couldn't be downloaded.".into(),
426 }),
427 some(format!(
428 "g1t tries again by itself{}. Check that this computer can reach {}; Retry tries now.",
429 facts.wait_seconds.map(|wait| format!(" in {}", said_wait(wait))).unwrap_or_default(),
430 facts.server
431 )),
432 vec![RETRY, VIEW_LOG],
433 )
434 }
435 }
436 Code::SandboxUnavailable => (
437 Tone::Problem,
438 "Not running: the sandbox can't run here".into(),
439 detail.or_else(|| some("The sandbox can't be made on this computer.")),
440 facts.fix.clone().or_else(|| some("Choose to run jobs in Docker or directly on this computer instead: neither is a sandbox, and settings say what each can reach.")),
441 vec![RETRY, LEARN_MORE, CHOOSE_BACKEND],
442 ),
443 Code::SandboxFailed => (
444 Tone::Problem,
445 "Not running: the sandbox failed".into(),
446 detail.or_else(|| some("The sandbox stopped, and didn't say why.")),
447 some(match facts.wait_seconds {
448 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Retry starts it now.", said_wait(wait)),
449 None => "Retry starts it again; its log says why it stopped.".into(),
450 }),
451 vec![RETRY, VIEW_LOG],
452 ),
453 Code::SandboxTaken => (
454 Tone::Warning,
455 "Not running: this computer has another sandbox".into(),
456 some("This computer already has a g1t-runner sandbox that g1t didn't make, such as one started with `g1t-runner sandbox up`, and it has room for one."),
457 some("Remove it with `g1t-runner sandbox down --delete`, then Retry; its runners stay listed on g1t until you remove them there. Or keep it, and turn off running jobs in g1t."),
458 vec![RETRY, LEARN_MORE],
459 ),
460 Code::Updating => (Tone::Working, "Updating".into(), detail, some("The runner starts again as soon as it's done."), vec![]),
461 Code::UpdateFailed => (
462 Tone::Warning,
463 "Update failed".into(),
464 detail,
465 some("The runner keeps running the version it has, and g1t tries the update again in a few hours. Retry tries now; if it keeps failing, report it."),
466 vec![RETRY, REPORT],
467 ),
468 Code::Offline => (
469 Tone::Warning,
470 format!("Offline: {} unreachable", facts.server),
471 some(format!("This computer can't reach {}, so the runner can't take jobs.", facts.server)),
472 some("Check the connection. The runner carries on by itself when it's back."),
473 vec![RETRY],
474 ),
475 Code::Crashed => (
476 Tone::Problem,
477 match facts.exit_code {
478 Some(exit) => format!("Runner stopped (exit code {exit})"),
479 None => "Runner stopped".into(),
480 },
481 detail,
482 Some(match facts.wait_seconds {
483 Some(wait) => format!("It starts again by itself in {}. If it keeps stopping, its log says why; Restart starts it now.", said_wait(wait)),
484 None => "Restart starts it again; its log says why it stopped.".into(),
485 }),
486 vec![RESTART, VIEW_LOG],
487 ),
488 };
489 let capabilities = facts.sandbox.as_ref().map(|status| capabilities(status, facts)).unwrap_or_default();
490 let in_capabilities = !capabilities.is_empty()
491 && match code {
492 Code::Idle | Code::Busy | Code::KindsStopped => true,
493 Code::Starting | Code::Updating => facts.live,
494 // Said by a card, for its kind of job, word for word.
495 Code::NoWsl
496 | Code::WslMirrored
497 | Code::NoVirtualization
498 | Code::NoVfkit
499 | Code::NoSysbox
500 | Code::WaitingForImage
501 | Code::ImageFailed
502 | Code::SandboxUnavailable
503 | Code::SandboxFailed => cause.is_some() && capabilities.iter().any(|c| c.cause == cause),
504 _ => false,
505 };
506 Diagnosis { code, tone, title, cause, fix, actions, capabilities, in_capabilities }
507}
508
509/// What the app calls a sandbox's problem with Linux jobs, from the
510/// launcher's sentence about it, which goes with it as its detail.
511pub fn sandbox_code(why: Why) -> Code {
512 match why {
513 Why::NoWsl => Code::NoWsl,
514 Why::WslMirrored => Code::WslMirrored,
515 Why::NoVirtualization => Code::NoVirtualization,
516 Why::NoVfkit => Code::NoVfkit,
517 Why::NoSysboxOrKvm => Code::NoSysbox,
518 Why::NoImages => Code::WaitingForImage,
519 Why::Image => Code::ImageFailed,
520 _ => Code::SandboxFailed,
521 }
522}
523
524/// The code for what is wrong with Linux jobs, as a status says it, and
525/// the launcher's own words for it.
526pub fn sandbox_trouble(status: &Status) -> Option<(Code, String, Option<String>)> {
527 let (cause, fix) = status.trouble()?;
528 let code = match launcher::why(&cause) {
529 Why::Other if !status.linux().available => Code::SandboxUnavailable,
530 why => sandbox_code(why),
531 };
532 Some((code, cause, fix))
533}
534
535/// Each kind of job the sandbox runs, as a card of its own.
536fn capabilities(status: &Status, facts: &Facts) -> Vec<CapabilityLine> {
537 let listed = if status.capabilities.is_empty() { vec![status.linux()] } else { status.capabilities.clone() };
538 listed.iter().map(|capability| capability_line(status, capability, facts)).collect()
539}
540
541/// `Linux jobs`.
542pub fn kind_label(id: &str) -> String {
543 match id {
544 "linux" => "Linux jobs".to_owned(),
545 "windows" => "Windows jobs".to_owned(),
546 "macos" => "macOS jobs".to_owned(),
547 other => format!("{other} jobs"),
548 }
549}
550
551/// What a kind of job is, in a line, by how the sandbox makes it.
552fn about(id: &str, backend: Option<&str>) -> String {
553 match (id, backend) {
554 ("linux", Some("sysbox")) => "The runner under Sysbox, each job in a fresh container".into(),
555 ("linux", _) => "A Linux VM of its own, each job in a fresh container".into(),
556 ("windows", _) => "Each job in a Windows Sandbox, discarded after".into(),
557 ("macos", _) => "Each job in a macOS VM of its own, discarded after".into(),
558 _ => "Each job sandboxed".into(),
559 }
560}
561
562/// A catalogue title as a card's headline: `Not running: virtualization is off` is `Virtualization is off`.
563fn headline(title: &str) -> String {
564 let said = title.strip_prefix("Not running: ").unwrap_or(title);
565 let mut letters = said.chars();
566 letters.next().map(|first| first.to_uppercase().chain(letters).collect()).unwrap_or_default()
567}
568
569fn capability_line(status: &Status, capability: &launcher::Capability, facts: &Facts) -> CapabilityLine {
570 let id = capability.id.as_str();
571 let label = kind_label(id);
572 let enabled = !facts.off.iter().any(|off| off == id);
573 let per_kind = status.per_kind();
574 let mut line = CapabilityLine {
575 id: id.to_owned(),
576 label: label.clone(),
577 tone: Tone::Off,
578 state: KindState::Stopped,
579 title: "Stopped".into(),
580 about: about(id, status.backend.as_deref()),
581 headline: None,
582 cause: None,
583 fix: None,
584 actions: vec![],
585 enabled,
586 available: capability.available,
587 // Nothing to start or stop where it can't run, nor while the whole runner is
588 // stopped, whose own Start starts it (one the person stopped can be started).
589 can_toggle: per_kind && (capability.available || !enabled) && !(facts.held && enabled),
590 job: None,
591 progress: None,
592 isolation: capability.isolation.clone().filter(|_| capability.available),
593 runner: capability.available.then(|| format!("{}-{id}", facts.machine)),
594 version: capability.runner_version.clone(),
595 };
596 let said = |line: &mut CapabilityLine, state: KindState, tone: Tone, cause: Option<String>, fix: Option<String>, actions: Vec<Action>| {
597 line.state = state;
598 line.tone = tone;
599 line.title = match state {
600 KindState::Busy => "Running a job",
601 KindState::Idle => "Waiting for jobs",
602 KindState::Starting => "Starting",
603 KindState::Downloading => "Downloading image",
604 KindState::Updating => "Updating",
605 KindState::Waiting => "Waiting for image",
606 KindState::Stopped => "Stopped",
607 KindState::Unavailable => "Not available",
608 KindState::Problem => "Problem",
609 }
610 .into();
611 line.cause = cause;
612 line.fix = fix;
613 line.actions = actions;
614 };
615 let again = |what: &str| format!("g1t {what} again by itself in a few minutes; Retry {what} now.");
616 let starting = facts.starting.iter().any(|kind| kind == id) || matches!(capability.state, State::Starting);
617
618 // Stopped by the person: nothing is wrong with it.
619 if capability.off() || (!enabled && per_kind) {
620 said(&mut line, KindState::Stopped, Tone::Off, some(format!("You stopped {label} on this computer. They stay stopped until you start them.")), None, vec![]);
621 return line;
622 }
623 // This computer can't run it, and why.
624 if !capability.available {
625 if id == "linux"
626 && let Some((code, cause, fix)) = sandbox_trouble(status)
627 {
628 let whole = diagnose(code, &Facts { detail: Some(cause), fix, sandbox: None, ..facts.clone() });
629 said(&mut line, KindState::Unavailable, Tone::Warning, whole.cause, whole.fix, whole.actions);
630 line.headline = Some(headline(&whole.title));
631 return line;
632 }
633 let cause = capability.cause.clone();
634 let (headline, cause, fix, actions) = match cause.as_deref().map(launcher::why) {
635 Some(Why::WindowsSandboxOff) => (
636 "Windows Sandbox is off",
637 some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."),
638 some("On Windows 11 Pro, Enterprise or Education, turn on Windows Sandbox in Windows features, then restart."),
639 vec![WINDOWS_FEATURES],
640 ),
641 Some(Why::NoWsb) => (
642 "Windows needs an update",
643 some("This Windows has Windows Sandbox, but not its wsb command, which came with Windows 11 24H2."),
644 some("Update Windows to 24H2 or later, then restart g1t."),
645 vec![],
646 ),
647 Some(Why::MacNotReleased) => (
648 "Not released yet",
649 some("Sandboxed macOS jobs aren't released yet."),
650 some("They come with a g1t-runner update, which g1t downloads by itself. There's nothing for you to do."),
651 vec![],
652 ),
653 _ => ("Can't run here", cause, capability.fix.clone(), vec![LEARN_MORE]),
654 };
655 said(&mut line, KindState::Unavailable, Tone::Off, cause, fix, actions);
656 line.headline = Some(headline.into());
657 return line;
658 }
659 // Linux jobs that should run and don't, in the catalogue's words.
660 if id == "linux"
661 && !starting
662 && !matches!(capability.state, State::Updating)
663 && let Some((code, cause, fix)) = sandbox_trouble(status)
664 {
665 let wait_seconds = if facts.live { None } else { facts.wait_seconds };
666 let whole = diagnose(code, &Facts { detail: Some(cause), fix, wait_seconds, sandbox: None, ..facts.clone() });
667 let state = if code == Code::WaitingForImage { KindState::Waiting } else { KindState::Problem };
668 said(&mut line, state, whole.tone, whole.cause, whole.fix, whole.actions);
669 line.headline = Some(headline(&whole.title));
670 return line;
671 }
672 // A native kind of job whose runner didn't start, in the launcher's words.
673 if id != "linux" && capability.state == State::Error && !starting {
674 let cause = capability.cause.clone().or_else(|| some("Its runner didn't start, and didn't say why."));
675 said(&mut line, KindState::Problem, Tone::Problem, cause, some(again("tries")), vec![RETRY, VIEW_LOG]);
676 line.headline = Some("Its runner didn't start".into());
677 return line;
678 }
679 let downloading = facts.download.as_ref().filter(|_| id == "linux");
680 match capability.state {
681 _ if starting || capability.state == State::Updating => match downloading {
682 Some(download) => {
683 said(&mut line, KindState::Downloading, Tone::Working, some("Downloading and checking the sandbox's image, once: it takes a few minutes."), None, vec![]);
684 line.progress = Some(download.percent);
685 }
686 None if capability.state == State::Updating => said(&mut line, KindState::Updating, Tone::Working, some("Taking the newest sandbox image; it takes jobs again when it's done."), None, vec![]),
687 None if capability.state == State::Absent => said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]),
688 None => said(&mut line, KindState::Starting, Tone::Working, None, None, vec![]),
689 },
690 State::Running => match &capability.job {
691 Some(job) => {
692 said(&mut line, KindState::Busy, Tone::Working, None, None, vec![]);
693 let page = if job.kind == "agent" { "agents" } else { "actions" };
694 let link = (!job.repo.is_empty() && !facts.site.is_empty()).then(|| format!("{}/{}/{page}", facts.site.trim_end_matches('/'), job.repo));
695 line.job = Some(JobLine { kind: job.kind.clone(), name: job.name.clone(), repo: job.repo.clone(), link });
696 }
697 None => said(&mut line, KindState::Idle, Tone::Ok, None, None, vec![]),
698 },
699 // Up while the others run, yet not running: it stopped by itself,
700 // or is to be registered.
701 State::Stopped | State::Absent if facts.live && status.running() => {
702 if capability.state == State::Absent {
703 said(&mut line, KindState::Starting, Tone::Working, some(format!("Registering it with {}.", facts.workspace.as_deref().unwrap_or("your workspace"))), None, vec![]);
704 } else {
705 said(&mut line, KindState::Problem, Tone::Problem, some("Its runner stopped by itself."), some(again("starts it")), vec![RETRY, VIEW_LOG]);
706 line.headline = Some("Its runner stopped".into());
707 }
708 }
709 State::Stopped | State::Absent => said(&mut line, KindState::Stopped, Tone::Off, None, None, vec![]),
710 State::Error | State::Unknown | State::Starting | State::Updating => {
711 said(&mut line, KindState::Problem, Tone::Problem, capability.cause.clone().or_else(|| status.error.clone()), capability.fix.clone(), vec![RETRY, VIEW_LOG]);
712 }
713 }
714 // The person stopped it, and this launcher can't stop one kind alone yet.
715 if !enabled && line.fix.is_none() {
716 line.fix = some(format!("This g1t-runner can't stop {label} on their own; the next one can, and g1t downloads it by itself."));
717 }
718 line
719}
720
721/// What the site says of this computer while g1t is closed on it.
722pub fn closed(facts: &Facts) -> Diagnosis {
723 Diagnosis {
724 code: Code::Off,
725 tone: Tone::Off,
726 title: format!("g1t is closed on {}", facts.machine),
727 cause: some(format!("This runner runs while g1t is open on {}.", facts.machine)),
728 fix: some(format!("Open g1t on {} to run jobs there again.", facts.machine)),
729 actions: vec![],
730 capabilities: vec![],
731 in_capabilities: false,
732 }
733}
734
735#[cfg(test)]
736mod tests {
737 use super::*;
738
739 fn facts() -> Facts {
740 Facts {
741 workspace: Some("acme".into()),
742 backend: "sandboxed",
743 machine: "build-box".into(),
744 server: "g1t.sh".into(),
745 site: "https://g1t.sh".into(),
746 platform: "windows".into(),
747 ..Facts::default()
748 }
749 }
750
751 #[test]
752 fn every_failure_says_why_and_how_to_fix_it() {
753 for code in [
754 Code::SignedOut,
755 Code::NoWorkspace,
756 Code::NotAllowed,
757 Code::RegisterFailed,
758 Code::NoDocker,
759 Code::NoLauncher,
760 Code::NoWsl,
761 Code::WslMirrored,
762 Code::NoVirtualization,
763 Code::NoVfkit,
764 Code::NoSysbox,
765 Code::WaitingForImage,
766 Code::ImageFailed,
767 Code::SandboxUnavailable,
768 Code::SandboxFailed,
769 Code::SandboxTaken,
770 Code::UpdateFailed,
771 Code::Offline,
772 Code::Crashed,
773 ] {
774 let diagnosis = diagnose(code, &Facts { detail: Some("why".into()), ..facts() });
775 assert!(diagnosis.cause.is_some(), "{code:?} has no cause");
776 assert!(diagnosis.fix.is_some(), "{code:?} has no fix");
777 assert!(!diagnosis.actions.is_empty(), "{code:?} has no action");
778 assert!(matches!(diagnosis.tone, Tone::Warning | Tone::Problem), "{code:?}");
779 // No fix sends the person off to check their connection or report it
780 // when that isn't what is wrong.
781 let fix = diagnosis.fix.unwrap();
782 if !matches!(code, Code::Offline | Code::ImageFailed) {
783 assert!(!fix.contains("check the connection") && !fix.contains("Check the connection"), "{code:?}: {fix}");
784 }
785 }
786 }
787
788 #[test]
789 fn the_words_name_the_workspace_the_machine_and_the_job() {
790 let not_allowed = diagnose(Code::NotAllowed, &facts());
791 assert_eq!(not_allowed.cause.as_deref(), Some("You can't add runners to acme: only its owners can."));
792 assert_eq!(not_allowed.actions[0].id, "choose_workspace");
793 let busy = diagnose(Code::Busy, &Facts { job: Some("Build (acme/web)".into()), ..facts() });
794 assert_eq!(busy.title, "Running Build (acme/web)");
795 assert_eq!(busy.cause.as_deref(), Some("Running sandboxed for acme on build-box."));
796 let crashed = diagnose(Code::Crashed, &Facts { exit_code: Some(101), wait_seconds: Some(30), ..facts() });
797 assert_eq!(crashed.title, "Runner stopped (exit code 101)");
798 assert_eq!(crashed.fix.as_deref(), Some("It starts again by itself in 30 seconds. If it keeps stopping, its log says why; Restart starts it now."));
799 assert_eq!(crashed.actions.iter().map(|a| a.id).collect::<Vec<_>>(), ["restart", "view_log"]);
800 assert_eq!(diagnose(Code::NoDocker, &facts()).actions[0].id, "open_docker");
801 assert_eq!(closed(&facts()).title, "g1t is closed on build-box");
802 assert_eq!(said_wait(300), "5 minutes");
803 assert_eq!(said_wait(45), "45 seconds");
804 }
805
806 fn status(json: &str) -> Status {
807 launcher::parse(json).unwrap()
808 }
809
810 const NO_WSL_PRO: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.2.1",
811 "error":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
812 "capabilities":[
813 {"id":"linux","available":false,"state":"error","isolation":"nested-container",
814 "cause":"WSL is not installed, and the sandbox for Linux jobs is a WSL distribution of its own.",
815 "fix":"Open a terminal as administrator and run `wsl --install --no-distribution`, then restart Windows. It works on every edition of Windows 11, Home included."},
816 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.2.1","job":null}]}"#;
817
818 #[test]
819 fn without_wsl_the_fix_is_the_command_to_copy_and_windows_jobs_still_show() {
820 let status = status(NO_WSL_PRO);
821 let (code, cause, _) = sandbox_trouble(&status).unwrap();
822 assert_eq!(code, Code::NoWsl);
823 let diagnosis = diagnose(code, &Facts { detail: Some(cause), sandbox: Some(status), live: true, ..facts() });
824 assert_eq!(diagnosis.title, "Not running: WSL isn't installed");
825 assert_eq!(diagnosis.fix.as_deref(), Some("Run `wsl --install --no-distribution` in an admin terminal, then restart Windows. It works on every edition of Windows 11, Home included."));
826 assert_eq!(diagnosis.actions[0], COPY_WSL_INSTALL);
827 let [linux, windows] = &diagnosis.capabilities[..] else { panic!("{:?}", diagnosis.capabilities) };
828 assert_eq!((linux.label.as_str(), linux.title.as_str(), linux.state), ("Linux jobs", "Not available", KindState::Unavailable));
829 assert_eq!(linux.headline.as_deref(), Some("WSL isn't installed"));
830 assert_eq!(linux.fix, diagnosis.fix);
831 assert_eq!(linux.actions[0].id, "copy_wsl_install");
832 assert_eq!((windows.label.as_str(), windows.title.as_str(), windows.tone), ("Windows jobs", "Waiting for jobs", Tone::Ok));
833 assert_eq!(windows.runner.as_deref(), Some("build-box-windows"));
834 assert_eq!(windows.about, "Each job in a Windows Sandbox, discarded after");
835 // The Linux card says it all: the whole runner's line isn't shown as well.
836 assert!(diagnosis.in_capabilities);
837 // An older launcher can't stop one kind of job alone.
838 assert!(!windows.can_toggle);
839 }
840
841 #[test]
842 fn on_home_windows_jobs_are_unavailable_and_linux_jobs_still_run() {
843 let status = status(
844 r#"{"schema":1,"state":"running","backend":"wsl2","runner_version":"0.3.0","launcher_version":"0.3.0",
845 "job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"},
846 "capabilities":[
847 {"id":"linux","available":true,"state":"running","runner_version":"0.3.0","isolation":"nested-container","turned_off":false,"job":{"kind":"workflow","id":"job_1","name":"build","repo":"acme/web"}},
848 {"id":"windows","available":false,"state":"absent","turned_off":false,"cause":"Windows Sandbox is not turned on, or this edition of Windows has none (Home does not).","fix":"On Windows 11 Pro…"}]}"#,
849 );
850 assert_eq!(sandbox_trouble(&status), None);
851 let busy = diagnose(Code::Busy, &Facts { job: status.job().map(launcher::Job::said), sandbox: Some(status), live: true, ..facts() });
852 assert_eq!(busy.title, "Running build (acme/web)");
853 assert!(busy.in_capabilities);
854 let [linux, windows] = &busy.capabilities[..] else { panic!() };
855 assert_eq!((linux.title.as_str(), linux.state, linux.tone), ("Running a job", KindState::Busy, Tone::Working));
856 let job = linux.job.as_ref().unwrap();
857 assert_eq!((job.name.as_str(), job.repo.as_str(), job.link.as_deref()), ("build", "acme/web", Some("https://g1t.sh/acme/web/actions")));
858 assert!(linux.can_toggle && linux.enabled);
859 assert_eq!((windows.title.as_str(), windows.tone, windows.state), ("Not available", Tone::Off, KindState::Unavailable));
860 assert_eq!(windows.headline.as_deref(), Some("Windows Sandbox is off"));
861 assert_eq!(windows.cause.as_deref(), Some("Windows Sandbox is turned off, or this edition of Windows has none: Home doesn't."));
862 assert_eq!(windows.actions[0].id, "windows_features");
863 // Nothing to start or stop where it can't run.
864 assert!(!windows.can_toggle);
865 assert_eq!(windows.runner, None);
866 }
867
868 /// 0.3.0 was published without the sandbox's images: Linux jobs wait for
869 /// them, plainly, while Windows jobs run.
870 const NO_IMAGES: &str = r#"{"schema":1,"state":"error","backend":"wsl2","launcher_version":"0.3.0",
871 "error":"Windows jobs are sandboxed and running, but Linux jobs are not: release 0.3.0 has no sandbox images",
872 "capabilities":[
873 {"id":"linux","available":true,"state":"error","isolation":"nested-container","turned_off":false},
874 {"id":"windows","available":true,"state":"running","isolation":"vm","runner_version":"0.3.0","turned_off":false}]}"#;
875
876 #[test]
877 fn a_release_without_images_is_waited_for_not_reported() {
878 let status = status(NO_IMAGES);
879 let (code, cause, _) = sandbox_trouble(&status).unwrap();
880 assert_eq!(code, Code::WaitingForImage);
881 let whole = diagnose(code, &Facts { detail: Some(cause), ..facts() });
882 assert_eq!(whole.title, "Waiting for the sandbox's image");
883 assert_eq!(whole.cause.as_deref(), Some("g1t-runner 0.3.0 is published without the sandbox's images yet, so Linux jobs can't start."));
884 assert_eq!(whole.fix.as_deref(), Some("g1t checks again every few minutes and starts Linux jobs once they're out. There's nothing for you to do."));
885 assert_eq!(whole.actions, [CHECK_NOW]);
886 assert!(!whole.fix.unwrap().contains("report"));
887 // Windows jobs run: the runner waits for jobs, and the Linux card says what it waits for.
888 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status), live: true, ..facts() });
889 assert!(idle.in_capabilities);
890 let [linux, windows] = &idle.capabilities[..] else { panic!() };
891 assert_eq!((linux.state, linux.title.as_str(), linux.tone), (KindState::Waiting, "Waiting for image", Tone::Warning));
892 assert_eq!(linux.headline.as_deref(), Some("Waiting for the sandbox's image"));
893 assert_eq!(windows.state, KindState::Idle);
894 let image = diagnose(Code::ImageFailed, &Facts { detail: Some("the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it".into()), ..facts() });
895 assert_eq!(image.title, "Not running: the sandbox's image didn't check out");
896 let fetch = diagnose(Code::ImageFailed, &Facts { detail: Some("could not fetch https://g1t.sh/x: dns error".into()), wait_seconds: Some(60), ..facts() });
897 assert_eq!(fetch.cause.as_deref(), Some("The sandbox's image couldn't be downloaded: could not fetch https://g1t.sh/x: dns error"));
898 assert_eq!(fetch.fix.as_deref(), Some("g1t tries again by itself in 60 seconds. Check that this computer can reach g1t.sh; Retry tries now."));
899 }
900
901 #[test]
902 fn a_kind_the_person_stopped_says_so_and_is_not_failing() {
903 let status = status(
904 r#"{"schema":1,"state":"running","backend":"wsl2","capabilities":[
905 {"id":"linux","available":true,"state":"running","turned_off":false},
906 {"id":"windows","available":true,"state":"stopped","turned_off":true}]}"#,
907 );
908 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(status.clone()), live: true, off: vec!["windows".into()], ..facts() });
909 let [linux, windows] = &idle.capabilities[..] else { panic!() };
910 assert_eq!(linux.state, KindState::Idle);
911 assert_eq!((windows.state, windows.tone, windows.enabled), (KindState::Stopped, Tone::Off, false));
912 assert_eq!(windows.cause.as_deref(), Some("You stopped Windows jobs on this computer. They stay stopped until you start them."));
913 assert!(windows.can_toggle && windows.actions.is_empty());
914 // Stopped by itself while Linux jobs run is a problem, with Retry.
915 let died = launcher::parse(&serde_json::to_string(&serde_json::json!({"schema":1,"state":"running","capabilities":[
916 {"id":"linux","available":true,"state":"running","turned_off":false},
917 {"id":"windows","available":true,"state":"stopped","turned_off":false}]})).unwrap()).unwrap();
918 let idle = diagnose(Code::Idle, &Facts { sandbox: Some(died.clone()), live: true, ..facts() });
919 assert_eq!((idle.capabilities[1].state, idle.capabilities[1].actions[0].id), (KindState::Problem, "retry"));
920 // With the whole runner stopped, it is only stopped.
921 let stopped = diagnose(Code::Stopped, &Facts { sandbox: Some(died), live: false, held: true, ..facts() });
922 // Its own Start or Stop waits for the runner's: the line above the cards starts it.
923 assert!(!stopped.capabilities[0].can_toggle);
924 assert_eq!(stopped.capabilities[1].state, KindState::Stopped);
925 assert!(!stopped.in_capabilities);
926 }
927
928 #[test]
929 fn a_download_shows_how_far_it_is() {
930 let status = status(r#"{"schema":1,"state":"starting","capabilities":[{"id":"linux","available":true,"state":"starting","turned_off":false},{"id":"windows","available":true,"state":"absent","turned_off":false}]}"#);
931 let facts = Facts { sandbox: Some(status), live: true, starting: vec!["linux".into(), "windows".into()], download: Some(launcher::Download { key: "wsl-x64".into(), percent: 40 }), ..facts() };
932 let starting = diagnose(Code::Starting, &facts);
933 assert!(starting.in_capabilities);
934 let [linux, windows] = &starting.capabilities[..] else { panic!() };
935 assert_eq!((linux.state, linux.title.as_str(), linux.progress), (KindState::Downloading, "Downloading image", Some(40)));
936 assert_eq!((windows.state, windows.progress), (KindState::Starting, None));
937 }
938
939 #[test]
940 fn each_launcher_trouble_has_its_words() {
941 let trouble = |linux_available: bool, text: &str| {
942 let json = serde_json::json!({
943 "schema": 1, "state": "error", "error": text,
944 "capabilities": [{"id": "linux", "available": linux_available, "state": "error", "cause": if linux_available { None } else { Some(text) }, "fix": "the launcher's fix"}]
945 });
946 sandbox_trouble(&status(&json.to_string())).unwrap()
947 };
948 assert_eq!(trouble(true, "WSL could not import the sandbox: Please enable the Virtual Machine Platform Windows feature and ensure virtualization is enabled in the BIOS.").0, Code::NoVirtualization);
949 assert_eq!(trouble(false, "vfkit, which runs the sandbox's VM with Apple's Virtualization framework, is not installed.").0, Code::NoVfkit);
950 assert_eq!(trouble(false, "This machine has neither Sysbox nor KVM, so there is nothing to sandbox the runner with.").0, Code::NoSysbox);
951 assert_eq!(trouble(false, "WSL is set to mirrored networking, in which Linux reaches this computer's own localhost.").0, Code::WslMirrored);
952 assert_eq!(trouble(true, "the sandbox image wsl-x64.tar.gz's SHA-256 is not the release's; not using it").0, Code::ImageFailed);
953 assert_eq!(trouble(true, "release 0.3.0 has no sandbox images").0, Code::WaitingForImage);
954 // Unknown words: shown as the launcher wrote them, its fix with them.
955 let (code, cause, fix) = trouble(false, "This user may not use /dev/kvm.");
956 assert_eq!(code, Code::SandboxUnavailable);
957 let unavailable = diagnose(code, &Facts { detail: Some(cause), fix, ..facts() });
958 assert_eq!(unavailable.cause.as_deref(), Some("This user may not use /dev/kvm."));
959 assert_eq!(unavailable.fix.as_deref(), Some("the launcher's fix"));
960 let (code, cause, _) = trouble(true, "the sandbox did not start within 600 seconds");
961 assert_eq!(code, Code::SandboxFailed);
962 let failed = diagnose(code, &Facts { detail: Some(cause), wait_seconds: Some(60), ..facts() });
963 assert_eq!(failed.cause.as_deref(), Some("the sandbox did not start within 600 seconds"));
964 assert_eq!(failed.fix.as_deref(), Some("It starts again by itself in 60 seconds. If it keeps stopping, its log says why; Retry starts it now."));
965 assert_eq!(diagnose(Code::NoVirtualization, &Facts { platform: "macos".into(), ..facts() }).actions[0].id, "retry");
966 assert_eq!(diagnose(Code::NoVfkit, &facts()).actions[0].id, "copy_vfkit_install");
967 }
968
969 #[test]
970 fn a_diagnosis_without_the_sandbox_lists_no_capabilities() {
971 let json = serde_json::to_value(diagnose(Code::Idle, &Facts { backend: "in Docker", ..facts() })).unwrap();
972 assert!(json.get("capabilities").is_none());
973 assert!(json.get("in_capabilities").is_none());
974 let sandboxed = serde_json::to_value(diagnose(Code::Idle, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() })).unwrap();
975 assert_eq!(sandboxed["capabilities"][1]["id"], "windows");
976 assert_eq!(sandboxed["capabilities"][1]["state"], "idle");
977 assert_eq!(sandboxed["in_capabilities"], true);
978 // What affects everything is said once, above the cards.
979 let signed_out = diagnose(Code::SignedOut, &Facts { sandbox: Some(status(NO_WSL_PRO)), ..facts() });
980 assert!(!signed_out.in_capabilities);
981 }
982}