291 lines
11 KiB
Rust
291 lines
11 KiB
Rust
|
|
//! 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]);
|
||
|
|
}
|
||
|
|
}
|