From 7052644467481eaf94040aa508a2ed206b7b266e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 23 Sep 2026 02:27:44 +0200 Subject: [PATCH] Land Cut 12: gate the answer path, recover pre-Cut-11 play stores (PA.f169, PA.f172, PA.f173, PA.f175) PA.f169 (blocker): two more `client_intents` callers (`world_create_seed_activate_and_play_round_trip_through_the_real_client`, `a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted`) ended in `.expect("node must be on PATH ...")` with no skip guard, so `cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon` itself failed in a `node`-less container -- Idunn's own gate. Both now carry the same `eve_client_bridge_is_available()` skip guard the third caller already had, printing a plain SKIPPED line naming themselves. Verified with `node` scrubbed from PATH: all three skip and the full bin suite is still 156 passed, 0 failed. PA.f172: `question_surface_version` (added in Cut 11 with no `#[serde(default)]`) made a pre-Cut-11 `PlayTurnStore` row fail to decode, which `PlayTurnStore::open` reported as "corrupt" and `runtime.rs` turned into a silently missing play card. `STORE_SCHEMA` moves to `ghostlight.play_turn_store.v2`; `question_surface_version` now carries `#[serde(default)]`, so the same struct is a compatible read of a genuine v1 row (`LEGACY_STORE_SCHEMA_V1`). `PlayTurnStore::open` recognizes that schema id as legacy, not corrupt, recovers the turn, and upgrades the row to v2 in place and durably. The strict canonical re-encode check still applies to current-schema rows; a v1 row's bytes were never canonical under v2 and the legacy branch does not attempt the comparison. A row that matches the current schema id but is genuinely undecodable is still refused as corrupt. `runtime.rs::runtime_readiness` now reports `playStatus` ("ok"/"unavailable") on `/health`, the same `Option -> ok/unavailable` shape `projectionStatus`/`controllerStatus` already use, so a play table that failed to open is visible there instead of only in a startup `tracing::warn!`. PA.f173: `close_with_fault` left `question`/`question_surface_version` set on the turn it closed. Unobservable through `open_question_id` only because it gates on `state == AwaitingPlayer`, which closing always leaves behind in the same write. `close_with_fault` now clears both, matching `answer_turn`. PA.f175: `execute_round` took one snapshot at round entry and reused it for every arm in that round, so a round that authors (a `PLAY_TOOLS` run) and then asks in the same round recorded `question_surface_version` from before its own commit. The `ASK_PLAYER_TOOL` arm now fetches a fresh snapshot at the moment the question actually opens. Tests added: a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt, a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt, close_with_fault_clears_the_open_question_off_the_persisted_row, a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round, health_reports_play_status_from_state_play. Dungeon bin: 156 passed, 0 failed (bridge available); 156 passed, 0 failed with `node` scrubbed from PATH (3 SKIPPED). Co-Authored-By: Claude Sonnet 5 --- crates/ghostlight-dungeon/src/play.rs | 412 ++++++++++++++++++++++- crates/ghostlight-dungeon/src/runtime.rs | 54 +++ 2 files changed, 452 insertions(+), 14 deletions(-) diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs index 62eb573..3eb39ba 100644 --- a/crates/ghostlight-dungeon/src/play.rs +++ b/crates/ghostlight-dungeon/src/play.rs @@ -43,7 +43,20 @@ use sha2::{Digest, Sha256}; use tokio::sync::{Mutex, Semaphore}; const STORE_TYPE: &str = "ghostlight.play_turn_store.v1"; -const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v1"; +/// PA.f172: bumped from `ghostlight.play_turn_store.v1` when +/// `question_surface_version` was added to `PlayTurn` without +/// `#[serde(default)]` — the row's own shape moved, so its schema id moves +/// with it. `question_surface_version` now carries `#[serde(default)]`, so +/// this v2 reader is a compatible read of a genuine v1 row too: `open` still +/// gates strictly on the row's own declared `schema_id` (a fresh legacy +/// branch recognizes `LEGACY_STORE_SCHEMA_V1` by name, never silently), but +/// once recognized, decodes it with exactly the same struct and upgrades it +/// in place rather than discarding the turn it names as unreadable. +const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v2"; +/// The schema id every row written before `question_surface_version` existed +/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and +/// upgraded to `STORE_SCHEMA` in place (PA.f172). +const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1"; const STORE_KEY: &str = "primary"; /// The one round budget: exhausting it closes the turn and narrates, rather @@ -245,10 +258,16 @@ struct PlayTurn { /// world's current revision happens to be when a later request arrives. /// This is what lets an answer be refused as stale by "older than the /// version the question first appeared on" rather than by "not exactly - /// today's version," the comparison PA.f161 replaces. No - /// `#[serde(default)]`, matching `question`'s own row-schema neighbors - /// above (PA.f95): the store has never been deployed, so no row anywhere - /// is missing it. + /// today's version," the comparison PA.f161 replaces. `#[serde(default)]` + /// (PA.f172, reversing the earlier PA.f95 reasoning here): a row written + /// before this field existed — a real shape on a workstation that ran + /// Dungeon before Cut 11, not a hypothetical — decodes to `None`, exactly + /// what "no question ever recorded a surface version" means for a turn + /// this old. `STORE_SCHEMA` moved to v2 alongside this default so the + /// canonical re-encode check below still holds current-schema rows to + /// their exact bytes; only the legacy v1 read path in + /// `PlayTurnStore::open` relies on this default to recover an older row. + #[serde(default)] question_surface_version: Option, refusal: Option, narration: Option, @@ -341,8 +360,9 @@ impl PlayTurn { /// `open_question_id` is, since `turn.question` and /// `turn.question_surface_version` are only ever set alongside it /// (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in the same -/// round, and `answer_turn` clears `question`/`question_surface_version` -/// together). +/// round, and `answer_turn` and `close_with_fault` both clear +/// `question`/`question_surface_version` on their own way out of +/// `AwaitingPlayer`, PA.f173). fn open_question_of(turn: &PlayTurn) -> Option { let id = turn.open_question_id()?; Some(OpenQuestion { @@ -463,7 +483,38 @@ impl PlayTurnStore { } (row.clone(), state) } - _ => bail!("play-turn v1 store contains foreign or legacy records; start with a fresh store"), + // PA.f172: a row written before `question_surface_version` + // existed — named legacy, not corrupt, because it is neither: it + // is a real, previously-canonical shape this process itself + // wrote, under the schema id that was current when it did. + // `question_surface_version` now carries `#[serde(default)]`, so + // the very same `PlayTurnStoreState` decodes it correctly (the + // field reads back `None`, matching what "no question ever + // recorded a surface version" means for a turn this old). This + // branch upgrades the row to `STORE_SCHEMA` (v2) in place and + // persists that upgrade immediately, so the legacy path is only + // ever taken once per row; the canonical re-encode check does + // not apply here — a v1 row's bytes were never canonical under + // v2 and were never meant to be — it resumes for every write + // this store makes from here on. + [row] + if row.r#type == STORE_TYPE + && row.key == STORE_KEY + && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) => + { + let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) + .context("play-turn legacy v1 state is corrupt")?; + if state.schema != LEGACY_STORE_SCHEMA_V1 { + bail!("play-turn legacy v1 state schema is invalid"); + } + state.schema = STORE_SCHEMA.into(); + let upgraded_row = envelope(&state)?; + if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()])? { + bail!("play-turn store changed while upgrading a legacy v1 row"); + } + (upgraded_row, state) + } + _ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"), }; Ok(Self { store, @@ -1150,6 +1201,15 @@ impl PlayTable { async fn close_with_fault(&self, turn: &mut PlayTurn, detail: String) -> Result<(), PlayError> { turn.state = PlayTurnState::Closed; turn.fault = Some(detail); + // PA.f173: a fault can close a turn that was `AwaitingPlayer`, and + // `answer_turn` is not the only door out of that state — clear the + // open question here too, the same way `answer_turn` does, so a + // closed turn never carries a question record alongside it. This was + // unobservable before this fix only because `open_question_id` gates + // on `state == AwaitingPlayer`, which a fault always leaves behind by + // setting `state = Closed` in the same write. + turn.question = None; + turn.question_surface_version = None; self.persist(turn).await } @@ -1425,12 +1485,25 @@ impl PlayTable { None, ); turn.question = Some(question); - // PA.f161: the world's own surface version at the instant - // this question opens, so a later answer is judged against - // the version the question actually first appeared on, never - // whatever the world's version happens to be when the - // answer arrives. - turn.question_surface_version = Some(crate::eve::surface_version(Some(&snapshot))); + // PA.f161/PA.f175: the world's own surface version at the + // instant this question opens, so a later answer is judged + // against the version the question actually first appeared + // on, never whatever the world's version happens to be when + // the answer arrives. This must be a fresh snapshot taken + // right here, not the round-entry `snapshot` above: a round + // that authors (a `PLAY_TOOLS` run, committed earlier in this + // same `while` loop) and then asks would otherwise record the + // version as of before its own commits, not the version the + // question actually opened on. + let question_snapshot = match self.fetch_round_snapshot().await { + Ok(snapshot) => snapshot, + Err(detail) => { + self.close_with_fault(turn, detail).await?; + return Ok(RoundOutcome::Closed); + } + }; + turn.question_surface_version = + Some(crate::eve::surface_version(Some(&question_snapshot))); turn.state = PlayTurnState::AwaitingPlayer; return Ok(RoundOutcome::AwaitingPlayer); } @@ -3219,6 +3292,317 @@ pub(crate) mod tests { ); } + /// PA.f175: `execute_round` takes one snapshot at round entry + /// (`round_snapshot`) and, before this fix, reused it for every arm in + /// that same round — including `ASK_PLAYER_TOOL` — so a round that + /// commits an authoring call (a `PLAY_TOOLS` run) and then asks in the + /// same round recorded `question_surface_version` from before its own + /// commit. A later "is this answer stale" comparison + /// (`runtime.rs::an_answer_naming_a_version_older_than_the_question_is_refused_as_stale`) + /// judges a version the question's own commit had already passed, so an + /// answer naming the version the commit actually landed at — the version + /// a real client would have just been served — could be wrongly refused + /// as older than the question. + /// + /// Mutation: revert `execute_round`'s `ASK_PLAYER_TOOL` arm to record + /// `crate::eve::surface_version(Some(&snapshot))` (the round-entry + /// snapshot) instead of fetching a fresh one at the moment the question + /// opens — this test's asserted version would then equal the version + /// from before the round's own `declare_fact` commit, one behind the + /// version this test expects. + #[tokio::test] + async fn a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round() { + let fixture = play_world(None, "player-author-then-ask").await; + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("play-turn-v1.cc"); + + let world_before = fixture.world.snapshot().await.unwrap(); + let version_before = crate::eve::surface_version(Some(&world_before)); + + let round = output( + "r0", + vec![ + call_event( + "c0", + "declare_fact", + serde_json::json!({ + "handle": "ruling", + "label": "The Ruling", + "statement": "The gate creaks open on its own.", + "standing": {"standing": "ruled"}, + }), + ), + call_event( + "c1", + ASK_PLAYER_TOOL, + serde_json::json!({"question": "Which way?"}), + ), + ], + ); + let personas = PersonaLane::new( + ControllerPort::new(fixture.world.clone()), + ScriptedPort::new(vec![]), + "gpt-5.6-sol".into(), + "gpt-5.6-sol".into(), + ) + .unwrap(); + let table = PlayTable::new( + fixture.world.clone(), + personas, + ScriptedPort::new(vec![round]), + "gpt-5.6-terra".into(), + Arc::new(Semaphore::new(2)), + &store_path, + ) + .unwrap(); + + table + .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) + .await + .unwrap(); + + let asked_view = table.current_turn_view().await.unwrap(); + let question = asked_view + .question + .as_ref() + .expect("the scripted ask_player call must open a question"); + + let world_after = fixture.world.snapshot().await.unwrap(); + let version_after = crate::eve::surface_version(Some(&world_after)); + assert!( + version_after > version_before, + "declare_fact must have committed and moved the world's own revision \ + ({version_before} -> {version_after})" + ); + assert_eq!( + question.surface_version, version_after, + "the question's own recorded version must be the version as of when it opened \ + (after this round's own declare_fact commit, {version_after}), not the \ + round-entry version {version_before}" + ); + } + + /// PA.f173: `close_with_fault` used to leave `question` and + /// `question_surface_version` set on the turn it closed, though a + /// `Closed` turn should never still carry an open question. This was + /// unobservable through `current_turn_view`/`open_question_id` only + /// because both gate on `state == AwaitingPlayer`, which closing always + /// leaves behind in the same write — so this test reaches past that + /// gate, at the raw persisted row. + /// + /// Mutation: remove the `turn.question = None; turn.question_surface_version + /// = None;` lines from `close_with_fault` — the persisted row would then + /// still carry the question's own text and version after the turn + /// closed, and this test's assertions would fail. + #[tokio::test] + async fn close_with_fault_clears_the_open_question_off_the_persisted_row() { + let fixture = play_world(None, "player-fault-clears-question").await; + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("play-turn-v1.cc"); + + let ask = output( + "r0", + vec![call_event( + "c0", + ASK_PLAYER_TOOL, + serde_json::json!({"question": "Which way?"}), + )], + ); + let personas = PersonaLane::new( + ControllerPort::new(fixture.world.clone()), + ScriptedPort::new(vec![]), + "gpt-5.6-sol".into(), + "gpt-5.6-sol".into(), + ) + .unwrap(); + let table = PlayTable::new( + fixture.world.clone(), + personas, + ScriptedPort::new(vec![ask]), + "gpt-5.6-terra".into(), + Arc::new(Semaphore::new(2)), + &store_path, + ) + .unwrap(); + + table + .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) + .await + .unwrap(); + let asked_view = table.current_turn_view().await.unwrap(); + assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer); + + let mut turn = table.store.lock().await.current().unwrap().clone(); + assert!(turn.question.is_some()); + assert!(turn.question_surface_version.is_some()); + table + .close_with_fault(&mut turn, "manufactured fault for PA.f173".into()) + .await + .unwrap(); + + let persisted = table.store.lock().await.current().unwrap().clone(); + assert_eq!(persisted.state, PlayTurnState::Closed); + assert_eq!( + persisted.question, None, + "a closed turn must not still carry the question it closed on top of" + ); + assert_eq!( + persisted.question_surface_version, None, + "a closed turn must not still carry the question's own surface version" + ); + } + + /// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field + /// minus `question_surface_version` — the shape every row actually + /// written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). Test-only: + /// no production writer ever mints this shape again. + #[derive(Clone, Serialize)] + struct LegacyPlayTurnV1 { + turn_id: String, + applied_keys: Vec, + opening_prompt: String, + player_prose: Vec, + rounds: Vec, + calls: Vec, + persona_turns: Vec<(SubjectId, PersonaTurn)>, + question: Option, + refusal: Option, + narration: Option, + fault: Option, + state: PlayTurnState, + } + + #[derive(Clone, Serialize)] + struct LegacyPlayTurnStoreStateV1 { + schema: String, + turn: Option, + ledger: KeyLedger, + revision: u64, + } + + /// PA.f172: a row written before `question_surface_version` existed — + /// under `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process + /// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is + /// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its + /// turn recovered rather than discarded: the missing field decodes to + /// `None`, exactly what "no question ever recorded a surface version" + /// means for a turn this old. The row is also upgraded to `STORE_SCHEMA` + /// in place, durably, so the legacy path is only ever taken once. + /// + /// Mutation: remove the `schema_id.as_deref() == + /// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this + /// row would then fall through to the catch-all and `open` would return + /// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row + /// silently disables play with "the play table is unavailable". + #[tokio::test] + async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() { + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("play-turn-v1.cc"); + + let legacy_turn = LegacyPlayTurnV1 { + turn_id: "11111111-1111-1111-1111-111111111111".into(), + applied_keys: vec!["key-a".into()], + opening_prompt: "You stand at the gate.".into(), + player_prose: vec!["I knock.".into()], + rounds: Vec::new(), + calls: Vec::new(), + persona_turns: Vec::new(), + question: None, + refusal: None, + narration: None, + fault: None, + state: PlayTurnState::Running, + }; + let legacy_state = LegacyPlayTurnStoreStateV1 { + schema: LEGACY_STORE_SCHEMA_V1.into(), + turn: Some(legacy_turn), + ledger: KeyLedger::default(), + revision: 3, + }; + let legacy_row = CultCacheEnvelope { + key: STORE_KEY.into(), + r#type: STORE_TYPE.into(), + payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), + }; + { + let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); + assert!( + raw_store + .compare_and_swap_batch(&[], vec![legacy_row]) + .unwrap(), + "seeding the legacy row must succeed against an empty store" + ); + } + + let store = PlayTurnStore::open(&store_path) + .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); + let recovered = store.current().expect("the legacy row's own turn must be recovered"); + assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(recovered.opening_prompt, "You stand at the gate."); + assert_eq!( + recovered.question_surface_version, None, + "a pre-Cut-11 turn never recorded a surface version; the missing field must decode to None" + ); + assert_eq!( + store.row.schema_id.as_deref(), + Some(STORE_SCHEMA), + "the legacy row must be upgraded to the current schema in place, not left at v1" + ); + + // Reopening must now take the strict current-schema path, proving + // the upgrade was actually durably persisted, not only held in + // memory on the first `open`. `store` must be dropped first: CultCache's + // own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new` + // on the same path while the first is still held (the same custody + // rule `pull_current_from_disk_for_test`'s own doc comment names). + let recovered_turn_id = recovered.turn_id.clone(); + drop(store); + let reopened = PlayTurnStore::open(&store_path).unwrap(); + assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id); + } + + /// PA.f172's own negative: a row that matches the current schema id but + /// whose bytes do not decode at all is still refused as corrupt — the + /// legacy path recognizes only the one schema id every pre-Cut-11 + /// process actually wrote, never a general-purpose "try to read it + /// anyway" escape hatch for a row that is genuinely damaged. + #[tokio::test] + async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt() { + let directory = tempfile::tempdir().unwrap(); + let store_path = directory.path().join("play-turn-v1.cc"); + + let corrupt_row = CultCacheEnvelope { + key: STORE_KEY.into(), + r#type: STORE_TYPE.into(), + payload: vec![0xC1], // msgpack's own reserved "never used" marker byte + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(STORE_SCHEMA.into()), + }; + { + let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); + assert!( + raw_store + .compare_and_swap_batch(&[], vec![corrupt_row]) + .unwrap(), + "seeding the corrupt row must succeed against an empty store" + ); + } + + // `.expect_err` needs `T: Debug`; `PlayTurnStore` does not derive it + // (its own row holds turn text nobody wants dumped by a failed + // assertion), so this matches directly instead. + let error = match PlayTurnStore::open(&store_path) { + Ok(_) => panic!("undecodable bytes must still refuse to open"), + Err(error) => error, + }; + assert!( + error.to_string().contains("corrupt"), + "a genuinely undecodable row must still be named corrupt, not legacy: {error}" + ); + } + /// The fork Self ruled on, isolated: a commit that changes only /// `turn.refusal` — exactly the shape `execute_actor_call`'s /// `ActionRejected` arm leaves a row in (the call's own record is diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 387ae25..abe28b1 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -1571,8 +1571,21 @@ async fn runtime_readiness(state: &AppState) -> anyhow::Result { } None => "unavailable", }; + // PA.f172: the play table can fail to open independently of + // `controllers` (a `PlayTurnStore` a prior session's row makes unreadable), + // and before this the only trace was `tracing::warn!("the play table is + // unavailable; world authority remains online")` at startup — visible in + // a log an operator has to go looking for, never on the readiness + // surface a client or operator actually watches. Same `Option` -> + // "ok"/"unavailable" shape as `projectionStatus`/`controllerStatus` + // above; this does not invent a new channel. + let play_status = match &state.play { + Some(_) => "ok", + None => "unavailable", + }; health["projectionStatus"] = Value::String(projection_status.into()); health["controllerStatus"] = Value::String(controller_status.into()); + health["playStatus"] = Value::String(play_status.into()); Ok(health) } @@ -2179,6 +2192,27 @@ mod tests { } } + /// PA.f172: `state.play` being `None` — a play table that failed to + /// open, for any reason, `PlayTurnStore::open` returning `Err` among + /// them — must be visible on `/health`, the same operator-facing surface + /// `projectionStatus`/`controllerStatus` already report through, not + /// only in a `tracing::warn!` at startup. + /// + /// Mutation: delete the `health["playStatus"] = ...` line from + /// `runtime_readiness` — `/health`'s own JSON would then carry no + /// `playStatus` key at all, and both assertions below would fail. + #[tokio::test] + async fn health_reports_play_status_from_state_play() { + let fixture = fixture().await; + let available = get(&fixture.state, &fixture.cookie, "/health").await; + assert_eq!(available["playStatus"], "ok", "{available}"); + + let mut degraded_state = fixture.state.clone(); + degraded_state.play = None; + let unavailable = get(°raded_state, &fixture.cookie, "/health").await; + assert_eq!(unavailable["playStatus"], "unavailable", "{unavailable}"); + } + fn route_snapshot_request( message_id: &str, schema_ids: Option>, @@ -2518,6 +2552,16 @@ mod tests { /// `play::tests::a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration`. #[tokio::test] async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() { + if !eve_client_bridge_is_available() { + eprintln!( + "SKIPPED world_create_seed_activate_and_play_round_trip_through_the_real_client: \ + `node` or the vendored lowering's built `dist/index.js` is not available in this \ + environment, so this test did not drive the real Eve client bridge. Run this where \ + `node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \ + (`npm install && npm run build` in that package) to exercise this check." + ); + return; + } let fixture = fixture().await; let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; @@ -2853,6 +2897,16 @@ mod tests { /// accepted. #[tokio::test] async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() { + if !eve_client_bridge_is_available() { + eprintln!( + "SKIPPED a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted: \ + `node` or the vendored lowering's built `dist/index.js` is not available in this \ + environment, so this test did not drive the real Eve client bridge. Run this where \ + `node` is on PATH and `vendor/eve/packages/eve-browser-lowering` has been built \ + (`npm install && npm run build` in that package) to exercise this check." + ); + return; + } let fixture = fixture().await; let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await;