ps-qa 0.5.11

Drive a running Blitz app through its MCP control socket and assert what the renderer did
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
//! The command line, as a type.
//!
//! # Why this is not hand-rolled
//!
//! It was, and the cost showed up as bugs rather than as ugliness. Modes were
//! matched on `args[0]` and every parameter read by position through
//! `args.get(n)`, so a flag was indistinguishable from a positional: `qa rename
//! --toon` took `--toon` as the group name and reported "no check matching
//! Some(\"--toon\")" until the flag was filtered back out by hand at each site.
//! A mistyped flag was silently ignored rather than rejected, because nothing
//! ever looked at the arguments it did not expect.
//!
//! The help text had the same problem from the other side: a 73 line string
//! kept in step with the dispatch by hand, describing defaults that lived as
//! literals hundreds of lines away. It drifted, and there was no way to notice.
//!
//! Deriving both from one definition means the parse and the help cannot
//! disagree, and a default is written once where the reader can see it.
//!
//! # Diagnostics are flags, not environment variables
//!
//! `QA_TRACE=1` and `SWEEP_TRACE=1` used to gate tracing. An environment
//! variable is invisible in the command a person pastes into a bug report, does
//! not appear in `--help`, and cannot be validated. They are `--trace` now.
//!
//! # One output format
//!
//! TOON, always, rather than a column layout for a person and a machine format
//! behind a flag. Two formats mean two code paths through every reporting
//! function, gated on a boolean threaded down from the argument parser, and the
//! one nobody runs is the one that rots. TOON is readable enough to keep as the
//! only answer: a uniform array declares its fields once and spends a line per
//! row, which is the shape a column layout was approximating anyway, without
//! losing any field that happens to contain a space.

use std::path::PathBuf;

use clap::{Parser, Subcommand, ValueEnum};

fn parse_timeout_scale(value: &str) -> Result<f64, String> {
    let scale = value
        .parse::<f64>()
        .map_err(|_| "timeout scale must be a number".to_owned())?;
    if scale.is_finite() && (1.0..=10.0).contains(&scale) {
        Ok(scale)
    } else {
        Err("timeout scale must be between 1 and 10".to_owned())
    }
}

/// Whether the checks of one component share a page.
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum CheckMode {
    /// A fresh host per check. Nothing a check does can reach its neighbour.
    Isolated,
    /// One host for the group, state carried between checks.
    Sweep,
}

#[derive(Parser)]
#[command(
    name = "ps-qa",
    about = "Drive a Blitz application through its control socket and judge what the renderer did.",
    long_about = None,
    version,
)]
pub struct Cli {
    /// The inspector descriptor to attach to. Defaults to
    /// `target/blitz-control.json`, then the newest one the running application
    /// advertises in the temporary directory.
    #[arg(long, global = true)]
    pub descriptor: Option<PathBuf>,

    /// The application profile: which surfaces exist, what they are called, and
    /// which controls must not be pressed. Defaults to `ps-qa.ron` in the
    /// working directory. Application-driving sweeps fail clearly when neither
    /// path exists; the harness does not guess a built-in application.
    #[arg(long, global = true)]
    pub app: Option<PathBuf>,

    /// Inter-event delay in seconds. Pass 0 to saturate the event queue, which
    /// measures a renderer's coalescing rather than its steady state: at the
    /// default the harness sets the cadence, so the reported frame interval
    /// describes the harness rather than the application.
    #[arg(long, global = true, default_value_t = 1.0 / 60.0)]
    pub pace: f64,

    /// Multiply interaction and rendered-outcome deadlines on an overloaded
    /// runner. The default remains the strict local latency contract; CI must
    /// opt in explicitly rather than silently weakening every check.
    #[arg(
        long,
        global = true,
        default_value_t = 1.0,
        value_parser = parse_timeout_scale
    )]
    pub timeout_scale: f64,

    /// Report the node each step addressed, and why a step chose it.
    #[arg(long, global = true)]
    pub trace: bool,

    /// Report the duration of each renderer capture request.
    #[arg(long, global = true)]
    pub trace_capture: bool,

    /// Fail pixel checks when the runtime cannot provide committed-paint events.
    #[arg(long, global = true)]
    pub require_paint_events: bool,

    /// Save before/after PPM captures for failed pixel checks.
    #[arg(long, global = true)]
    pub pixel_artifact_dir: Option<PathBuf>,

    #[command(subcommand)]
    pub command: Command,
}

