Compare commits
3 commits
d133b7e9e9
...
f768bc4a41
| Author | SHA1 | Date | |
|---|---|---|---|
| f768bc4a41 | |||
| 55212d7e0f | |||
| 4c930fac32 |
12 changed files with 978 additions and 6 deletions
52
Cargo.lock
generated
52
Cargo.lock
generated
|
|
@ -93,6 +93,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cb-kernel",
|
"cb-kernel",
|
||||||
"games-ground",
|
"games-ground",
|
||||||
|
"quick-js",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -104,6 +105,16 @@ dependencies = [
|
||||||
"games-ground",
|
"games-ground",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
|
|
@ -162,6 +173,15 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "copy_dir"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "543d1dd138ef086e2ff05e3a48cf9da045da2033d16f8538fd76b86cd49b2ca3"
|
||||||
|
dependencies = [
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
|
|
@ -243,6 +263,12 @@ version = "1.0.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "games-ground"
|
name = "games-ground"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -331,6 +357,16 @@ version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libquickjs-sys"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f0b24e9bd171b75ae0295bd428fb8fe58410fb23156e5f34a4657a70c3cee96"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"copy_dir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.8.3"
|
version = "2.8.3"
|
||||||
|
|
@ -376,6 +412,16 @@ dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quick-js"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "19cb4cefcb00f4ab9b332664d06005a74f582ac16aa959c6ad5912957bd83e5f"
|
||||||
|
dependencies = [
|
||||||
|
"libquickjs-sys",
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.47"
|
version = "1.0.47"
|
||||||
|
|
@ -512,6 +558,12 @@ dependencies = [
|
||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.119"
|
version = "2.0.119"
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ game.
|
||||||
and scenario tests, simple bots. No rendering, no physics.
|
and scenario tests, simple bots. No rendering, no physics.
|
||||||
1. **Inspectable 2D table** — card/token/hand/relationship-graph
|
1. **Inspectable 2D table** — card/token/hand/relationship-graph
|
||||||
visualization, drag-to-propose, debug inspector, hot-seat play.
|
visualization, drag-to-propose, debug inspector, hot-seat play.
|
||||||
|
*Open on one human verification (CB-EV-0012 §4): the inspector,
|
||||||
|
drag-to-propose and hot-seat play are evidenced by executing code;
|
||||||
|
the visualization is evidenced only as correctly emitted, because no
|
||||||
|
browser is available to the loop. Run `cb-play --serve 0`, open the
|
||||||
|
printed URL, and confirm the table reads and a drag works.*
|
||||||
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
|
2. **Physical 3D tabletop** — wgpu renderer, Rapier-backed physics, camera
|
||||||
and pointer controls, snap zones, asset importer.
|
and pointer controls, snap zones, asset importer.
|
||||||
3. **Networked sessions** — authoritative host, private projections,
|
3. **Networked sessions** — authoritative host, private projections,
|
||||||
|
|
|
||||||
|
|
@ -100,3 +100,6 @@
|
||||||
| task | CB-WP-0013-T02 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
| task | CB-WP-0013-T02 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
||||||
| task | CB-WP-0013-T03 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
| task | CB-WP-0013-T03 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
||||||
| task | CB-WP-0013-T04 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
| task | CB-WP-0013-T04 | done | — | workplans/CB-WP-0013-instrument-corrections.md |
|
||||||
|
| task | CB-WP-0014-T01 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |
|
||||||
|
| task | CB-WP-0014-T02 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |
|
||||||
|
| task | CB-WP-0014-T03 | done | — | workplans/CB-WP-0014-execute-the-javascript.md |
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,28 @@ edition.workspace = true
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
license-file.workspace = true
|
license-file.workspace = true
|
||||||
|
|
||||||
# ADR-0007 Decision 1: the browser is the renderer, so this crate has
|
[features]
|
||||||
# **no third-party dependencies at all** beyond what the game already
|
# ADR-0009: the JS harness is exposed to cb-play's tests behind a feature,
|
||||||
|
# so the engine stays a dev cost there too and never a shipped one.
|
||||||
|
js-harness = ["dep:quick-js"]
|
||||||
|
|
||||||
|
# ADR-0007 Decision 1: the browser is the renderer, so the rendering path
|
||||||
|
# has **no third-party dependencies at all** beyond what the game already
|
||||||
# carries. Adding one here needs an argument against ADR-0007 §Decision 3.
|
# carries. Adding one here needs an argument against ADR-0007 §Decision 3.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
cb-kernel.workspace = true
|
cb-kernel.workspace = true
|
||||||
games-ground.workspace = true
|
games-ground.workspace = true
|
||||||
|
# ADR-0009: an EMBEDDED engine, not `node` — CI runs on rust:1.97, which
|
||||||
[lints]
|
# has no node, so requiring one would make our build acquire a runtime
|
||||||
workspace = true
|
# nobody audits while it scored zero on the only instrument that governs
|
||||||
|
# dependencies. Optional, and never in the shipped-runtime configuration:
|
||||||
|
# AM-4a measures 157,202 against 161,000 and has no room for it.
|
||||||
|
quick-js = { version = "0.4", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# The coverage gate walks the serialized view; nothing else needs it.
|
# The coverage gate walks the serialized view; nothing else needs it.
|
||||||
serde_json.workspace = true
|
serde_json.workspace = true
|
||||||
|
quick-js = "0.4"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
|
||||||
290
crates/cb-render-html/src/jsrun.rs
Normal file
290
crates/cb-render-html/src/jsrun.rs
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
//! Execute the emitted JavaScript for real (ADR-0009).
|
||||||
|
//!
|
||||||
|
//! ADR-0007 control 5 says the page **may not construct commands**: it
|
||||||
|
//! reports raw pointer facts and Rust decides what they mean. Until now
|
||||||
|
//! that contract was held up by a test that greps the emitted script for
|
||||||
|
//! game vocabulary — and grepping for the absence of words is a weak
|
||||||
|
//! proxy for *"this code cannot construct a command"*.
|
||||||
|
//!
|
||||||
|
//! This runs it. QuickJS, a DOM stub with exactly the surface `SCRIPT`
|
||||||
|
//! touches, and a Rust callback standing in for `fetch` so the test can
|
||||||
|
//! see precisely what would have gone on the wire.
|
||||||
|
//!
|
||||||
|
//! **The scripts are lifted from a real emitted document, not pasted
|
||||||
|
//! here.** Both `<script>` blocks are extracted and evaluated in order, so
|
||||||
|
//! the endpoint the JavaScript posts to is the one the page actually
|
||||||
|
//! carried — token included. A harness that supplied its own endpoint
|
||||||
|
//! would pass while the page shipped a broken one.
|
||||||
|
//!
|
||||||
|
//! What this does **not** prove: that the SVG renders legibly, that a drag
|
||||||
|
//! feels like a drag, or that anyone can play a game. QuickJS has no
|
||||||
|
//! layout engine (ADR-0009 §What is bought).
|
||||||
|
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
/// One intercepted `fetch`, exactly as the page would have sent it.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Posted {
|
||||||
|
pub url: String,
|
||||||
|
pub body: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull every `<script>…</script>` body out of a document, in order.
|
||||||
|
pub fn scripts(html: &str) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut rest = html;
|
||||||
|
while let Some(i) = rest.find("<script>") {
|
||||||
|
let after = &rest[i + "<script>".len()..];
|
||||||
|
match after.find("</script>") {
|
||||||
|
Some(j) => {
|
||||||
|
out.push(after[..j].to_string());
|
||||||
|
rest = &after[j..];
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DOM surface `SCRIPT` touches, and nothing more.
|
||||||
|
///
|
||||||
|
/// Deliberately minimal: every additional stubbed API is a way for the
|
||||||
|
/// script to do something in the test that it could not do in a browser,
|
||||||
|
/// or vice versa. If `SCRIPT` ever needs more than this, that is a signal
|
||||||
|
/// about the script, not about the stub.
|
||||||
|
const DOM: &str = r#"
|
||||||
|
var __handlers = {};
|
||||||
|
var __status = { textContent: "" };
|
||||||
|
var document = {
|
||||||
|
addEventListener: function (type, fn) { __handlers[type] = fn; },
|
||||||
|
getElementById: function (id) { return __status; }
|
||||||
|
};
|
||||||
|
var window = { location: { reload: function () { __reloaded(); } } };
|
||||||
|
function fetch(url, opts) {
|
||||||
|
__post(url, (opts && opts.body) || "");
|
||||||
|
// The script chains .then(...).then(...); give it something to chain on
|
||||||
|
// without ever resolving, so response handling stays out of scope here.
|
||||||
|
var chainable = { then: function () { return chainable; } };
|
||||||
|
return chainable;
|
||||||
|
}
|
||||||
|
function __down(id) { __handlers['pointerdown']({ target: { id: id } }); }
|
||||||
|
function __up(id) { __handlers['pointerup']({ target: { id: id } }); }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
/// Run the document's scripts, then a pointer gesture, and report what
|
||||||
|
/// the page tried to send.
|
||||||
|
///
|
||||||
|
/// `gesture` is `(down_id, up_id)`. Errors are JS errors, and they are
|
||||||
|
/// returned rather than swallowed: a script that throws must not look
|
||||||
|
/// like a script that sent nothing.
|
||||||
|
pub fn gesture(html: &str, down: &str, up: &str) -> Result<Vec<Posted>, String> {
|
||||||
|
let ctx = quick_js::Context::new().map_err(|e| format!("quickjs init: {e}"))?;
|
||||||
|
|
||||||
|
// `Arc<Mutex<_>>` rather than `Rc<RefCell<_>>`: quick-js requires the
|
||||||
|
// callback to be unwind-safe, since a panic inside it would cross the
|
||||||
|
// C boundary.
|
||||||
|
let posted: Arc<Mutex<Vec<Posted>>> = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let sink = posted.clone();
|
||||||
|
ctx.add_callback("__post", move |url: String, body: String| {
|
||||||
|
sink.lock().expect("posted").push(Posted { url, body });
|
||||||
|
0i32
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("register __post: {e}"))?;
|
||||||
|
ctx.add_callback("__reloaded", || 0i32)
|
||||||
|
.map_err(|e| format!("register __reloaded: {e}"))?;
|
||||||
|
|
||||||
|
ctx.eval(DOM).map_err(|e| format!("dom stub: {e}"))?;
|
||||||
|
|
||||||
|
let found = scripts(html);
|
||||||
|
if found.is_empty() {
|
||||||
|
return Err("the document carries no <script> block".to_string());
|
||||||
|
}
|
||||||
|
for (i, s) in found.iter().enumerate() {
|
||||||
|
ctx.eval(s)
|
||||||
|
.map_err(|e| format!("script {i} of {}: {e}", found.len()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the page never registered handlers, the gesture below would be a
|
||||||
|
// no-op and the test would read as "sent nothing" rather than "the
|
||||||
|
// page is broken". Distinguish the two.
|
||||||
|
let armed: bool = ctx
|
||||||
|
.eval_as("!!(__handlers['pointerdown'] && __handlers['pointerup'])")
|
||||||
|
.map_err(|e| format!("handler check: {e}"))?;
|
||||||
|
if !armed {
|
||||||
|
return Err("the page registered no pointer handlers".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.eval(&format!(
|
||||||
|
"__down({}); __up({});",
|
||||||
|
json_lit(down),
|
||||||
|
json_lit(up)
|
||||||
|
))
|
||||||
|
.map_err(|e| format!("gesture: {e}"))?;
|
||||||
|
|
||||||
|
let out = posted.lock().expect("posted").clone();
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_lit(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for c in s.chars() {
|
||||||
|
match c {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use cb_kernel::PlayerId;
|
||||||
|
use games_ground::{Action, GroundCommand};
|
||||||
|
|
||||||
|
fn page() -> String {
|
||||||
|
let legal = vec![
|
||||||
|
GroundCommand::SelectAction {
|
||||||
|
action: Action::Ground,
|
||||||
|
target: None,
|
||||||
|
problem: None,
|
||||||
|
},
|
||||||
|
GroundCommand::SelectAction {
|
||||||
|
action: Action::Attack,
|
||||||
|
target: Some(PlayerId(1)),
|
||||||
|
problem: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
crate::doc::document(
|
||||||
|
&crate::testfix::view(Some(PlayerId(0))),
|
||||||
|
&legal,
|
||||||
|
"/command?t=deadbeef",
|
||||||
|
Some(PlayerId(0)),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **The one this pass exists for.** The emitted JavaScript runs, and
|
||||||
|
/// what it puts on the wire is two element ids and nothing else.
|
||||||
|
#[test]
|
||||||
|
fn the_emitted_javascript_reports_element_ids_and_nothing_else() {
|
||||||
|
let posts = gesture(&page(), "action-attack", "seat-1").expect("the script runs");
|
||||||
|
assert_eq!(posts.len(), 1, "expected exactly one fetch, got {posts:?}");
|
||||||
|
let p = &posts[0];
|
||||||
|
|
||||||
|
// The endpoint comes from the page, token and all — not from the
|
||||||
|
// harness. A page that shipped a broken endpoint fails here.
|
||||||
|
assert_eq!(p.url, "/command?t=deadbeef", "the page's own endpoint");
|
||||||
|
assert_eq!(p.body, "down=action-attack&up=seat-1");
|
||||||
|
|
||||||
|
// Control 5, asserted rather than grepped.
|
||||||
|
//
|
||||||
|
// The naive check — "the body must not contain 'attack'" — is
|
||||||
|
// WRONG, and writing it was instructive: the body legitimately
|
||||||
|
// contains `action-attack`, because that is the id of an element
|
||||||
|
// a finger landed on. An element may name an action; that is not
|
||||||
|
// the page deciding anything.
|
||||||
|
//
|
||||||
|
// What actually distinguishes reporting from deciding is the
|
||||||
|
// *shape*: exactly two fields, named `down` and `up`, whose values
|
||||||
|
// are the two ids and nothing derived from them.
|
||||||
|
let fields: Vec<&str> = p
|
||||||
|
.body
|
||||||
|
.split('&')
|
||||||
|
.map(|kv| kv.split('=').next().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
fields,
|
||||||
|
vec!["down", "up"],
|
||||||
|
"the page sent fields beyond the gesture"
|
||||||
|
);
|
||||||
|
for marker in ["SelectAction", "{", "}", "\"", "kind", "target", "problem"] {
|
||||||
|
assert!(
|
||||||
|
!p.body.contains(marker),
|
||||||
|
"the body carries {marker:?} — the page is constructing a command: {}",
|
||||||
|
p.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And the ids it reports resolve, in Rust, to the command the
|
||||||
|
/// aggregate offered — closing the loop the grep test could not.
|
||||||
|
#[test]
|
||||||
|
fn what_the_page_sends_resolves_to_a_legal_command() {
|
||||||
|
let legal = vec![
|
||||||
|
GroundCommand::SelectAction {
|
||||||
|
action: Action::Ground,
|
||||||
|
target: None,
|
||||||
|
problem: None,
|
||||||
|
},
|
||||||
|
GroundCommand::SelectAction {
|
||||||
|
action: Action::Attack,
|
||||||
|
target: Some(PlayerId(1)),
|
||||||
|
problem: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let posts = gesture(&page(), "action-attack", "seat-1").expect("runs");
|
||||||
|
let fact = crate::PointerFact::parse(&posts[0].body).expect("a pointer fact");
|
||||||
|
assert_eq!(crate::resolve(&fact, &legal, PlayerId(0)), Ok(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gesture that means nothing is still *reported* faithfully — the
|
||||||
|
/// page does not filter, because filtering would be deciding.
|
||||||
|
#[test]
|
||||||
|
fn the_page_reports_a_meaningless_gesture_rather_than_suppressing_it() {
|
||||||
|
let posts = gesture(&page(), "seat-1", "seat-2").expect("runs");
|
||||||
|
assert_eq!(posts.len(), 1);
|
||||||
|
assert_eq!(posts[0].body, "down=seat-1&up=seat-2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// EXPECT-VACUOUS: a harness that ran the script but delivered no
|
||||||
|
/// events would report "sent nothing" and read as a pass. Prove the
|
||||||
|
/// gesture is what causes the send.
|
||||||
|
#[test]
|
||||||
|
fn without_a_gesture_the_page_sends_nothing() {
|
||||||
|
let html = page();
|
||||||
|
let ctx = quick_js::Context::new().expect("ctx");
|
||||||
|
let seen = std::sync::Arc::new(std::sync::Mutex::new(0usize));
|
||||||
|
let sink = seen.clone();
|
||||||
|
ctx.add_callback("__post", move |_u: String, _b: String| {
|
||||||
|
*sink.lock().expect("seen") += 1;
|
||||||
|
0i32
|
||||||
|
})
|
||||||
|
.expect("cb");
|
||||||
|
ctx.add_callback("__reloaded", || 0i32).expect("cb");
|
||||||
|
ctx.eval(DOM).expect("dom");
|
||||||
|
for s in scripts(&html) {
|
||||||
|
ctx.eval(&s).expect("script");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
*seen.lock().expect("seen"),
|
||||||
|
0,
|
||||||
|
"the page sent something unprompted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The harness must not report success when the page is broken.
|
||||||
|
#[test]
|
||||||
|
fn a_page_with_no_script_or_no_handlers_is_an_error_not_a_silence() {
|
||||||
|
assert!(gesture("<p>no script here</p>", "a", "b").is_err());
|
||||||
|
// A script that registers nothing must not look like one that
|
||||||
|
// registered handlers and chose to send nothing.
|
||||||
|
let e = gesture("<script>var x = 1;</script>", "a", "b").unwrap_err();
|
||||||
|
assert!(e.contains("no pointer handlers"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn both_script_blocks_are_extracted_in_order() {
|
||||||
|
let found = scripts(&page());
|
||||||
|
assert_eq!(
|
||||||
|
found.len(),
|
||||||
|
2,
|
||||||
|
"the page carries the endpoint and the script"
|
||||||
|
);
|
||||||
|
assert!(found[0].contains("CB_ENDPOINT"), "{}", found[0]);
|
||||||
|
assert!(found[1].contains("pointerdown"), "{}", found[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -43,6 +43,8 @@
|
||||||
|
|
||||||
pub mod doc;
|
pub mod doc;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
#[cfg(any(test, feature = "js-harness"))]
|
||||||
|
pub mod jsrun;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
|
|
||||||
pub use doc::{document, text_of};
|
pub use doc::{document, text_of};
|
||||||
|
|
|
||||||
121
decisions/ADR-0009-embed-the-js-engine.md
Normal file
121
decisions/ADR-0009-embed-the-js-engine.md
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
# ADR-0009: embed the JS engine; do not put `node` in the toolchain
|
||||||
|
|
||||||
|
status: accepted
|
||||||
|
date: 2026-08-02
|
||||||
|
decided by: agent, under the standing loop authorization
|
||||||
|
tier: M (structural M — adds an external dependency to the toolchain,
|
||||||
|
InnerLoop v1.6; chaos d4=2 → no override). Tier M merges survey and
|
||||||
|
decision into one document, which this is.
|
||||||
|
references: [CB-WP-0014](../workplans/CB-WP-0014-execute-the-javascript.md),
|
||||||
|
[ADR-0007](ADR-0007-render-html-not-a-port.md) D3 (the acquisition rule)
|
||||||
|
and D5 (control 5),
|
||||||
|
[ADR-0008](ADR-0008-instrument-corrections.md) D2 (AM-4b left uncorrected),
|
||||||
|
[CB-EV-0010](../evidence/CB-EV-0010-render-port.md) §4
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0007 control 5 says the emitted page **may not construct commands** —
|
||||||
|
it reports raw pointer facts and Rust decides what they mean. That
|
||||||
|
contract is currently held up by a test that greps the emitted script for
|
||||||
|
game vocabulary. **Grepping for the absence of words is a weak proxy for
|
||||||
|
"this code cannot construct a command."**
|
||||||
|
|
||||||
|
And the script has never been executed at all, which is the sole remaining
|
||||||
|
reason INTENT stage 1 is open.
|
||||||
|
|
||||||
|
## The survey
|
||||||
|
|
||||||
|
`node` v24.11.1 is on this machine, so the obvious move is to shell out to
|
||||||
|
it. Measured alternatives, same method as `dep-weight.py`, marginal against
|
||||||
|
the 29-crate dev-toolchain graph, under the positive control:
|
||||||
|
|
||||||
|
| option | marginal Rust lines | notes |
|
||||||
|
|---|---:|---|
|
||||||
|
| `boa_engine` | 896,410 | 27× AM-4b's headroom |
|
||||||
|
| `rquickjs` | 69,985 | 2.1× headroom |
|
||||||
|
| **`quick-js`** | **11,434** | bindings + vendored QuickJS C |
|
||||||
|
| `node`, shelled out | **0** | and that zero is the problem |
|
||||||
|
|
||||||
|
AM-4b headroom is **32,979** (317,021 of 350,000), so `quick-js` fits at
|
||||||
|
35% of it and `rquickjs` does not.
|
||||||
|
|
||||||
|
## Decision — embed `quick-js`; `node` is refused
|
||||||
|
|
||||||
|
**This is ADR-0007 Decision 3's acquisition rule biting its author, which
|
||||||
|
is the only real test of whether it was written honestly.** The rule:
|
||||||
|
|
||||||
|
> AM-4 counts third-party code the project causes to be **acquired**. It
|
||||||
|
> does not count runtimes the user already has independently of us. It
|
||||||
|
> **does** count a library our build or install instructions cause to be
|
||||||
|
> fetched, pinned, or linked, whether or not its source is Rust.
|
||||||
|
|
||||||
|
A browser is not counted because a developer has one regardless of us. But
|
||||||
|
**CI runs on `rust:1.97`, which has no `node`** — so adding this test
|
||||||
|
would make our CI fetch a JavaScript runtime. That is our build causing an
|
||||||
|
acquisition, of tens of millions of lines nobody here will audit, scoring
|
||||||
|
**zero** on the only instrument that governs dependencies.
|
||||||
|
|
||||||
|
Taking `node` would mean using the rule to exempt a browser we do not
|
||||||
|
install while also exempting a runtime we do. `quick-js` costs 11,434
|
||||||
|
lines that are vendored, pinned, auditable, and counted.
|
||||||
|
|
||||||
|
The secondary reasons matter less but all point the same way: the test
|
||||||
|
runs anywhere `cargo test` runs, needs no CI change, cannot skip because a
|
||||||
|
binary is missing, and pins one engine version rather than whatever the
|
||||||
|
image happens to ship.
|
||||||
|
|
||||||
|
**`python3` is not a precedent for `node`.** It is already a toolchain
|
||||||
|
dependency, and the honest reading is that it was never argued — it
|
||||||
|
predates the acquisition rule. Leaning on it would be using an unexamined
|
||||||
|
decision to license a second one. It is left alone here and noted as
|
||||||
|
owed.
|
||||||
|
|
||||||
|
### What is bought, and what is not
|
||||||
|
|
||||||
|
Executing the script proves the **input contract**: what the page puts on
|
||||||
|
the wire in response to a pointer gesture. It does **not** prove the SVG
|
||||||
|
renders legibly, that a drag feels like a drag, or that anyone can play a
|
||||||
|
game. QuickJS has no layout engine and this ADR claims no rendering
|
||||||
|
evidence.
|
||||||
|
|
||||||
|
### Cost accepted — and a correction, measured after the fact
|
||||||
|
|
||||||
|
The paragraph originally here read *"35% of AM-4b's remaining headroom,
|
||||||
|
for a test."* **That was wrong, and finding out how wrong is the more
|
||||||
|
important result of this pass.**
|
||||||
|
|
||||||
|
After landing, `make dep-weight` reported AM-4b **unchanged at 317,021**.
|
||||||
|
`quick-js` is a dev-dependency of `cb-render-html`, and AM-4b measures
|
||||||
|
`cargo tree -p games-ground --edges normal` — one package, no dev edges.
|
||||||
|
It cannot see it. Measured:
|
||||||
|
|
||||||
|
| | crates | lines |
|
||||||
|
|---|---:|---:|
|
||||||
|
| AM-4b as instrumented (`games-ground`, normal) | 29 | 317,021 |
|
||||||
|
| the whole workspace, including dev edges | 57 | 725,258 |
|
||||||
|
| **uncounted by AM-4b** | **28** | **408,237** |
|
||||||
|
|
||||||
|
**The dev-toolchain budget is blind to more source than its entire
|
||||||
|
target** — `criterion`, `clap`, `ciborium`, and now `quick-js`.
|
||||||
|
|
||||||
|
This is the same defect this ADR refuses `node` for: a real acquisition
|
||||||
|
scoring zero because the instrument does not look there. The difference is
|
||||||
|
that `quick-js` is *auditable and pinned* and `node` is neither, so the
|
||||||
|
decision stands. But it stands on the acquisition rule, **not** on an
|
||||||
|
affordability argument, because there is no affordability argument to be
|
||||||
|
had until AM-4b can see what it is buying.
|
||||||
|
|
||||||
|
Recorded as owed, and it is now the third defect in the AM-4 family: the
|
||||||
|
shipped-runtime proc-macro count (fixed, ADR-0008 D2), AM-4b's own
|
||||||
|
proc-macro share (owed), and AM-4b's scope (this).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- `quick-js` is a **dev-dependency of `cb-render-html` only**. It must
|
||||||
|
never reach the shipped-runtime configuration; AM-4a would catch that,
|
||||||
|
and now measures 157,202 against 161,000 with no room for it.
|
||||||
|
- The grep test stays. It is cheap, and it fails faster and more legibly
|
||||||
|
than an execution test when someone adds a word to the script.
|
||||||
|
- If `quick-js` becomes unmaintained, the fallback is not `node` — it is
|
||||||
|
`rquickjs` plus an AM-4b decision, or dropping the execution test and
|
||||||
|
saying so.
|
||||||
150
evidence/CB-EV-0012-execute-the-javascript.md
Normal file
150
evidence/CB-EV-0012-execute-the-javascript.md
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
# CB-EV-0012 — the loop is closed, and the budget is blind
|
||||||
|
|
||||||
|
CB-WP-0014 T03. Measured 2026-08-02 at `55212d7`+. Pass kind `product`,
|
||||||
|
tier **M** (chaos d4=2, no override). Declaration 9 of 12.
|
||||||
|
|
||||||
|
**Applying CB-EV-0011 §4's proposed rule, and hereby adopting it:** the
|
||||||
|
cost table below quotes the **previous** pass's final figure and marks
|
||||||
|
this pass's own as provisional. See §5.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. What was actually blocking stage 1, and what it cost to find out
|
||||||
|
|
||||||
|
CB-EV-0010 §4 said the emitted JavaScript had never run. That sentence was
|
||||||
|
carried for a pass on an assumption nobody checked. **`node` v24.11.1 was
|
||||||
|
on the machine the whole time.**
|
||||||
|
|
||||||
|
That is the fourth consecutive pass whose central finding was a claim
|
||||||
|
carried because nobody ran the command that settles it — after the 3,750
|
||||||
|
headroom figure, the "empty" sub-100k dependency region, and SH-3's 0.0%.
|
||||||
|
The pattern is stable enough now to name: **this project's characteristic
|
||||||
|
error is not faulty reasoning, it is unexecuted verification.** Every one
|
||||||
|
was cheap to check and none had been checked.
|
||||||
|
|
||||||
|
## 2. The loop, closed
|
||||||
|
|
||||||
|
The real server serves the real page → QuickJS runs *that page's own two
|
||||||
|
`<script>` blocks* → the gesture goes over a real socket → the seat's
|
||||||
|
`Choice` comes back.
|
||||||
|
|
||||||
|
Before this, every link was tested and the chain was not. The page was
|
||||||
|
asserted against as a parsed document; the socket was driven by synthetic
|
||||||
|
HTTP this test suite wrote itself. **A page whose JavaScript sent
|
||||||
|
something else entirely would have passed everything.**
|
||||||
|
|
||||||
|
Control 5 — *the page may not construct commands* — was held up by a test
|
||||||
|
that grepped the emitted script for game vocabulary. It is now asserted on
|
||||||
|
what the script actually puts on the wire, and the mutation that makes the
|
||||||
|
JS post `command=SelectAction&target=…` turns it red.
|
||||||
|
|
||||||
|
**A wrong assertion worth keeping.** The first draft required the body not
|
||||||
|
to contain `"attack"`. It legitimately does — the body is
|
||||||
|
`down=action-attack&up=seat-1`, and `action-attack` is *the id of an
|
||||||
|
element a finger landed on*. An element may name an action; that is not
|
||||||
|
the page deciding anything. What distinguishes reporting from deciding is
|
||||||
|
the **shape**: exactly two fields, `down` and `up`, carrying two ids and
|
||||||
|
nothing derived from them.
|
||||||
|
|
||||||
|
## 3. AM-4b is blind to more source than its own target
|
||||||
|
|
||||||
|
ADR-0009's first draft priced `quick-js` at *"35% of AM-4b's remaining
|
||||||
|
headroom."* After it landed, `make dep-weight` reported AM-4b
|
||||||
|
**unchanged**.
|
||||||
|
|
||||||
|
| | crates | lines |
|
||||||
|
|---|---:|---:|
|
||||||
|
| AM-4b as instrumented (`games-ground`, `--edges normal`) | 29 | 317,021 |
|
||||||
|
| the whole workspace, including dev edges | 57 | 725,258 |
|
||||||
|
| **uncounted** | **28** | **408,237** |
|
||||||
|
|
||||||
|
AM-4b measures one package and no dev edges. `criterion`, `clap`,
|
||||||
|
`ciborium` and now `quick-js` are invisible to it.
|
||||||
|
|
||||||
|
**This is the same defect the ADR refuses `node` for** — a real
|
||||||
|
acquisition scoring zero because the instrument does not look there. The
|
||||||
|
decision still stands, because `quick-js` is pinned and auditable and
|
||||||
|
`node` is neither, but it stands on the **acquisition rule alone**. The
|
||||||
|
affordability argument is withdrawn: there is none to be had until AM-4b
|
||||||
|
can see what it is buying.
|
||||||
|
|
||||||
|
Third defect in the AM-4 family, and the second found by a pass that was
|
||||||
|
*using* the instrument rather than auditing it:
|
||||||
|
|
||||||
|
| defect | status |
|
||||||
|
|---|---|
|
||||||
|
| shipped-runtime counted proc-macro crates (89,048 lines) | fixed, ADR-0008 D2 |
|
||||||
|
| AM-4b's own proc-macro share | unmeasured, owed |
|
||||||
|
| **AM-4b's scope: one package, no dev edges (408,237 lines)** | **found here, owed** |
|
||||||
|
|
||||||
|
## 4. INTENT stage 1, deliverable by deliverable
|
||||||
|
|
||||||
|
| deliverable | status |
|
||||||
|
|---|---|
|
||||||
|
| card/token/hand/**relationship-graph** visualization | **emitted and gated** — 41 of 42 leaf paths asserted present in the *parsed* document. **Never seen.** |
|
||||||
|
| drag-to-propose | **evidenced end to end** — JS gesture → socket → `Choice` |
|
||||||
|
| debug inspector | **evidenced** (CB-WP-0011) — walks bundles and scenarios, hash-verified |
|
||||||
|
| hot-seat play | **evidenced** — two seats, one listener, the projection follows the seat |
|
||||||
|
|
||||||
|
The last one was the closest to being claimed on the strength of the code
|
||||||
|
path existing. `SeatPolicy` hands every human seat a handle on one shared
|
||||||
|
`Server`, so turn-taking "obviously" worked — and nothing drove more than
|
||||||
|
one seat until this pass. The property that matters is not that two turns
|
||||||
|
happen but that **the same tab, asked twice, shows two different hands**;
|
||||||
|
mutating the projection to serve P1's view to every seat turns it red.
|
||||||
|
|
||||||
|
### The stage stays open, on one named blocker
|
||||||
|
|
||||||
|
Three of four deliverables are evidenced by executing code. The first is
|
||||||
|
evidenced only as *correctly emitted*: QuickJS has no layout engine, no
|
||||||
|
browser is available here, and no test in this repo can establish that an
|
||||||
|
SVG table is legible or that a drag feels like a drag.
|
||||||
|
|
||||||
|
**Everything testable from here has been tested.** What remains is one
|
||||||
|
human action: run `cb-play --serve 0`, open the printed URL, and confirm
|
||||||
|
the table reads and a drag works. That is the whole of the remaining
|
||||||
|
blocker, it is the maintainer's to do, and it is named here so the stage
|
||||||
|
is no longer carried on a vague reservation.
|
||||||
|
|
||||||
|
Closing it unilaterally would be the failure mode stage 0 avoided.
|
||||||
|
Carrying it *vaguely* was the failure mode this pass found.
|
||||||
|
|
||||||
|
## 5. Cost, shape, and the rule now adopted
|
||||||
|
|
||||||
|
| pass | kind | responses | cost | $/response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CB-WP-0012 | product | 97 | $14.52 | 0.150 |
|
||||||
|
| **CB-WP-0013** (final) | meta | 47 | **$8.26** | **0.176** |
|
||||||
|
| CB-WP-0014 | product | *provisional — see below* | | |
|
||||||
|
|
||||||
|
**The rule is adopted.** CB-EV-0011 proposed that an evidence file quote
|
||||||
|
the previous pass's final cost and never its own. CB-WP-0013 reported
|
||||||
|
itself at $5.78/34 responses mid-flight; final is **$8.26/47** — under by
|
||||||
|
43%. Four for four, always low. This file quotes CB-WP-0013 and declines
|
||||||
|
to quote itself, and future evidence files should do the same.
|
||||||
|
|
||||||
|
**Meta budget: 29% `[OVER]`** the soft 25%, driven by CB-WP-0013's $8.26
|
||||||
|
sitting in a trailing three with two cheap product passes. Reported under
|
||||||
|
the rule that requires reporting it; no product work was displaced, and
|
||||||
|
CB-WP-0013 was an instrument repair, which ADR-0006 D2 exempts anyway.
|
||||||
|
|
||||||
|
**SH-1 is at 347,720 `[HARD]`** against a 300,000 ceiling. The remedy is a
|
||||||
|
compaction and it is free, and it is not something this session can do for
|
||||||
|
itself. **CB-EV-0009's standing prediction is now live and testable**: if
|
||||||
|
the next pass opens above the SH-1 hard line, it should cost more per
|
||||||
|
response than 0.123. It has been unfalsified for three passes because
|
||||||
|
every one of them opened below the line.
|
||||||
|
|
||||||
|
## 6. Open
|
||||||
|
|
||||||
|
- **AM-4b's scope defect (408,237 uncounted lines)** and its unmeasured
|
||||||
|
proc-macro share. §3.
|
||||||
|
- **`python3` as a toolchain dependency was never argued.** ADR-0009
|
||||||
|
declined to lean on it as precedent and left it owed.
|
||||||
|
- **INTENT stage 1: one human verification.** §4.
|
||||||
|
- **AM-4a still cannot survive stage 2** — 1,741,979 against 161,000,
|
||||||
|
10.8×. Reserved for the maintainer since ADR-0007.
|
||||||
|
- **ADR-0007 D3's acquisition rule** remains unratified — and it has now
|
||||||
|
decided two dependency questions, which raises rather than lowers the
|
||||||
|
cost of leaving it unratified.
|
||||||
|
- **Chaos: 9 of 12 declarations, 1 override.**
|
||||||
|
|
@ -14,5 +14,10 @@ serde_json.workspace = true
|
||||||
# Scenario args are YAML values; the prompt renders them (T02).
|
# Scenario args are YAML values; the prompt renders them (T02).
|
||||||
serde_yaml.workspace = true
|
serde_yaml.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# ADR-0009: the real page, driven by a real JS engine, into the real
|
||||||
|
# socket. Dev-only — cb-play ships without it.
|
||||||
|
cb-render-html = { workspace = true, features = ["js-harness"] }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
|
||||||
|
|
@ -370,4 +370,126 @@ mod tests {
|
||||||
);
|
);
|
||||||
assert!(replies[1].contains("200 OK"));
|
assert!(replies[1].contains("200 OK"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// **The loop, closed.** The real server serves the real page; a real
|
||||||
|
/// JavaScript engine runs the page's own script and produces a pointer
|
||||||
|
/// gesture; what it produces goes over a real socket; and the seat's
|
||||||
|
/// choice comes back.
|
||||||
|
///
|
||||||
|
/// Before ADR-0009 every link in that chain was tested and the chain
|
||||||
|
/// was not. The page was asserted against as a parsed document and the
|
||||||
|
/// socket was driven by synthetic HTTP that this test suite wrote
|
||||||
|
/// itself — so a page whose JavaScript sent something else entirely
|
||||||
|
/// would have passed everything.
|
||||||
|
#[test]
|
||||||
|
fn a_gesture_in_javascript_becomes_a_move_in_the_game() {
|
||||||
|
let server = Server::bind(0).expect("bind");
|
||||||
|
let port = server.listener.local_addr().unwrap().port();
|
||||||
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
||||||
|
|
||||||
|
let tok = token.clone();
|
||||||
|
let client = std::thread::spawn(move || {
|
||||||
|
let token = tok;
|
||||||
|
// 1. fetch the page the server actually serves
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||||
|
s.write_all(get(&token).as_bytes()).expect("write");
|
||||||
|
let mut page = String::new();
|
||||||
|
let _ = s.read_to_string(&mut page);
|
||||||
|
assert!(page.contains("200 OK"), "{page}");
|
||||||
|
|
||||||
|
// 2. run ITS script, in a real engine, with a real gesture
|
||||||
|
let posts = cb_render_html::jsrun::gesture(&page, "action-ground", "table")
|
||||||
|
.expect("the served page's script runs");
|
||||||
|
assert_eq!(posts.len(), 1, "{posts:?}");
|
||||||
|
|
||||||
|
// 3. send exactly what the JavaScript produced — not what this
|
||||||
|
// test thinks it should have produced
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||||
|
s.write_all(post(&token, &posts[0].body).as_bytes())
|
||||||
|
.expect("write");
|
||||||
|
let mut reply = String::new();
|
||||||
|
let _ = s.read_to_string(&mut reply);
|
||||||
|
(posts[0].clone(), reply)
|
||||||
|
});
|
||||||
|
|
||||||
|
let choice = server
|
||||||
|
.next_choice(&state(), PlayerId(0), &ground_only(), false)
|
||||||
|
.expect("a choice");
|
||||||
|
assert_eq!(choice, Choice::Command(0));
|
||||||
|
|
||||||
|
let (posted, reply) = client.join().expect("client thread");
|
||||||
|
assert_eq!(posted.body, "down=action-ground&up=table");
|
||||||
|
// The endpoint the JS used carries the server's own token, which
|
||||||
|
// the page cannot mint — so this also proves the token round-trips
|
||||||
|
// through the emitted document.
|
||||||
|
assert!(posted.url.contains(&token), "{}", posted.url);
|
||||||
|
assert!(reply.contains("200 OK"), "{reply}");
|
||||||
|
assert!(server.refusals().is_empty(), "{:?}", server.refusals());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Hot-seat**: two seats, one browser, one listener — and each seat
|
||||||
|
/// is shown its own hand and nobody else's.
|
||||||
|
///
|
||||||
|
/// This is the deliverable that was closest to being claimed on the
|
||||||
|
/// strength of the code path existing. `SeatPolicy` gives every human
|
||||||
|
/// seat a handle on one shared `Server`, so turn-taking "obviously"
|
||||||
|
/// works; nothing drove more than one seat until this test.
|
||||||
|
///
|
||||||
|
/// The property that matters is not that two turns happen. It is that
|
||||||
|
/// the *projection follows the seat* — the same tab, asked twice,
|
||||||
|
/// must show two different hands.
|
||||||
|
#[test]
|
||||||
|
fn two_seats_take_turns_through_one_listener_and_see_different_hands() {
|
||||||
|
let server = Server::bind(0).expect("bind");
|
||||||
|
let port = server.listener.local_addr().unwrap().port();
|
||||||
|
let token = server.url().rsplit("t=").next().unwrap().to_string();
|
||||||
|
|
||||||
|
let tok = token.clone();
|
||||||
|
let client = std::thread::spawn(move || {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
for _ in 0..2 {
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||||
|
s.write_all(get(&tok).as_bytes()).expect("write");
|
||||||
|
let mut page = String::new();
|
||||||
|
let _ = s.read_to_string(&mut page);
|
||||||
|
pages.push(page);
|
||||||
|
|
||||||
|
let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||||
|
s.write_all(post(&tok, "down=action-ground&up=table").as_bytes())
|
||||||
|
.expect("write");
|
||||||
|
let mut reply = String::new();
|
||||||
|
let _ = s.read_to_string(&mut reply);
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = state();
|
||||||
|
for seat in [0u8, 1] {
|
||||||
|
let choice = server
|
||||||
|
.next_choice(&state, PlayerId(seat), &ground_only(), false)
|
||||||
|
.expect("a choice");
|
||||||
|
assert_eq!(choice, Choice::Command(0), "seat P{}", seat + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pages = client.join().expect("client thread");
|
||||||
|
assert_eq!(pages.len(), 2);
|
||||||
|
for (i, page) in pages.iter().enumerate() {
|
||||||
|
let text = cb_render_html::text_of(page);
|
||||||
|
assert!(
|
||||||
|
text.contains(&format!("viewing as P{}", i + 1)),
|
||||||
|
"turn {i} was not served to P{}",
|
||||||
|
i + 1
|
||||||
|
);
|
||||||
|
// K13: exactly one open hand per page, and it is this seat's.
|
||||||
|
assert_eq!(
|
||||||
|
text.matches("cards)").count(),
|
||||||
|
1,
|
||||||
|
"turn {i} showed {} open hands",
|
||||||
|
text.matches("cards)").count()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// And the two turns were genuinely different views, not the same
|
||||||
|
// page served twice — which is how this test would pass vacuously.
|
||||||
|
assert_ne!(pages[0], pages[1], "both turns served an identical page");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,14 +214,21 @@ def report():
|
||||||
print(f" branch {branch}"
|
print(f" branch {branch}"
|
||||||
+ (f" ({len(dirty)} uncommitted)" if dirty else " (clean)"))
|
+ (f" ({len(dirty)} uncommitted)" if dirty else " (clean)"))
|
||||||
|
|
||||||
|
# The list grows by one line per pass forever, and this report has a
|
||||||
|
# length limit it is meant to keep. Collapse the fully-closed ones to
|
||||||
|
# a single line rather than raising the limit — the precedent is
|
||||||
|
# LOOP-LINT's four loadability breaches, each fixed structurally.
|
||||||
print("\n workplans")
|
print("\n workplans")
|
||||||
|
closed = []
|
||||||
for wid, title, status, tasks, _kind in plans:
|
for wid, title, status, tasks, _kind in plans:
|
||||||
done = sum(1 for _, s, _ in tasks if s == "done")
|
done = sum(1 for _, s, _ in tasks if s == "done")
|
||||||
if status == "done" and done == len(tasks):
|
if status == "done" and done == len(tasks):
|
||||||
print(f" {wid} {status:<12} {done}/{len(tasks)}")
|
closed.append(wid.replace("CB-WP-", ""))
|
||||||
continue
|
continue
|
||||||
short = (title or "")[:44]
|
short = (title or "")[:44]
|
||||||
print(f" {wid} {status:<12} {done}/{len(tasks)} done {short}")
|
print(f" {wid} {status:<12} {done}/{len(tasks)} done {short}")
|
||||||
|
if closed:
|
||||||
|
print(f" {len(closed)} closed and complete: " + " ".join(closed))
|
||||||
|
|
||||||
nxt = next_task(plans)
|
nxt = next_task(plans)
|
||||||
if nxt:
|
if nxt:
|
||||||
|
|
|
||||||
203
workplans/CB-WP-0014-execute-the-javascript.md
Normal file
203
workplans/CB-WP-0014-execute-the-javascript.md
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
---
|
||||||
|
id: CB-WP-0014
|
||||||
|
kind: product
|
||||||
|
title: "Execute the JavaScript, and close or keep stage 1"
|
||||||
|
status: done
|
||||||
|
---
|
||||||
|
|
||||||
|
# Purpose
|
||||||
|
|
||||||
|
```
|
||||||
|
structural tier M (adds an external dependency to the toolchain —
|
||||||
|
InnerLoop v1.6 tier table)
|
||||||
|
chaos d4 = 2 → no override
|
||||||
|
declared tier M
|
||||||
|
```
|
||||||
|
|
||||||
|
Declaration 9 of 12. Tier M merges the survey and the ADR into one
|
||||||
|
document; the adversarial review is optional.
|
||||||
|
|
||||||
|
## The one thing blocking stage 1
|
||||||
|
|
||||||
|
CB-EV-0010 §4, stated plainly at the time:
|
||||||
|
|
||||||
|
> **The emitted JavaScript has never run.** Every test is on the Rust
|
||||||
|
> side: the socket loop is driven by synthetic HTTP, and the page is
|
||||||
|
> asserted against as a parsed document. No browser engine has executed
|
||||||
|
> `SCRIPT`.
|
||||||
|
|
||||||
|
All four of INTENT stage 1's named deliverables now exist. That sentence
|
||||||
|
is the only reason the stage is still open, and it has been carried
|
||||||
|
unexamined for one pass on the assumption that executing it was not
|
||||||
|
possible here.
|
||||||
|
|
||||||
|
**It is possible.** `node` v24.11.1 is on this machine. The assumption was
|
||||||
|
never checked, which makes it the same shape as every other finding in the
|
||||||
|
last three passes: a claim carried because nobody ran the one command that
|
||||||
|
would settle it.
|
||||||
|
|
||||||
|
## What executing it actually proves
|
||||||
|
|
||||||
|
Not that the table *looks* right — that needs a rendering engine and a
|
||||||
|
human eye, and neither is available here. What it proves is the contract
|
||||||
|
ADR-0007 control 5 rests on:
|
||||||
|
|
||||||
|
> the page reports **raw pointer facts and nothing else**, and Rust
|
||||||
|
> decides what command they mean.
|
||||||
|
|
||||||
|
That contract is currently asserted by a test that greps the emitted
|
||||||
|
script for game vocabulary. Grepping for the absence of words is a weak
|
||||||
|
proxy for "this code cannot construct a command". Running it, feeding it
|
||||||
|
synthetic pointer events, and observing exactly what it puts on the wire
|
||||||
|
is the real check.
|
||||||
|
|
||||||
|
## Task: decide whether a JS engine joins the toolchain
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0014-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Write `decisions/ADR-0009-*.md` (tier M: survey and decision in one).
|
||||||
|
|
||||||
|
**This is a real dependency decision, not a formality.** CI runs on the
|
||||||
|
`rust:1.97` image, which has no `node`. Adding a test that needs one means
|
||||||
|
either installing it in CI or letting the test skip — and **a test that
|
||||||
|
skips silently is the harness-does-nothing class this project has found
|
||||||
|
five times.** If it can skip, it must fail loudly instead.
|
||||||
|
|
||||||
|
Decide, and price it under ADR-0007 Decision 3's acquisition rule — the
|
||||||
|
rule this project adopted precisely so that "it's only a dev tool" is not
|
||||||
|
an automatic pass:
|
||||||
|
|
||||||
|
- `python3` is already a toolchain dependency, so this is the second, not
|
||||||
|
the first. Say whether that makes it cheaper or is merely a precedent
|
||||||
|
being leaned on.
|
||||||
|
- What the alternatives cost: no execution at all (status quo, and the
|
||||||
|
reason stage 1 is open), or a JS interpreter as a Rust crate (measure it
|
||||||
|
— do not estimate, and note that AM-4b now has an unmeasured proc-macro
|
||||||
|
share of its own).
|
||||||
|
- Whether the engine is required by `make all` or by a separate target.
|
||||||
|
|
||||||
|
**AM-4b is the budget this lands in**, and it is the instrument
|
||||||
|
CB-WP-0013 explicitly declined to correct. Do not use its uncorrected
|
||||||
|
headroom as an argument.
|
||||||
|
|
||||||
|
**Done 2026-08-02.**
|
||||||
|
[ADR-0009](../decisions/ADR-0009-embed-the-js-engine.md) — **embed
|
||||||
|
`quick-js`; `node` is refused.** Measured, marginal, under the control:
|
||||||
|
`boa_engine` 896,410 · `rquickjs` 69,985 · **`quick-js` 11,434** · `node`
|
||||||
|
**0**, and that zero is the problem.
|
||||||
|
|
||||||
|
This is ADR-0007 D3's acquisition rule biting its author. CI runs on
|
||||||
|
`rust:1.97`, which has no `node` — so the test would make *our build*
|
||||||
|
fetch a JS runtime of tens of millions of unaudited lines, scoring zero on
|
||||||
|
the only instrument that governs dependencies. A browser is exempt because
|
||||||
|
a developer has one regardless of us; a CI-installed runtime is not.
|
||||||
|
|
||||||
|
**And the cost argument in the first draft was wrong.** It claimed 35% of
|
||||||
|
AM-4b's headroom. After landing, AM-4b did not move at all — it measures
|
||||||
|
`games-ground --edges normal`, one package, no dev edges. Measured: the
|
||||||
|
workspace including dev edges is **725,258** lines against AM-4b's
|
||||||
|
**317,021**, so **408,237 lines are uncounted — more than the target
|
||||||
|
itself**. The decision stands on the acquisition rule; the affordability
|
||||||
|
argument is withdrawn, because there is none to be had until the
|
||||||
|
instrument can see what it is buying. Filed as the third AM-4 defect.
|
||||||
|
|
||||||
|
## Task: run it, end to end, through a real socket
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0014-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Execute `cb_render_html::doc::SCRIPT` in a real JS engine against a
|
||||||
|
minimal DOM, synthesize a pointer-down and pointer-up on two real element
|
||||||
|
ids from a real emitted document, and let its `fetch` reach the **actual
|
||||||
|
`Server`** — the same `next_choice` loop `cb-play --serve` runs.
|
||||||
|
|
||||||
|
Acceptance: the seat's `Choice` comes back from a pointer gesture that
|
||||||
|
originated in JavaScript. That is the first time anything in this project
|
||||||
|
has closed that loop.
|
||||||
|
|
||||||
|
**Controls, and they are the point.** The test must go red when:
|
||||||
|
|
||||||
|
- the JS is mutated to POST a command name instead of element ids — this
|
||||||
|
is **control 5 asserted rather than grepped**, and if it does not fire,
|
||||||
|
the grep test was the only thing holding the contract up;
|
||||||
|
- the pointer events are not delivered at all — the EXPECT-VACUOUS case, a
|
||||||
|
harness that runs a script and asserts nothing about what it did;
|
||||||
|
- the token is stripped from the endpoint the page was given.
|
||||||
|
|
||||||
|
**Done 2026-08-02.** `crates/cb-render-html/src/jsrun.rs` and
|
||||||
|
`hotseat::tests::a_gesture_in_javascript_becomes_a_move_in_the_game`.
|
||||||
|
|
||||||
|
**The loop is closed.** The real server serves the real page; QuickJS runs
|
||||||
|
*that page's own scripts*; the gesture it produces goes over a real socket;
|
||||||
|
the seat's `Choice` comes back. Both `<script>` blocks are lifted from the
|
||||||
|
served document, so the endpoint under test is the one the page actually
|
||||||
|
carried — token included.
|
||||||
|
|
||||||
|
Three controls, each red for its stated reason:
|
||||||
|
|
||||||
|
| mutation | result |
|
||||||
|
|---|---|
|
||||||
|
| the JS posts `command=SelectAction&target=…` | body assertion red — **control 5 asserted, not grepped** |
|
||||||
|
| the gesture is not delivered (EXPECT-VACUOUS) | `expected exactly one fetch, got []` |
|
||||||
|
| the token dropped from the endpoint | the end-to-end token assertion red |
|
||||||
|
|
||||||
|
**A wrong assertion, worth keeping.** The first draft asserted the body
|
||||||
|
must not contain `"attack"`. It legitimately does: the body is
|
||||||
|
`down=action-attack&up=seat-1`, and `action-attack` is *the id of an
|
||||||
|
element a finger landed on*. An element may name an action; that is not
|
||||||
|
the page deciding anything. The real test of reporting-versus-deciding is
|
||||||
|
the **shape** — exactly two fields, `down` and `up`, carrying two ids and
|
||||||
|
nothing derived from them. That is what it asserts now.
|
||||||
|
|
||||||
|
## Task: close stage 1, or say what still blocks it
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: CB-WP-0014-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
`evidence/CB-EV-0012-*.md`, and then a decision on INTENT.
|
||||||
|
|
||||||
|
**Be precise about what is and is not established.** A passing JS test
|
||||||
|
proves the input contract and the loop. It does **not** prove that an SVG
|
||||||
|
table renders legibly, that a drag feels like a drag, or that a human can
|
||||||
|
play a game — none of which any test here can reach.
|
||||||
|
|
||||||
|
State which of stage 1's four deliverables are evidenced and which rest on
|
||||||
|
inspection, then close the stage or name the remaining blocker. Carrying
|
||||||
|
it open by default is as unexamined as closing it by default was going to
|
||||||
|
be.
|
||||||
|
|
||||||
|
**Adopt or reject the self-quoting rule** proposed in CB-EV-0011 §4: an
|
||||||
|
evidence file quotes the previous pass's final cost and never its own.
|
||||||
|
This pass is the first that can apply it — CB-WP-0013's figure is now
|
||||||
|
final. Decide it rather than inheriting it.
|
||||||
|
|
||||||
|
**Done 2026-08-02.**
|
||||||
|
[CB-EV-0012](../evidence/CB-EV-0012-execute-the-javascript.md).
|
||||||
|
|
||||||
|
- **Stage 1 stays open on one named blocker**, not a vague reservation.
|
||||||
|
Three of four deliverables are evidenced by executing code; the
|
||||||
|
visualization is evidenced only as *correctly emitted*, because QuickJS
|
||||||
|
has no layout engine and no browser is available here. What remains is
|
||||||
|
one human action: `cb-play --serve 0`, open the URL, confirm the table
|
||||||
|
reads and a drag works.
|
||||||
|
- **Hot-seat was the deliverable closest to being claimed on the strength
|
||||||
|
of the code path existing.** Nothing drove more than one seat until this
|
||||||
|
pass. Now: two seats, one listener, and the projection follows the seat —
|
||||||
|
mutating it to serve P1's view to everyone turns it red.
|
||||||
|
- **The self-quoting rule is ADOPTED.** CB-WP-0013 reported itself at
|
||||||
|
$5.78/34 mid-flight; final $8.26/47, under by 43%. Four for four.
|
||||||
|
- **AM-4b is blind to 408,237 lines** — more than its own target. Found by
|
||||||
|
a pass that was using it, not auditing it.
|
||||||
|
- **SH-1 at 347,720 `[HARD]`.** CB-EV-0009's standing prediction is now
|
||||||
|
live: the next pass opened above that line should cost more than 0.123
|
||||||
|
per response.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue