Skip to main content

logicaffeine_compile/page_host/
scenario.rs

1//! Running one scenario — a program's own statements, then a `## Test` body — on a page tier,
2//! and holding the tiers' runs side by side.
3//!
4//! The reference trace is the tree-walker's: for every `k`, the program and the body's first `k`
5//! statements run in a fresh interpreter, a fresh sealed world and a fresh document, and the page
6//! is read afterwards. Runs are deterministic, so this is exactly the page a single run shows
7//! after its `k`-th statement.
8//!
9//! A driven tier (the lowered page on the VM, a browser) is walked statement by statement: a
10//! statement that acts on the page is carried out by the driver, and one that only reads it has
11//! nothing to do on that tier — the test's assertions are the reference tier's. What the driven
12//! walk cannot carry it REFUSES, naming the statement and why, rather than guessing: a statement
13//! whose operand is computed (evaluating it is the interpreter's job), a driving statement nested
14//! in a block, a call that may write the page.
15
16use super::lowered::{DriveError, LoweredPage};
17use super::snapshot::DocSnapshot;
18use super::trace::{Divergence, PageTrace, Stop, TraceStep};
19use super::{Drive, DrivenPage, PageTier, RemotePage};
20use crate::analysis::{PolicyRegistry, TypeRegistry};
21use crate::ast::stmt::{Expr, Literal, Stmt, UiStmt};
22use crate::intern::Interner;
23use crate::semantics::dom;
24use crate::semantics::world::PageWorld;
25use serde::Serialize;
26use std::path::Path;
27use std::time::Duration;
28
29/// Which tier executes a scenario's page — the configuration-side name of a [`super::PageTier`].
30#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum PageTierKind {
33    /// The page's lowered LOGOS on the tree-walker — the same interpreter that runs the test. The
34    /// reference every other tier is held to.
35    LoweredHere,
36    /// The page's lowered LOGOS on the bytecode VM.
37    LoweredVm,
38    /// A real browser, through a [`RemotePageLauncher`].
39    Browser,
40}
41
42impl PageTierKind {
43    pub fn label(self) -> &'static str {
44        match self {
45            PageTierKind::LoweredHere => "lowered tree-walker",
46            PageTierKind::LoweredVm => "lowered VM",
47            PageTierKind::Browser => "browser",
48        }
49    }
50}
51
52/// Starts a page in another process for one scenario.
53pub trait RemotePageLauncher {
54    /// Start the page `arrival` describes, fresh — visited at its route, its load-time answers
55    /// already in place, its main run — ready to be driven.
56    fn launch(&self, arrival: &Arrival) -> Result<Box<dyn RemotePage>, String>;
57}
58
59/// How a scenario's page arrives: everything that happens before statement 0 is observed.
60///
61/// A visitor lands on a page at a URL, in a world that already answers the requests the page makes
62/// as it loads; the page's own `main` runs and a `## Page` mounts itself. Every tier arrives this
63/// way — the in-process tiers by driving the arranged body's arrival ([`Arranged::arrival`]), a
64/// remote page by being handed this and loading.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct Arrival<'s> {
67    /// The program the page ships (its `## Main`, if it had one, already stripped).
68    pub source: &'s str,
69    /// The URL the page is visited at: the auto-mounted `## Page`'s declared path, else `/`.
70    pub route: String,
71    /// The test's `Given that … answers …` statements that come before the page loads, in order.
72    pub answers: Vec<GivenAnswer>,
73    /// Every component the test mounts at a statement of its own — what the page's host must be
74    /// able to mount on demand, read from the test's own `Mount` statements.
75    pub mounts: Vec<String>,
76}
77
78/// `Given that METHOD "url" answers STATUS with BODY.`
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct GivenAnswer {
81    pub method: String,
82    pub url: String,
83    pub status: i64,
84    pub body: String,
85}
86
87/// The sealed world a scenario runs in.
88#[derive(Clone, Copy, Debug)]
89pub struct World {
90    pub caps: logicaffeine_system::testworld::Capabilities,
91    pub seed: u64,
92    /// The deterministic step budget for each tree-walker run.
93    pub budget: u64,
94}
95
96impl World {
97    /// The world a test runs in: its entropy is keyed by the test's identity, so adding or
98    /// reordering tests never shifts another test's stream.
99    pub fn for_test(
100        name: &str,
101        suite_seed: u64,
102        caps: logicaffeine_system::testworld::Capabilities,
103        budget: u64,
104    ) -> World {
105        let mut h: u64 = 0xCBF2_9CE4_8422_2325 ^ suite_seed;
106        for b in name.bytes() {
107            h ^= u64::from(b);
108            h = h.wrapping_mul(0x0000_0100_0000_01B3);
109        }
110        World {
111            caps,
112            seed: h,
113            budget,
114        }
115    }
116
117    /// The property-generation seed derived from the world's.
118    pub fn gen_seed(&self) -> u64 {
119        self.seed ^ 0x5DEE_CE66_1234_ABCD
120    }
121
122    /// Enter the world: a fresh document, a fresh page runtime, fresh storage, no network, and
123    /// the sealed clock/entropy/filesystem — SCOPED, not destructive: whatever the caller's own
124    /// document/runtime/clock/sealed world held a moment ago is saved in the returned guard and
125    /// comes back the instant it drops. A candidate page tier paints into the very same
126    /// thread-locals the caller's own page just finished painting into (that sharing is what lets
127    /// a page query mean the same thing on every tier), so entering a nested world without saving
128    /// what it is about to overwrite would hand the caller back the candidate's leftovers instead
129    /// of its own.
130    ///
131    /// The fresh document is the one a browser loads the page in: the shell [`super::PAGE_STEM`]
132    /// names, served by the same function the browser tier's bundle is built from
133    /// ([`crate::web_host::browser_host_html`]), so every tier arrives at the same head and title.
134    /// `route`, when the scenario auto-mounts a `## Page`, is that page's own declared URL — the
135    /// fresh document boots already AT that path (`dom::init_route`), exactly as a real visitor
136    /// arrives at it, rather than at the boot default `"/"`.
137    fn enter(&self, route: Option<&str>) -> WorldGuard {
138        let guard = WorldGuard(Some(PageWorld::take()));
139        crate::concurrency::net_inbox::set_net_offline(true);
140        logicaffeine_system::testworld::install(logicaffeine_system::testworld::TestWorld::new(
141            self.caps, self.seed,
142        ));
143        dom::arrive(&crate::web_host::browser_host_html(super::PAGE_STEM));
144        if let Some(path) = route {
145            dom::init_route(path, "");
146        }
147        guard
148    }
149}
150
151/// The caller's own page world, saved by [`World::enter`] and put back when this drops.
152struct WorldGuard(Option<PageWorld>);
153
154impl Drop for WorldGuard {
155    fn drop(&mut self) {
156        if let Some(world) = self.0.take() {
157            world.restore();
158        }
159    }
160}
161
162/// The lowered page's run on the tree-walker — the reference — once: the trace to where the program
163/// stopped, and the statement it could not carry and why, when that is where it stopped.
164#[derive(Clone, Debug)]
165pub struct HereRun {
166    pub trace: PageTrace,
167    pub refused: Option<(usize, String)>,
168}
169
170impl HereRun {
171    /// The run as a candidate tier's: refused at the statement it could not carry, traced otherwise.
172    pub fn tier_run(&self) -> TierRun {
173        match &self.refused {
174            Some((statement, reason)) => TierRun::Refused {
175                statement: *statement,
176                reason: reason.clone(),
177            },
178            None => TierRun::Traced(self.trace.clone()),
179        }
180    }
181}
182
183/// A tier's run of a scenario.
184#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
185#[serde(rename_all = "snake_case")]
186pub enum TierRun {
187    Traced(PageTrace),
188    /// The tier cannot carry the scenario; `statement` is the one it cannot carry (`0` for the
189    /// program itself).
190    Refused {
191        statement: usize,
192        reason: String,
193    },
194}
195
196/// What one top-level test statement is, to a tier that drives the page from outside.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub enum Classified {
199    /// It acts on the page.
200    Drive(Drive),
201    /// It only reads the page (or nothing at all).
202    Read,
203    /// A driven tier cannot carry it; the reason says why.
204    Unsupported(String),
205}
206
207/// A test's statements in the order they run.
208///
209/// A `## Page` mounts itself, and that mount belongs to the program — which would run it before any
210/// test statement, so a test could never arrange what the page finds when it loads. A browser test
211/// sets the scene and THEN opens the page; so does this: the test's leading scene statements
212/// ([`is_scene`]) run first, then the page's own mount. `Reload the page.` is the page loading
213/// again, its own mount included — every tier's reload mounts the page again itself, so nothing is
214/// placed after one. A program that mounts nothing of its own — a `## Component` a test mounts for
215/// itself — runs exactly as written.
216#[derive(Clone, Debug)]
217pub struct Arranged<'a> {
218    /// The program without the page's own mount.
219    pub defs: Vec<Stmt<'a>>,
220    /// The test's statements with the page's mount placed where it runs.
221    pub body: Vec<Stmt<'a>>,
222    /// For each statement of `body`, the authored test statement it is (1-based), or `None` for
223    /// the page's own mount the runner placed. A placed mount belongs to the step of the
224    /// statement before it (step 0 when it comes first).
225    pub authored: Vec<Option<usize>>,
226    /// How many statements at the head of `body` are the page's ARRIVAL: the leading scene and the
227    /// page's own mount after it. A visitor never sees the page before it has loaded, so every one
228    /// of them belongs to step 0 — the page as it arrives — and a step they carry an authored number
229    /// for shows that same arrived page. `0` when the program mounts no page of its own.
230    pub arrival: usize,
231}
232
233/// Whether `stmt` arranges the world a page meets rather than acting on the page.
234pub fn is_scene(stmt: &Stmt) -> bool {
235    matches!(
236        stmt,
237        Stmt::GivenFetch { .. } | Stmt::ServeFetches | Stmt::GivenFile { .. }
238    )
239}
240
241/// The names of every `## Page`-kind component `defs` declares — the parser gives each one an
242/// auto-inserted `Mount <page>.` in `defs` itself (a page "mounts itself"), which this recognizes
243/// by name rather than by a test author having written the word `Mount` anywhere.
244fn page_component_names(defs: &[Stmt]) -> Vec<logicaffeine_base::Symbol> {
245    defs.iter()
246        .filter_map(|s| match s {
247            Stmt::ComponentDef(d)
248                if matches!(d.kind, crate::ast::component::ComponentKind::Page { .. }) =>
249            {
250                Some(d.name)
251            }
252            _ => None,
253        })
254        .collect()
255}
256
257/// Whether `defs` auto-mounts a `## Page` — the parser's own inserted `Mount <page>.` [`arrange`]
258/// relocates into the test body, not a statement the test's own body ever writes. A test against
259/// such a program drives a mounted page exactly as much as an explicit `Mount` in its body does;
260/// `largo test`'s cross-tier check (`testrun.rs`) uses this SAME signal for that reason, so the two
261/// cannot drift on what "this test drives a mounted page" means.
262pub fn defs_auto_mount_a_page(defs: &[Stmt]) -> bool {
263    let pages = page_component_names(defs);
264    defs.iter()
265        .any(|s| matches!(s, Stmt::Ui(UiStmt::Mount { component, selector: None }) if pages.contains(component)))
266}
267
268/// The `## Page` component `defs` auto-mounts, and its own declared path — the parser's own
269/// inserted `Mount <page>.` [`arrange`] relocates into the test body. `None` when `defs` auto-
270/// mounts nothing (a `## Component` a test mounts for itself, if anything).
271fn auto_mounted_page(
272    defs: &[Stmt],
273) -> Option<(logicaffeine_base::Symbol, logicaffeine_base::Symbol)> {
274    let paths: std::collections::HashMap<logicaffeine_base::Symbol, logicaffeine_base::Symbol> =
275        defs.iter()
276            .filter_map(|s| match s {
277                Stmt::ComponentDef(d) => match d.kind {
278                    crate::ast::component::ComponentKind::Page { path, .. } => Some((d.name, path)),
279                    _ => None,
280                },
281                _ => None,
282            })
283            .collect();
284    defs.iter().find_map(|s| match s {
285        Stmt::Ui(UiStmt::Mount {
286            component,
287            selector: None,
288        }) => paths.get(component).map(|p| (*component, *p)),
289        _ => None,
290    })
291}
292
293/// The declared URL of the `## Page` `defs` auto-mounts, if any, resolved through `interner`.
294/// `routePath()` must read this from the moment a page's own auto-mount runs, not the boot default
295/// `"/"`: a page's own test, and every tier that mounts it, is that page AT its own URL, exactly as
296/// a visitor who navigated there directly would find it.
297pub fn auto_mounted_page_path(defs: &[Stmt], interner: &Interner) -> Option<String> {
298    auto_mounted_page(defs).map(|(_, path)| interner.resolve(path).to_string())
299}
300
301/// Boot the document at the URL the `## Page` `defs` auto-mounts declares, so `routePath()`
302/// answers that URL from the page's first statement — the page as a visitor who navigated to it
303/// finds it. A program that auto-mounts no page leaves the route where it is.
304pub fn boot_at_declared_route(defs: &[Stmt], interner: &Interner) {
305    if let Some(path) = auto_mounted_page_path(defs, interner) {
306        dom::init_route(&path, "");
307    }
308}
309
310/// The name of the `## Page` component `defs` auto-mounts, if any, resolved through `interner` —
311/// what a driven tier's `Replay these actions:` runs against before any explicit `Mount` in the
312/// test body ever ran (a page mounts itself).
313pub fn auto_mounted_page_component(defs: &[Stmt], interner: &Interner) -> Option<String> {
314    auto_mounted_page(defs).map(|(component, _)| interner.resolve(component).to_string())
315}
316
317/// Arrange one test against its program. See [`Arranged`].
318pub fn arrange<'a>(defs: &[Stmt<'a>], body: &[Stmt<'a>]) -> Arranged<'a> {
319    let pages = page_component_names(defs);
320    let is_page_mount = |s: &Stmt| matches!(s, Stmt::Ui(UiStmt::Mount { component, selector: None }) if pages.contains(component));
321    let mounts: Vec<Stmt<'a>> = defs.iter().filter(|s| is_page_mount(s)).cloned().collect();
322    let program: Vec<Stmt<'a>> = defs.iter().filter(|s| !is_page_mount(s)).cloned().collect();
323    let mut arranged = Vec::with_capacity(body.len() + mounts.len());
324    let mut authored = Vec::with_capacity(body.len() + mounts.len());
325    let scene = body.iter().take_while(|s| is_scene(s)).count();
326    for (i, stmt) in body.iter().enumerate() {
327        if i == scene {
328            arranged.extend(mounts.iter().cloned());
329            authored.extend(mounts.iter().map(|_| None));
330        }
331        arranged.push(stmt.clone());
332        authored.push(Some(i + 1));
333    }
334    if scene == body.len() {
335        arranged.extend(mounts.iter().cloned());
336        authored.extend(mounts.iter().map(|_| None));
337    }
338    let arrival = if mounts.is_empty() {
339        0
340    } else {
341        scene + mounts.len()
342    };
343    Arranged {
344        defs: program,
345        body: arranged,
346        authored,
347        arrival,
348    }
349}
350
351/// A program and a test body to run against it, arranged ([`arrange`]).
352pub struct Scenario<'a> {
353    pub interner: &'a Interner,
354    pub types: &'a TypeRegistry,
355    pub policies: &'a PolicyRegistry,
356    pub arranged: Arranged<'a>,
357    pub world: World,
358}
359
360impl<'a> Scenario<'a> {
361    pub fn new(
362        interner: &'a Interner,
363        types: &'a TypeRegistry,
364        policies: &'a PolicyRegistry,
365        defs: &[Stmt<'a>],
366        body: &[Stmt<'a>],
367        world: World,
368    ) -> Self {
369        Scenario {
370            interner,
371            types,
372            policies,
373            arranged: arrange(defs, body),
374            world,
375        }
376    }
377
378    /// How many statements the test's author wrote.
379    fn authored_len(&self) -> usize {
380        self.arranged.authored.iter().flatten().count()
381    }
382
383    /// `arranged.defs` and `arranged.body` combined, in that order — `arrange` relocates a page's
384    /// own `Mount` OUT of `defs` and into `body` (see [`Arranged`]), so anything hunting for it
385    /// (a page's own auto-mount, its declared component or route) must search both, the way
386    /// [`Scenario::reference_trace`]'s own interpreter run does by construction.
387    fn full_program(&self) -> Vec<Stmt<'a>> {
388        self.arranged
389            .defs
390            .iter()
391            .chain(&self.arranged.body)
392            .cloned()
393            .collect()
394    }
395
396    /// This scenario's own auto-mounted page's declared URL, if it has one — see
397    /// [`auto_mounted_page_path`].
398    fn route(&self) -> Option<String> {
399        auto_mounted_page_path(&self.full_program(), self.interner)
400    }
401
402    /// Where step `k`'s statements end in the arranged body: everything before the next authored
403    /// statement, and never before the page has arrived.
404    fn step_end(&self, k: usize) -> usize {
405        let next = self
406            .arranged
407            .authored
408            .iter()
409            .position(|a| *a == Some(k + 1))
410            .unwrap_or(self.arranged.body.len());
411        next.max(self.arranged.arrival)
412    }
413
414    /// Whether a driven tier carries step `k` out itself: the program's own step always, and an
415    /// authored statement when it drives the page (a statement that only reads has nothing to do).
416    fn executes(&self, k: usize, steps: &[Classified]) -> bool {
417        k == 0
418            || self
419                .arranged
420                .authored
421                .iter()
422                .position(|a| *a == Some(k))
423                .is_some_and(|i| matches!(steps[i], Classified::Drive(_)))
424    }
425
426    /// The reference: the lowered page on the tree-walker ([`Scenario::lowered_here_run`]), where a
427    /// statement the page cannot carry is where the program stops.
428    pub fn reference_trace(&self) -> PageTrace {
429        self.lowered_here_run().trace
430    }
431
432    /// The run on the lowered page, on the tree-walker that runs the test: ONE interpreter runs
433    /// the program as the page's main — its lowered prelude and definitions — and then the arranged
434    /// body, one step at a time, every driving statement carried out on the page by the driver
435    /// every lowered tier shares, with that same interpreter as its engine. The page is read after
436    /// each step's statements, where `Scenario::step_end` ends it — the boundaries the reference
437    /// trace runs to — in a single run.
438    pub fn lowered_here_trace(&self) -> TierRun {
439        self.lowered_here_run().tier_run()
440    }
441
442    /// The run on the lowered page on the tree-walker, once: its trace and, when it stopped on a
443    /// statement the page cannot carry, that statement and why.
444    pub fn lowered_here_run(&self) -> HereRun {
445        crate::interpreter::on_session_stack(|| self.lowered_here_run_in_session())
446    }
447
448    /// [`Scenario::lowered_here_run`], on the session's own thread.
449    fn lowered_here_run_in_session(&self) -> HereRun {
450        let _guard = self.world.enter(self.route().as_deref());
451        let mut interp = crate::interpreter::Interpreter::new(self.interner)
452            .with_type_registry(self.types)
453            .with_policies(self.policies.clone());
454        if !self.world.caps.filesystem {
455            interp = interp.with_vfs(std::sync::Arc::new(logicaffeine_system::fs::MemVfs::new()));
456        }
457        interp.enable_test_mode(self.world.budget);
458        interp.set_gen_seed(self.world.gen_seed());
459        let auto_mounted = auto_mounted_page_component(&self.full_program(), self.interner);
460        let mut trace = PageTrace::default();
461        match interp.host_lowered_page(&self.arranged.defs, auto_mounted) {
462            Ok(()) => {}
463            Err(DriveError::Refused(reason)) => {
464                trace.stopped = Some(Stop {
465                    statement: 0,
466                    message: reason.clone(),
467                });
468                return HereRun {
469                    trace,
470                    refused: Some((0, reason)),
471                };
472            }
473            Err(DriveError::Failed(message)) => {
474                trace.steps.push(TraceStep {
475                    statement: 0,
476                    executed: true,
477                    snapshot: DocSnapshot::capture(interp.hosted_page_failure()),
478                });
479                trace.stopped = Some(Stop {
480                    statement: 0,
481                    message,
482                });
483                return HereRun {
484                    trace,
485                    refused: None,
486                };
487            }
488        }
489        let mut from = 0;
490        let mut returned = false;
491        for k in 0..=self.authored_len() {
492            let end = self.step_end(k);
493            let step = &self.arranged.body[from..end.max(from)];
494            from = end.max(from);
495            let outcome = if returned {
496                Ok(crate::interpreter::ControlFlow::Continue)
497            } else if crate::interpreter::needs_async(step, self.interner) {
498                futures::executor::block_on(interp.run_statements_async(step))
499            } else {
500                interp.run_statements(step)
501            };
502            let refusal = interp.hosted_page_refusal().map(|(d, why)| match d {
503                Drive::Mount { component, .. } => self
504                    .why_not_lowered(component)
505                    .unwrap_or_else(|| why.to_string()),
506                _ => why.to_string(),
507            });
508            trace.steps.push(TraceStep {
509                statement: k,
510                executed: true,
511                snapshot: DocSnapshot::capture(interp.hosted_page_failure()),
512            });
513            if let Some(reason) = refusal {
514                trace.stopped = Some(Stop {
515                    statement: k,
516                    message: reason.clone(),
517                });
518                return HereRun {
519                    trace,
520                    refused: Some((k, reason)),
521                };
522            }
523            match outcome {
524                Ok(crate::interpreter::ControlFlow::Continue) => {}
525                Ok(_) => returned = true,
526                Err(message) => {
527                    trace.stopped = Some(Stop {
528                        statement: k,
529                        message,
530                    });
531                    break;
532                }
533            }
534        }
535        HereRun {
536            trace,
537            refused: None,
538        }
539    }
540
541    /// The run on the lowered page, on the bytecode VM.
542    pub fn lowered_vm_trace(&self) -> TierRun {
543        let steps = match self.classify_body() {
544            Ok(steps) => steps,
545            Err(refused) => return refused,
546        };
547        let program = match crate::vm::Compiler::compile_with_types(
548            &self.arranged.defs,
549            self.interner,
550            Some(self.types),
551        ) {
552            Ok(p) => p,
553            Err(e) => {
554                return TierRun::Refused {
555                    statement: 0,
556                    reason: format!("the program does not compile for the bytecode VM: {e}"),
557                }
558            }
559        };
560        let _guard = self.world.enter(self.route().as_deref());
561        let vm = crate::vm::Vm::new(&program).with_policy_ctx(self.policies, self.interner);
562        let mut page = LoweredPage::new(crate::vm::session::Session::new(vm, self.interner));
563        let auto_mounted = auto_mounted_page_component(&self.full_program(), self.interner);
564        let started = page.start(auto_mounted, |s| s.run_main());
565        let run = self.walk(&mut PageTier::LoweredVm(page), started, &steps, 0);
566        run
567    }
568
569    /// The run on a page another process executes, `source` being the program it ships.
570    ///
571    /// The page is launched already arrived ([`Arrival`]): at its own route, with the scene's
572    /// answers in place, its `main` run — which performs a `## Page`'s own mount, exactly as a
573    /// browser loading it does. The walk then starts after the arrival, and a `Mount` the test
574    /// writes at a statement of its own is carried out at that statement, through the entry the
575    /// arrival asked the page's host to expose.
576    pub fn remote_trace(&self, launcher: &dyn RemotePageLauncher, source: &str) -> TierRun {
577        let steps = match self.classify_body() {
578            Ok(steps) => steps,
579            Err(refused) => return refused,
580        };
581        let mut answers = Vec::new();
582        for (i, step) in steps[..self.arranged.arrival].iter().enumerate() {
583            match step {
584                Classified::Drive(Drive::AnswerFetch {
585                    method,
586                    url,
587                    status,
588                    body,
589                }) => answers.push(GivenAnswer {
590                    method: method.clone(),
591                    url: url.clone(),
592                    status: *status,
593                    body: body.clone(),
594                }),
595                Classified::Drive(Drive::Mount { .. }) if self.arranged.authored[i].is_none() => {}
596                _ => {
597                    return TierRun::Refused {
598                        statement: self.owner(i),
599                        reason: format!(
600                            "{} cannot be arranged before a page in another process loads: only \
601                             `Given that … answers …` travels with the page to where it runs",
602                            stmt_label(&self.arranged.body[i])
603                        ),
604                    }
605                }
606            }
607        }
608        let mut mounts: Vec<String> = steps[self.arranged.arrival..]
609            .iter()
610            .filter_map(|s| match s {
611                Classified::Drive(Drive::Mount { component, .. }) => Some(component.clone()),
612                _ => None,
613            })
614            .collect();
615        mounts.sort();
616        mounts.dedup();
617        let arrival = Arrival {
618            source,
619            route: self.route().unwrap_or_else(|| "/".to_string()),
620            answers,
621            mounts,
622        };
623        let _guard = self.world.enter(self.route().as_deref());
624        match launcher.launch(&arrival) {
625            Ok(page) => self.walk(
626                &mut PageTier::Browser(page),
627                Ok(()),
628                &steps,
629                self.arranged.arrival,
630            ),
631            Err(e) => TierRun::Refused {
632                statement: 0,
633                reason: format!("the page did not start: {e}"),
634            },
635        }
636    }
637
638    /// The step a statement of the arranged body belongs to: its own authored number, or the
639    /// step of the authored statement before a placed mount.
640    fn owner(&self, i: usize) -> usize {
641        self.arranged.authored[..=i]
642            .iter()
643            .rev()
644            .flatten()
645            .next()
646            .copied()
647            .unwrap_or(0)
648    }
649
650    /// Classify every statement, or refuse at the first one a driven tier cannot carry — before
651    /// anything runs, so a refusal never follows half a run.
652    fn classify_body(&self) -> Result<Vec<Classified>, TierRun> {
653        let mut steps = Vec::with_capacity(self.arranged.body.len());
654        for (i, stmt) in self.arranged.body.iter().enumerate() {
655            match classify(stmt, self.interner) {
656                Classified::Unsupported(reason) => {
657                    return Err(TierRun::Refused {
658                        statement: self.owner(i),
659                        reason,
660                    })
661                }
662                c => steps.push(c),
663            }
664        }
665        Ok(steps)
666    }
667
668    /// Walk a tier's page through the arranged body, one snapshot per step, each taken where
669    /// [`Scenario::step_end`] ends it — the same boundaries the reference trace runs to. `from` is
670    /// how much of the body the tier has already carried out by the time it is handed over (a
671    /// remote page launched already arrived).
672    fn walk(
673        &self,
674        tier: &mut PageTier<'_>,
675        started: Result<(), DriveError>,
676        steps: &[Classified],
677        from: usize,
678    ) -> TierRun {
679        let page = tier.driven();
680        let mut trace = PageTrace::default();
681        let snap = |page: &mut dyn DrivenPage| {
682            page.snapshot().unwrap_or_else(|e| DocSnapshot {
683                document: format!("(the page could not be read: {e})"),
684                ..DocSnapshot::default()
685            })
686        };
687        match started {
688            Ok(()) => {}
689            Err(DriveError::Refused(reason)) => {
690                return TierRun::Refused {
691                    statement: 0,
692                    reason,
693                }
694            }
695            Err(DriveError::Failed(message)) => {
696                trace.steps.push(TraceStep {
697                    statement: 0,
698                    executed: true,
699                    snapshot: snap(page),
700                });
701                trace.stopped = Some(Stop {
702                    statement: 0,
703                    message,
704                });
705                return TierRun::Traced(trace);
706            }
707        }
708        let mut i = from;
709        for k in 0..=self.authored_len() {
710            let executed = self.executes(k, steps);
711            while i < self.step_end(k) {
712                let step = &steps[i];
713                let outcome = match step {
714                    Classified::Drive(d) => page.drive(d),
715                    Classified::Read => Ok(()),
716                    Classified::Unsupported(reason) => {
717                        return TierRun::Refused {
718                            statement: self.owner(i),
719                            reason: reason.clone(),
720                        }
721                    }
722                };
723                match outcome {
724                    Ok(()) => {}
725                    Err(DriveError::Refused(reason)) => {
726                        let reason = match step {
727                            Classified::Drive(Drive::Mount { component, .. }) => {
728                                self.why_not_lowered(component).unwrap_or(reason)
729                            }
730                            _ => reason,
731                        };
732                        return TierRun::Refused {
733                            statement: self.owner(i),
734                            reason,
735                        };
736                    }
737                    Err(DriveError::Failed(message)) => {
738                        trace.steps.push(TraceStep {
739                            statement: k,
740                            executed,
741                            snapshot: snap(page),
742                        });
743                        trace.stopped = Some(Stop {
744                            statement: k,
745                            message,
746                        });
747                        return TierRun::Traced(trace);
748                    }
749                }
750                i += 1;
751            }
752            trace.steps.push(TraceStep {
753                statement: k,
754                executed,
755                snapshot: snap(page),
756            });
757        }
758        TierRun::Traced(trace)
759    }
760
761    /// Why the lowering left `component` without a mount entry, in the lowering's own words.
762    fn why_not_lowered(&self, component: &str) -> Option<String> {
763        why_not_lowered(&self.arranged.defs, self.types, self.interner, component)
764    }
765}
766
767/// Why the lowering left `component` without a mount entry, in the lowering's own words — the
768/// compiler emits `defs` as a whole rather than failing it outright, so a construct one component
769/// cannot carry (a store shared across replicas, an event modifier with no lowered driver, …)
770/// shows up as a MISSING export, not a compile error; this is the one place that turns that
771/// absence back into the reason, for every caller that drives a lowered page from outside it
772/// (`Scenario::walk`'s own [`Scenario::why_not_lowered`], and [`drive_main`]).
773fn why_not_lowered(
774    defs: &[Stmt],
775    types: &TypeRegistry,
776    interner: &Interner,
777    component: &str,
778) -> Option<String> {
779    let component_defs: Vec<_> = defs
780        .iter()
781        .filter_map(|s| match s {
782            Stmt::ComponentDef(d) => Some((**d).clone()),
783            _ => None,
784        })
785        .collect();
786    let def = component_defs
787        .iter()
788        .find(|d| interner.resolve(d.name) == component)?;
789    match logicaffeine_language::parser::lgx_lower::lowerable(
790        def,
791        &component_defs,
792        Some(types),
793        interner,
794    ) {
795        Err(why) => Some(format!(
796            "`Mount {component}` cannot run on this tier: {}",
797            why.message
798        )),
799        Ok(()) => None,
800    }
801}
802
803/// `## Main` driving a page — the plain, non-sealed twin of [`Scenario::lowered_vm_trace`]. There
804/// is no test to seal a world for and no reference trace to hold against: `compile.rs::vm_outcome`
805/// hands over a program's own definitions and its own driving script (its gate has already made
806/// sure `drive_stmts` is nothing but driving statements), and this drives them exactly as a
807/// `## Test` drives its candidate tier — one statement at a time, through the session's guarded
808/// door, settling after each — so a trapping turn recovers here and a typed fetch completes here,
809/// the same as they do under `## Test`.
810pub fn drive_main<'a>(
811    defs: &[Stmt<'a>],
812    drive_stmts: &[Stmt<'a>],
813    interner: &Interner,
814    types: &TypeRegistry,
815    policies: &PolicyRegistry,
816) -> Result<String, String> {
817    let program = crate::vm::Compiler::compile_with_types(defs, interner, Some(types))
818        .map_err(|e| format!("the VM compiler cannot compile this program: {e}"))?;
819    let vm = crate::vm::Vm::new(&program).with_policy_ctx(policies, interner);
820    let mut page = LoweredPage::new(crate::vm::session::Session::new(vm, interner));
821    let to_message = |e: DriveError| match e {
822        DriveError::Failed(m) | DriveError::Refused(m) => m,
823    };
824    let auto_mounted = auto_mounted_page_component(&[defs, drive_stmts].concat(), interner);
825    page.start(auto_mounted, |s| s.run_main())
826        .map_err(to_message)?;
827    for stmt in drive_stmts {
828        let d = match classify(stmt, interner) {
829            Classified::Drive(d) => d,
830            _ => {
831                return Err(format!(
832                    "{} is not a driving statement — the gate that routed this program's `## \
833                     Main` here is out of step with `classify`",
834                    stmt_label(stmt)
835                ))
836            }
837        };
838        if let Err(e) = page.drive(&d) {
839            // The compiler emits `defs` as a whole rather than failing it outright, so a
840            // construct one component cannot carry shows up as a MISSING mount export, not a
841            // compile error (see `why_not_lowered`) — the generic `DriveError::Refused` this
842            // produces on its own names no more than that absence; ask the lowering directly for
843            // the reason, exactly as `Scenario::walk` already does for a `## Test`'s Mount.
844            let message = match (&d, e) {
845                (Drive::Mount { component, .. }, DriveError::Refused(reason)) => {
846                    why_not_lowered(defs, types, interner, component).unwrap_or(reason)
847                }
848                (_, e) => to_message(e),
849            };
850            return Err(message);
851        }
852    }
853    // An action failure NO handler absorbed does not stop the script — the reference tree-walker
854    // keeps running the rest of `## Main` past a trapping turn exactly the same way (a later click
855    // still lands) — but it IS the program's own failure once nothing is left to run, the same way
856    // an entirely unhandled failure is fatal to the tree-walker's own `RunOutcome` (confirmed
857    // directly: `tw_outcome` on a click with no `When an action fails:` handler reports the trap as
858    // its error even though the click after it still committed).
859    match page.unhandled_failure() {
860        Some(message) => Err(message.to_string()),
861        None => Ok(page.output()),
862    }
863}
864
865/// Whether `stmt` acts on a page — a statement the page host carries out ([`Drive`]), whatever its
866/// operands — rather than one that reads the page or never touches it.
867pub fn drives_the_page(stmt: &Stmt, interner: &Interner) -> bool {
868    match stmt {
869        Stmt::Ui(ui) => ui.role() != crate::ast::stmt::Role::Action,
870        _ => to_drive(stmt, interner).is_some(),
871    }
872}
873
874/// What `stmt` is to a tier that drives the page from outside.
875pub fn classify(stmt: &Stmt, interner: &Interner) -> Classified {
876    match to_drive(stmt, interner) {
877        Some(Ok(d)) => Classified::Drive(d),
878        Some(Err(why)) => Classified::Unsupported(why),
879        None if reads_only_stmt(stmt) => Classified::Read,
880        None => Classified::Unsupported(format!(
881            "{} is carried out by the tree-walker running the test; a driven tier can carry only \
882             driving statements and statements that read the page",
883            stmt_label(stmt)
884        )),
885    }
886}
887
888/// The driving statement `stmt` is, with its operands, or `None` when it drives nothing.
889fn to_drive(stmt: &Stmt, interner: &Interner) -> Option<Result<Drive, String>> {
890    let t = |e: &Expr| operand_text(e, interner);
891    Some(match stmt {
892        Stmt::Ui(ui) => match ui {
893            UiStmt::Mount {
894                component,
895                selector,
896            } => (|| {
897                Ok(Drive::Mount {
898                    component: interner.resolve(*component).to_string(),
899                    at: selector.map(t).transpose()?,
900                })
901            })(),
902            UiStmt::Click { selector } => t(selector).map(|selector| Drive::Click { selector }),
903            UiStmt::Submit { selector } => t(selector).map(|selector| Drive::Submit { selector }),
904            UiStmt::Press { key, selector } => (|| {
905                Ok(Drive::Press {
906                    key: t(key)?,
907                    selector: match selector {
908                        Some(s) => t(s)?,
909                        None => "body".to_string(),
910                    },
911                })
912            })(),
913            UiStmt::TypeInto { text, selector } => (|| {
914                Ok(Drive::Type {
915                    text: t(text)?,
916                    selector: t(selector)?,
917                })
918            })(),
919            UiStmt::Choose { value, selector } => (|| {
920                Ok(Drive::Choose {
921                    value: t(value)?,
922                    selector: t(selector)?,
923                })
924            })(),
925            UiStmt::Hover { selector } => t(selector).map(|selector| Drive::Hover { selector }),
926            UiStmt::Focus { selector } => t(selector).map(|selector| Drive::Focus { selector }),
927            UiStmt::Blur { selector } => t(selector).map(|selector| Drive::Blur { selector }),
928            // `Raise "filedropped" on SEL with FILE.` drops a file: the file travels in the drive, so
929            // each drop delivers its own file on whichever tier carries it out.
930            UiStmt::RaiseWindowEvent {
931                event,
932                target,
933                value,
934            } => (|| {
935                let event = t(event)?;
936                let target = t(target)?;
937                if event == "filedropped" {
938                    return Ok(Drive::DropFile {
939                        target,
940                        file: dropped_file(value, interner)?,
941                    });
942                }
943                Ok(Drive::Raise {
944                    event,
945                    target,
946                    value: t(value)?,
947                })
948            })(),
949            UiStmt::Navigate { url } => t(url).map(|url| Drive::Navigate { url }),
950            UiStmt::Reload => Ok(Drive::Reload),
951            other => Err(format!(
952                "`{}` in a test is not a driving statement the page host maps",
953                ui_label(other)
954            )),
955        },
956        Stmt::AdvanceTime { millis } => match millis {
957            Expr::Literal(Literal::Number(n)) => Ok(Drive::AdvanceTime {
958                ms: (*n).max(0) as u64,
959            }),
960            Expr::Literal(Literal::Duration(ns)) => Ok(Drive::AdvanceTime {
961                ms: ((*ns).max(0) / 1_000_000) as u64,
962            }),
963            other => Err(computed(other, interner)),
964        },
965        Stmt::GivenFetch {
966            method,
967            url,
968            status,
969            body,
970        } => (|| {
971            let status = match status {
972                Expr::Literal(Literal::Number(n)) => *n,
973                other => return Err(computed(other, interner)),
974            };
975            Ok(Drive::AnswerFetch {
976                method: interner.resolve(*method).to_string(),
977                url: t(url)?,
978                status,
979                body: t(body)?,
980            })
981        })(),
982        Stmt::ServeFetches => Ok(Drive::ServeFetchesWithRoutes),
983        Stmt::Replay { actions } => Ok(Drive::Replay {
984            actions: actions
985                .iter()
986                .map(|a| interner.resolve(*a).to_string())
987                .collect(),
988        }),
989        _ => return None,
990    })
991}
992
993/// The file a drop's operand declares: `a new File with name … and size … and type … and bytes …`
994/// with literal fields, read by [`dom::DroppedFile::declared`]'s rule, or a literal Text naming it.
995fn dropped_file(value: &Expr, interner: &Interner) -> Result<dom::DroppedFile, String> {
996    let Expr::New {
997        type_name,
998        init_fields,
999        ..
1000    } = value
1001    else {
1002        return Ok(dom::DroppedFile::named(&operand_text(value, interner)?));
1003    };
1004    if interner.resolve(*type_name) != "File" {
1005        return Err(format!(
1006            "a dropped file is a `File` or its name as Text, and this is a new {}",
1007            interner.resolve(*type_name)
1008        ));
1009    }
1010    let field = |name: &str| {
1011        init_fields
1012            .iter()
1013            .find(|(f, _)| interner.resolve(*f) == name)
1014            .map(|(_, e)| *e)
1015    };
1016    let text = |name: &str| field(name).map(|e| operand_text(e, interner)).transpose();
1017    let size = match field("size") {
1018        Some(Expr::Literal(Literal::Number(n))) => Some(*n),
1019        Some(other) => return Err(computed(other, interner)),
1020        None => None,
1021    };
1022    let bytes = match field("bytes") {
1023        Some(Expr::List(items)) => Some(
1024            items
1025                .iter()
1026                .map(|item| match item {
1027                    Expr::Literal(Literal::Number(n)) => Ok(*n),
1028                    other => Err(computed(other, interner)),
1029                })
1030                .collect::<Result<Vec<i64>, String>>()?,
1031        ),
1032        Some(other) => return Err(computed(other, interner)),
1033        None => None,
1034    };
1035    dom::DroppedFile::declared(
1036        text("name")?.unwrap_or_default(),
1037        size,
1038        text("type")?,
1039        bytes,
1040    )
1041}
1042
1043/// A driving statement's operand, when it is written as a literal.
1044fn operand_text(e: &Expr, interner: &Interner) -> Result<String, String> {
1045    match e {
1046        Expr::Literal(Literal::Text(s)) => Ok(interner.resolve(*s).to_string()),
1047        Expr::Literal(Literal::Number(n)) => Ok(n.to_string()),
1048        other => Err(computed(other, interner)),
1049    }
1050}
1051
1052fn computed(e: &Expr, interner: &Interner) -> String {
1053    format!(
1054        "its operand `{}` is computed, and evaluating it is the tree-walker's job; a driven tier \
1055         takes literal operands until the interpreter hands it evaluated statements",
1056        logicaffeine_language::printer::render_expr(e, interner)
1057    )
1058}
1059
1060/// Whether `stmt` only reads — the forms a test uses to look at a page, over expressions that
1061/// cannot write it.
1062fn reads_only_stmt(stmt: &Stmt) -> bool {
1063    match stmt {
1064        Stmt::Expect { condition } => reads_only(condition),
1065        Stmt::ExpectOutput { .. } => true,
1066        Stmt::Let { value, .. } | Stmt::Set { value, .. } => reads_only(value),
1067        Stmt::Show { object, recipient } => {
1068            reads_only(object) && matches!(recipient, Expr::Identifier(_))
1069        }
1070        Stmt::RuntimeAssert {
1071            condition, reason, ..
1072        } => reads_only(condition) && reason.map_or(true, reads_only),
1073        _ => false,
1074    }
1075}
1076
1077/// Whether evaluating `e` can only read. An allowlist: a shape not named here is not claimed.
1078fn reads_only(e: &Expr) -> bool {
1079    match e {
1080        Expr::Literal(_) | Expr::Identifier(_) | Expr::OptionNone => true,
1081        Expr::BinaryOp { left, right, .. }
1082        | Expr::Index {
1083            collection: left,
1084            index: right,
1085        }
1086        | Expr::Contains {
1087            collection: left,
1088            value: right,
1089        }
1090        | Expr::Range {
1091            start: left,
1092            end: right,
1093        }
1094        | Expr::Union { left, right }
1095        | Expr::Intersection { left, right } => reads_only(left) && reads_only(right),
1096        Expr::Not { operand }
1097        | Expr::Length {
1098            collection: operand,
1099        }
1100        | Expr::FieldAccess {
1101            object: operand, ..
1102        }
1103        | Expr::OptionSome { value: operand }
1104        | Expr::Copy { expr: operand } => reads_only(operand),
1105        Expr::Slice {
1106            collection,
1107            start,
1108            end,
1109        } => reads_only(collection) && reads_only(start) && reads_only(end),
1110        Expr::List(items) | Expr::Tuple(items) => items.iter().all(|i| reads_only(i)),
1111        Expr::InterpolatedString(parts) => parts.iter().all(|p| match p {
1112            crate::ast::stmt::StringPart::Literal(_) => true,
1113            crate::ast::stmt::StringPart::Expr { value, .. } => reads_only(value),
1114        }),
1115        Expr::Dom(query) => query.selector().map_or(true, reads_only),
1116        _ => false,
1117    }
1118}
1119
1120fn stmt_label(stmt: &Stmt) -> &'static str {
1121    match stmt {
1122        Stmt::If { .. } => "an `If`",
1123        Stmt::While { .. } => "a `While`",
1124        Stmt::Repeat { .. } => "a `Repeat`",
1125        Stmt::Call { .. } => "a `Call`",
1126        Stmt::ExpectFail { .. } => "an `Expect this to fail:` block",
1127        Stmt::ForAll { .. } => "a `For all` property",
1128        Stmt::Inspect { .. } => "an `Inspect`",
1129        Stmt::GivenFile { .. } => "`Given a file`",
1130        Stmt::Let { .. } => "a `Let` that calls a function",
1131        Stmt::Set { .. } => "a `Set` that calls a function",
1132        Stmt::Expect { .. } => "an `Expect` that calls a function",
1133        Stmt::Show { .. } => "a `Show` that calls a function",
1134        _ => "this statement",
1135    }
1136}
1137
1138fn ui_label(ui: &UiStmt) -> &'static str {
1139    match ui {
1140        UiStmt::ScrollTo { .. } => "Scroll to",
1141        UiStmt::AddClass { .. } => "Add class",
1142        UiStmt::RemoveClass { .. } => "Remove class",
1143        UiStmt::SelectText { .. } => "Select the text in",
1144        UiStmt::CopyToClipboard { .. } => "Copy … to the clipboard",
1145        UiStmt::Download { .. } => "Download",
1146        UiStmt::ReplaceUrl { .. } => "Replace the URL with",
1147        UiStmt::GoBack => "Go back",
1148        UiStmt::SetTitle { .. } => "Set the title to",
1149        UiStmt::Save { .. } => "Save",
1150        UiStmt::Load { .. } => "Load",
1151        UiStmt::Forget { .. } => "Forget",
1152        UiStmt::Notify { .. } => "Notify",
1153        UiStmt::Publish { .. } => "Publish",
1154        UiStmt::Toggle { .. } => "Toggle",
1155        UiStmt::CallStore { .. } => "Call <Store>'s",
1156        UiStmt::StopTimer { .. } => "Stop the timer",
1157        UiStmt::StartTimer { .. } => "Start the timer",
1158        _ => "this verb",
1159    }
1160}
1161
1162/// One `## Test`, run on the reference tier and on each candidate tier.
1163#[derive(Clone, Debug, Serialize)]
1164pub struct TestTierComparison {
1165    pub test: String,
1166    /// The page's lowered LOGOS on the tree-walker's run — the reference.
1167    pub reference: PageTrace,
1168    /// Every tier asked for, in the order asked — the reference tier among them answering with the
1169    /// reference's own run.
1170    pub candidates: Vec<(PageTierKind, TierRun)>,
1171}
1172
1173impl TestTierComparison {
1174    /// How `kind`'s run differs from the reference; `None` when it did not run a trace.
1175    pub fn divergence(&self, kind: PageTierKind) -> Option<Divergence> {
1176        self.candidates
1177            .iter()
1178            .find(|(k, _)| *k == kind)
1179            .and_then(|(_, run)| match run {
1180                TierRun::Traced(t) => Some(self.reference.compare(t)),
1181                TierRun::Refused { .. } => None,
1182            })
1183    }
1184}
1185
1186/// Parse `source`, and run every `## Test` in it that works a page on the reference tier and on
1187/// each of `candidates`. A test whose program mounts nothing is not a page scenario and is left
1188/// out. `## Main` is the program's entry point, never test fixture, and is stripped as the test
1189/// runner strips it.
1190pub fn compare_tests(
1191    source: &str,
1192    candidates: &[PageTierKind],
1193    suite_seed: u64,
1194    budget: u64,
1195    launcher: Option<&dyn RemotePageLauncher>,
1196) -> Result<Vec<TestTierComparison>, String> {
1197    let stripped = crate::analysis::assemble::strip_main_block(source);
1198    crate::ui_bridge::with_parsed_program(&stripped, |parsed, interner| {
1199        let (stmts, types, policies) = parsed?;
1200        let defs: Vec<Stmt> = stmts
1201            .iter()
1202            .filter(|s| !matches!(s, Stmt::TestDef { .. }))
1203            .cloned()
1204            .collect();
1205        let mut out = Vec::new();
1206        for s in stmts.iter() {
1207            let Stmt::TestDef {
1208                name,
1209                body,
1210                cases: None,
1211                skip: None,
1212                capabilities,
1213                budget: b,
1214            } = s
1215            else {
1216                continue;
1217            };
1218            if !works_a_page(&defs, body) {
1219                continue;
1220            }
1221            let caps = logicaffeine_system::testworld::Capabilities {
1222                filesystem: capabilities.filesystem,
1223                network: capabilities.network,
1224                environment: capabilities.environment,
1225                clock: capabilities.clock,
1226            };
1227            let test = interner.resolve(*name).to_string();
1228            let scenario = Scenario::new(
1229                interner,
1230                types,
1231                &policies,
1232                &defs,
1233                body,
1234                World::for_test(&test, suite_seed, caps, b.unwrap_or(budget)),
1235            );
1236            let here = scenario.lowered_here_run();
1237            let candidates = candidates
1238                .iter()
1239                .map(|k| match k {
1240                    PageTierKind::LoweredHere => (*k, here.tier_run()),
1241                    _ => (*k, scenario.run(*k, launcher, &stripped)),
1242                })
1243                .collect();
1244            out.push(TestTierComparison {
1245                test,
1246                reference: here.trace,
1247                candidates,
1248            });
1249        }
1250        Ok(out)
1251    })
1252}
1253
1254/// An example page's assembled source — `entry`'s project closure through
1255/// [`crate::analysis::assemble_project`], the same function `largo build --static` compiles from,
1256/// not a raw single-file read. A page that shares a layout or a type across files (`Import
1257/// Layout.` + `{extends Site}`, `Import Shared.`) is a normal project; a discovery path that reads
1258/// only the one file it happened to be pointed at is what misdiagnoses that page, not the page.
1259///
1260/// Retried like a single-file `read_stable`: assembly reads the entry AND every file it imports
1261/// straight off disk itself, so a page beside another process's mid-write (a live, multi-file
1262/// corpus) must not read as a spurious failure any more than a single-file read would. Returns the
1263/// underlying assembly error only once 20 attempts, 5ms apart, never produce a reading that repeats
1264/// — a real, persistent problem, not a torn one.
1265pub fn assemble_stable(entry: &Path) -> Result<String, String> {
1266    let mut last_ok: Option<String> = None;
1267    let mut last_err: Option<String> = None;
1268    for attempt in 0..20 {
1269        if attempt > 0 {
1270            std::thread::sleep(Duration::from_millis(5));
1271        }
1272        match crate::analysis::assemble_project(entry) {
1273            Ok(assembled) if last_ok.as_deref() == Some(assembled.source.as_str()) => {
1274                return Ok(assembled.source);
1275            }
1276            Ok(assembled) => last_ok = Some(assembled.source),
1277            Err(e) => last_err = Some(e),
1278        }
1279    }
1280    last_ok.ok_or_else(|| {
1281        last_err.unwrap_or_else(|| format!("could not stably assemble {}", entry.display()))
1282    })
1283}
1284
1285impl<'a> Scenario<'a> {
1286    /// The run on `kind`. The reference tier's run is [`Scenario::reference_trace`].
1287    pub fn run(
1288        &self,
1289        kind: PageTierKind,
1290        launcher: Option<&dyn RemotePageLauncher>,
1291        source: &str,
1292    ) -> TierRun {
1293        match kind {
1294            PageTierKind::LoweredHere => self.lowered_here_trace(),
1295            PageTierKind::LoweredVm => self.lowered_vm_trace(),
1296            PageTierKind::Browser => match launcher {
1297                Some(l) => self.remote_trace(l, source),
1298                None => TierRun::Refused {
1299                    statement: 0,
1300                    reason: "no browser is attached to this run".to_string(),
1301                },
1302            },
1303        }
1304    }
1305}
1306
1307/// Whether a scenario works a page: something in the program or the body mounts one.
1308pub fn works_a_page(defs: &[Stmt], body: &[Stmt]) -> bool {
1309    defs.iter()
1310        .chain(body)
1311        .any(|s| matches!(s, Stmt::Ui(UiStmt::Mount { .. })))
1312}