/// Every mode, with its own parameters.
///
/// The doc comment on each variant is what `--help` prints, so the description
/// and the behaviour are the same edit.
#[derive(Subcommand)]
pub enum Command {
    /// Tree size and a role histogram.
    Nodes,

    /// Node count per retained pane, and what retention costs against the whole
    /// tree.
    Panes,

    /// One metrics read, as a frame-window summary.
    Idle,

    /// Assert the blinking-rectangle repro: no missed refreshes, and no frame
    /// interval past two refresh periods. Exits 1 when the blink is present.
    Blink {
        /// Missed refreshes to tolerate before failing.
        #[arg(default_value_t = 0)]
        allowed_missed: u64,
    },

    /// Hidden nodes that still own a painted box, worst first. Retention keeps
    /// some on purpose, so this exits 1 only past a budget.
    Ghost {
        /// Ignore boxes smaller than this, in square pixels.
        #[arg(default_value_t = 64.0)]
        min_area: f64,
        /// How many hidden boxes are acceptable before this fails.
        #[arg(default_value_t = 400)]
        max: usize,
    },

    /// What the application does while nothing happens.
    Drift {
        /// How long to watch, in seconds.
        #[arg(default_value_t = 20.0)]
        seconds: f64,
    },

    /// One metrics read, laid out for reading.
    Frames,

    /// The raw metrics response.
    Metrics,

    /// The semantic tree.
    Tree,

    /// Query the live tree: every control matching a role, a name pattern and a
    /// state, as TOON.
    ///
    /// This is the one to reach for. `layout` answers "where is the node called
    /// exactly this", which is why every real question ended up piped through
    /// awk: "which buttons are off screen", "is anything painting at 0x0",
    /// "what is on this surface at all". Those are filters, and they belong
    /// here rather than in whatever the caller can assemble from a column
    /// dump.
    ///
    /// Patterns are glob-style: `chat*` matches every name starting with
    /// "chat", `*settings*` anywhere, and a bare word is a substring, which is
    /// what a name is usually recalled as.
    Find {
        /// Name pattern. `chat*`, `*close*`, or a bare substring. Omit to match
        /// every node.
        #[arg(default_value = "*")]
        pattern: String,
        /// Only this role: button, textbox, menuitem, checkbox, heading, and so
        /// on. Repeat to accept several.
        #[arg(long)]
        role: Vec<String>,
        /// Only nodes the tree calls visible.
        #[arg(long)]
        visible: bool,
        /// Only nodes the tree calls hidden. The pair that found the retained
        /// panes.
        #[arg(long)]
        hidden: bool,
        /// Only nodes with a non-zero box. A control at 0x0 is in the tree and
        /// on nobody's screen, which is the distinction that cost the most time
        /// to keep re-deriving.
        #[arg(long)]
        painted: bool,
        /// Only nodes whose box lies outside the window, on either axis.
        #[arg(long)]
        offscreen: bool,
        /// Only nodes that are disabled.
        #[arg(long)]
        disabled: bool,
        /// Report how many matched, and nothing else.
        #[arg(long)]
        count: bool,
        /// Stop after this many rows.
        #[arg(long)]
        limit: Option<usize>,
    },

    /// Live boxes: x, y, w, h per named node.
    Layout {
        /// Match nodes whose accessible name contains this.
        #[arg(default_value = "")]
        name: String,
    },

    /// Matching nodes with their attributes, plus the ancestor chain, so a
    /// spill can be read against the container that was meant to clip it.
    Dom {
        /// Match nodes whose accessible name contains this.
        name: String,
        /// How many ancestors to walk up.
        #[arg(default_value_t = 6)]
        depth: usize,
    },

    /// Scroll state and lowest descendants of the main scrolling region.
    Transcript,

    /// The colours the renderer resolved per node, biggest box first, so a
    /// full-window wash names the element that asked for it.
    Paint {
        /// Match nodes whose accessible name contains this.
        #[arg(default_value = "")]
        name: String,
        /// Ignore boxes smaller than this, in square pixels.
        #[arg(default_value_t = 10000.0)]
        min_area: f64,
    },

    /// Named, painted text whose resolved foreground is too close to the
    /// background actually stacked beneath it.
    Contrast {
        /// Match nodes whose accessible name contains this.
        #[arg(default_value = "")]
        name: String,
        /// Minimum WCAG ratio for prose and labels.
        #[arg(long, default_value_t = 4.5)]
        text_ratio: f64,
        /// Minimum WCAG ratio for interactive control chrome.
        #[arg(long, default_value_t = 3.0)]
        control_ratio: f64,
    },

    /// Boxes that stick out of their container, worst first.
    Spill {
        /// Which axis to measure.
        #[arg(default_value = "h")]
        axis: String,
        /// Overhang to tolerate, in pixels.
        #[arg(default_value_t = 1.0)]
        tolerance: f64,
    },

    /// Stream metrics, console and runtime errors.
    Watch {
        /// How long to listen, in seconds.
        #[arg(default_value_t = 20.0)]
        seconds: f64,
    },

    /// Wheel events over a named node.
    Scroll {
        /// How many wheel ticks to send.
        #[arg(default_value_t = 120)]
        ticks: u32,
        /// Pixels per tick. Negative scrolls down.
        #[arg(default_value_t = -80.0)]
        delta: f64,
        /// Match the scroller whose accessible name contains this.
        #[arg(default_value = "")]
        over: String,
    },

    /// Scroll a named node's container directly, rather than by wheel events.
    Drag {
        /// Match the node whose accessible name contains this.
        #[arg(default_value = "")]
        name: String,
        /// Pixels to move per step.
        #[arg(default_value_t = -400.0)]
        dy: f64,
        /// How many steps.
        #[arg(default_value_t = 10)]
        steps: u32,
    },

    /// Drive real keystrokes into a text field.
    Type {
        /// How many characters to send.
        #[arg(default_value_t = 20)]
        count: u32,
        /// Match the field whose accessible name contains this.
        #[arg(default_value = "")]
        name: String,
    },

    /// Send a named key into a scroller, or into a bare node id.
    Key {
        /// pageup, pagedown, home, end, up, down, left, right or tab.
        name: String,
        /// How many times to send it.
        #[arg(default_value_t = 1)]
        count: u32,
        /// Match the scroller whose accessible name contains this.
        #[arg(default_value = "")]
        over: String,
    },

    /// Scroll a named node into view, reporting its y before and after.
    Reveal {
        /// Match the node whose accessible name contains this.
        name: String,
    },

    /// Render what the application actually drew and report the visible ink in
    /// it, for the whole window or one named node.
    ///
    /// This is the only mode that can tell a drawn control from a blank box:
    /// every other reading here comes from the tree, where the two are
    /// identical.
    Capture {
        /// Match the node by accessible name, role:name, #id or @data-slot.
        /// Empty captures the window.
        #[arg(default_value = "")]
        name: String,
        /// Render scale.
        #[arg(default_value_t = 1.0)]
        scale: f64,
        /// Save the rendered pixels as a binary PPM image for visual diagnosis.
        #[arg(long)]
        output: Option<std::path::PathBuf>,
    },

    /// Move, press and release a real pointer over the first match, which is
    /// the path a person's mouse takes.
    ///
    /// `click` synthesises an event at a node id instead, so when a control is
    /// reported working that a sweep calls dead, this is what tells the two
    /// apart.
    Press {
        /// Match the control whose accessible name contains this.
        name: String,
    },

    /// Click the first matching visible, enabled node.
    Click {
        /// Match the control whose accessible name contains this. Omit when
        /// `--id` comes from `find`.
        #[arg(required_unless_present = "id")]
        name: Option<String>,
        /// Activate this exact semantic node id.
        #[arg(long, conflicts_with = "name")]
        id: Option<u64>,
    },

    /// Every button in the running application, measured against what the
    /// renderer drew for it. Reports the ones that cannot be seen.
    ///
    /// Exits 1 on any fault. Does not click anything.
    Audit {
        /// Restrict to one family of controls.
        family: Option<String>,
    },

    /// Click every button and check it did what its name says.
    ///
    /// This presses destructive controls on purpose, so point the application
    /// at a throwaway profile first. Exits 1 on any button that did not act.
    Sweep {
        /// Restrict to one family of controls.
        family: Option<String>,
    },

    /// Sweep every surface, not just the one the application opened on.
    ///
    /// Navigates each surface, expands what is collapsed and hovers every row
    /// first, then clicks what that reveals. Reports what it could not reach
    /// instead of skipping it, so coverage is a number rather than silence.
    Cover {
        /// Restrict to one surface.
        surface: Option<String>,
        /// Activate only controls that have no named outcome check.
        ///
        /// Inventory still materializes and accounts for every concrete
        /// control. Controls already driven by the ordered outcome suite are
        /// not clicked a second time, which avoids turning a coverage audit
        /// into a destructive replay of every repeated row action.
        #[arg(long)]
        unmapped_only: bool,
        /// Where the named outcome checks live. Defaults to `tests/ps-qa`.
        #[arg(long)]
        checks: Option<PathBuf>,
        /// Hard wall-clock budget for the complete sweep.
        #[arg(long, default_value_t = 180)]
        max_seconds: u64,
    },

    /// Count reachable, unreachable, anonymous, manual and outcome-declared
    /// controls on every surface without activating ordinary controls.
    ///
    /// Navigation, section expansion and row hover use semantic node ids. This
    /// is the fast answer to "what can an agent reach?". Add
    /// `--require-outcomes` to make missing named verdicts fail CI; use `cover`
    /// when the generic effect of pressing every eligible control is required.
    Inventory {
        /// Restrict to one surface.
        surface: Option<String>,
        /// Fail when a reachable control has no named outcome check.
        #[arg(long)]
        require_outcomes: bool,
        /// Where the named outcome checks live. Defaults to `tests/ps-qa`.
        #[arg(long)]
        checks: Option<PathBuf>,
    },

    /// Reconcile a saved `inventory` report against named outcome checks.
    ///
    /// This needs no running application. It lets an agent continue filling
    /// coverage from a CI artifact instead of launching another GUI merely to
    /// ask which controls remain unverified.
    Reconcile {
        /// TOON report previously emitted by `ps-qa inventory`.
        inventory: PathBuf,
        /// Where the checks live. Defaults to `tests/ps-qa`.
        #[arg(long)]
        checks: Option<PathBuf>,
    },

    /// Drive every control named by the checks and judge what the renderer did
    /// with it. Exits 1 on any failure.
    Qa {
        /// A group, or a single check's id, so chasing one failure does not
        /// re-run its neighbours.
        selector: Option<String>,
        /// Where the checks live. Defaults to `tests/ps-qa` beneath the working
        /// directory.
        #[arg(long)]
        checks: Option<PathBuf>,
    },

    /// Launch one headless page, run its checks, and stop the host.
    ///
    /// This is the single-page counterpart to `sweep-components`. The host
    /// announces its descriptor on stdout; ps-qa waits for that line with the
    /// startup deadline instead of making callers poll a file.
    QaHosted {
        /// A group, or a single check's id. Defaults to every check.
        selector: Option<String>,
        /// The headless host binary.
        #[arg(long)]
        host: PathBuf,
        /// The built page or page directory for the host to load.
        #[arg(long)]
        page: PathBuf,
        /// Where the checks live.
        #[arg(long)]
        checks: Option<PathBuf>,
        /// How long to wait for the host to announce its descriptor.
        #[arg(long, default_value_t = 30)]
        startup_timeout: u64,
    },

    /// Every check the harness can see, without a running application.
    List {
        /// Where the checks live.
        #[arg(long)]
        checks: Option<PathBuf>,
    },

    /// Drive a component library one component at a time, each in its own
    /// process, and report a verdict per component. Exits 1 on any failure.
    ///
    /// One process per component is the point rather than an inefficiency. A
    /// shared process makes every check order-dependent, and a component that
    /// wedges the renderer takes down every component after it; a failure then
    /// describes its neighbour rather than itself. Each run here starts from a
    /// fresh page, so a verdict is about its own component and nothing else.
    ///
    /// The host is launched with the component's built page, hosts the
    /// inspection socket itself and opens no window, so a sweep of a whole
    /// library runs next to someone using their machine and on a CI box with no
    /// display server. `--host` names the binary and it is expected to print
    /// the descriptor path on stdout when it is ready to be attached to.
    SweepComponents {
        /// Component ids to run. Defaults to every directory under `--dists`.
        ids: Vec<String>,
        /// The headless host binary, which must print its descriptor path on
        /// stdout once it is serving.
        #[arg(long)]
        host: PathBuf,
        /// Where the per-component built pages live, one directory per id.
        #[arg(long)]
        dists: PathBuf,
        /// Where the checks live, one `<id>.ron` per component.
        #[arg(long)]
        checks: Option<PathBuf>,
        /// How long to wait for a component's host to announce itself.
        #[arg(long, default_value_t = 30)]
        startup_timeout: u64,

        /// How the checks of one component relate to each other.
        ///
        /// `isolated` (the default) gives every check its own host, so it runs
        /// against a page nothing has touched. On a component page there are no
        /// surfaces to inherit and a check that opens a menu simply leaves it
        /// open for its neighbour: Dropdown and Select both failed that way
        /// while passing alone, because the next check pressed the same trigger
        /// to prepare itself and closed what was already open.
        ///
        /// `sweep` runs the whole group against one host, sharing state, which
        /// is how a whole-application run behaves and the right choice for a
        /// page whose checks are deliberately a sequence. It is also faster:
        /// one host rather than one per check.
        #[arg(long, value_enum, default_value_t = CheckMode::Isolated)]
        mode: CheckMode,
    },
}

/// Whether `--trace` was given.
///
/// A global rather than a parameter threaded through every driving function.
/// Tracing is read at ten call sites nested several frames deep inside the
/// sweep and the check runner, and passing a bool down to each one would put an
/// argument that means "how to talk about the work" into the signature of every
/// function that does the work. It is written once, before anything runs.
static TRACE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static TRACE_CAPTURE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static REQUIRE_PAINT_EVENTS: std::sync::atomic::AtomicBool =
    std::sync::atomic::AtomicBool::new(false);
static PIXEL_ARTIFACT_DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();

/// The inter-event delay, in seconds. Set once, from `main`, for the same
/// reason as `TRACE`.
static PACE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);

/// Multiplier for interaction and rendered-outcome deadlines. Like `PACE`, it
/// is set once before any check runs and read several frames below the CLI.
static TIMEOUT_SCALE: std::sync::atomic::AtomicU64 =
    std::sync::atomic::AtomicU64::new(1.0_f64.to_bits());

/// Record whether tracing was asked for. Called once, from `main`.
pub fn set_trace(on: bool) {
    TRACE.store(on, std::sync::atomic::Ordering::Relaxed);
}

pub fn set_capture_options(
    trace_capture: bool,
    require_paint_events: bool,
    pixel_artifact_dir: Option<PathBuf>,
) {
    TRACE_CAPTURE.store(trace_capture, std::sync::atomic::Ordering::Relaxed);
    REQUIRE_PAINT_EVENTS.store(require_paint_events, std::sync::atomic::Ordering::Relaxed);
    let _ = PIXEL_ARTIFACT_DIR.set(pixel_artifact_dir);
}

/// Record the inter-event delay. Called once, from `main`.
pub fn set_pace(seconds: f64) {
    PACE.store(seconds.to_bits(), std::sync::atomic::Ordering::Relaxed);
}

/// Record the validated deadline multiplier. Called once, from `main`.
pub fn set_timeout_scale(scale: f64) {
    TIMEOUT_SCALE.store(scale.to_bits(), std::sync::atomic::Ordering::Relaxed);
}

/// `--app <path>`, if one was given. Set once, from `main`, for the same reason
/// as `TRACE`: the profile is read from inside the reach and sweep code, several
/// frames below anything that has seen the command line.
static APP: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();

/// Record the profile path. Called once, from `main`.
pub fn set_app_profile(path: Option<PathBuf>) {
    let _ = APP.set(path);
}

/// The profile path named on the command line, if any.
pub fn app_profile() -> Option<PathBuf> {
    APP.get().cloned().flatten()
}

/// The inter-event delay, in seconds.
pub fn pace() -> f64 {
    f64::from_bits(PACE.load(std::sync::atomic::Ordering::Relaxed))
}

/// The interaction and rendered-outcome deadline multiplier.
pub fn timeout_scale() -> f64 {
    f64::from_bits(TIMEOUT_SCALE.load(std::sync::atomic::Ordering::Relaxed))
}

/// Whether to name the node a step addressed, and why it chose it.
pub fn trace() -> bool {
    TRACE.load(std::sync::atomic::Ordering::Relaxed)
}

pub fn trace_capture() -> bool {
    TRACE_CAPTURE.load(std::sync::atomic::Ordering::Relaxed)
}

pub fn require_paint_events() -> bool {
    REQUIRE_PAINT_EVENTS.load(std::sync::atomic::Ordering::Relaxed)
}

pub fn pixel_artifact_dir() -> Option<PathBuf> {
    PIXEL_ARTIFACT_DIR.get().cloned().flatten()
}

impl Command {
    /// Whether this mode needs product-owned navigation and safety rules.
    ///
    /// Renderer diagnostics deliberately work against any inspectable Blitz
    /// document. Broad application driving does not: guessing which controls
    /// are manual, destructive, or surface openers makes a missing profile a
    /// safety bug rather than a useful default.
    pub fn requires_app_profile(&self) -> bool {
        matches!(
            self,
            Command::Sweep { .. }
                | Command::Cover { .. }
                | Command::Inventory { .. }
                | Command::Qa { .. }
                | Command::QaHosted { .. }
                | Command::SweepComponents { .. }
        )
    }

    /// Whether this mode should announce the descriptor it attached to.
    ///
    /// The answer to "why is this number wrong" is usually "a different
    /// process", so the modes that report raw numbers say what they read them
    /// from.
    pub fn is_dump(&self) -> bool {
        matches!(
            self,
            Command::Metrics | Command::Watch { .. } | Command::Frames | Command::Tree
        )
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use clap::Parser;

    use super::{CheckMode, Cli, Command, parse_timeout_scale};

    #[test]
    fn timeout_scale_is_explicit_and_bounded() {
        assert_eq!(parse_timeout_scale("1").unwrap(), 1.0);
        assert_eq!(parse_timeout_scale("2.5").unwrap(), 2.5);
        assert!(parse_timeout_scale("0.5").is_err());
        assert!(parse_timeout_scale("11").is_err());
        assert!(parse_timeout_scale("not-a-number").is_err());
    }

    #[test]
    fn only_application_driving_modes_require_a_profile() {
        assert!(Command::Sweep { family: None }.requires_app_profile());
        assert!(
            Command::Qa {
                selector: None,
                checks: None,
            }
            .requires_app_profile()
        );
        assert!(
            Command::QaHosted {
                selector: None,
                host: PathBuf::from("host"),
                page: PathBuf::from("page.html"),
                checks: None,
                startup_timeout: 30,
            }
            .requires_app_profile()
        );
        assert!(
            Command::SweepComponents {
                ids: Vec::new(),
                host: PathBuf::from("host"),
                dists: PathBuf::from("dist"),
                checks: None,
                startup_timeout: 30,
                mode: CheckMode::Isolated,
            }
            .requires_app_profile()
        );
        assert!(!Command::Metrics.requires_app_profile());
        assert!(
            !Command::Click {
                name: None,
                id: Some(7)
            }
            .requires_app_profile()
        );
    }

    #[test]
    fn hosted_qa_arguments_are_typed_by_clap() {
        let cli = Cli::try_parse_from([
            "ps-qa",
            "--app",
            "fixture.ron",
            "qa-hosted",
            "fixture-text-entry",
            "--host",
            "qa-inspect-host",
            "--page",
            "page.html",
            "--checks",
            "checks",
        ])
        .unwrap();

        let Command::QaHosted {
            selector,
            host,
            page,
            checks,
            startup_timeout,
        } = cli.command
        else {
            panic!("qa-hosted did not parse as the hosted QA command");
        };
        assert_eq!(selector.as_deref(), Some("fixture-text-entry"));
        assert_eq!(host, PathBuf::from("qa-inspect-host"));
        assert_eq!(page, PathBuf::from("page.html"));
        assert_eq!(checks, Some(PathBuf::from("checks")));
        assert_eq!(startup_timeout, 30);
    }
}