plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


2a4119c — Peter John 2026-11-02T15:01:16+05:30
Add plum-game/web-test: playwright-rs browser check for web game builds

Generated files not shown: plum-game/web-test/Cargo.lock

plum-game/web-test/Cargo.toml ADDED
@@ -0,0 +1,19 @@
1
+ [package]
2
+ name = "plum-web-test"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [workspace]
7
+
8
+ [lib]
9
+ name = "plum_web_test"
10
+ path = "src/lib.rs"
11
+
12
+ [[bin]]
13
+ name = "plum-web-test"
14
+ path = "src/main.rs"
15
+
16
+ [dependencies]
17
+ playwright-rs = "0.20.1"
18
+ tokio = { version = "1", features = ["full"] }
19
+ image = { version = "0.25", default-features = false, features = ["png"] }
plum-game/web-test/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # plum-web-test
2
+
3
+ Browser check for `plum game --web` builds, driven by [playwright-rs](https://crates.io/crates/playwright-rs) 0.20.
4
+ Standalone crate (own `[workspace]`, not part of the root workspace).
5
+
6
+ It builds `plum-cli`, exports the game with `plum game --web`, serves the output on
7
+ 127.0.0.1:0, opens it in headless Chromium, clicks the canvas once, then asserts:
8
+ the canvas is not one uniform colour, two screenshots 1 s apart differ (the game
9
+ animates), and there are no page errors or console errors (except the known harmless
10
+ macroquad `register_plugin is not defined`). AudioContext state is reported; a missing
11
+ running context is only a warning unless `--require-audio` is given.
12
+
13
+ ## One-time setup
14
+
15
+ Downloads the Chromium build matching the bundled Playwright driver (about 100 MB, to
16
+ `~/Library/Caches/ms-playwright` on macOS; the driver itself is fetched by the crate's build script):
17
+
18
+ cargo run --manifest-path plum-game/web-test/Cargo.toml --example install-browsers
19
+
20
+ ## Usage
21
+
22
+ cargo run --manifest-path plum-game/web-test/Cargo.toml -- plum-examples/game.plum --screenshot /tmp/orb-run.png
23
+
24
+ Options: `--frames-wait SECS` (default 8), `--screenshot OUT.png`, `--require-audio`.
25
+ Exit code is non-zero with a message on failure.
26
+
27
+ `cargo test --manifest-path plum-game/web-test/Cargo.toml` runs the same check on
28
+ `plum-examples/game.plum`; it skips with a note if Chromium is not installed.
plum-game/web-test/examples/install-browsers.rs ADDED
@@ -0,0 +1,6 @@
1
+ // One-time setup: installs the Chromium build matching the bundled Playwright driver.
2
+ #[tokio::main]
3
+ async fn main() -> Result<(), Box<dyn std::error::Error>> {
4
+ playwright_rs::install_browsers(Some(&["chromium"])).await?;
5
+ Ok(())
6
+ }
plum-game/web-test/src/lib.rs ADDED
@@ -0,0 +1,302 @@
1
+ //! Browser check for `plum game --web` builds, driven by playwright-rs.
2
+ use playwright_rs::{LaunchOptions, Playwright};
3
+ use std::io::{Read, Write};
4
+ use std::net::{TcpListener, TcpStream};
5
+ use std::path::{Path, PathBuf};
6
+ use std::process::Command;
7
+ use std::sync::atomic::{AtomicBool, Ordering};
8
+ use std::sync::{Arc, Mutex};
9
+ use std::time::Duration;
10
+
11
+ pub struct Options {
12
+ pub frames_wait: f64,
13
+ pub screenshot: Option<PathBuf>,
14
+ pub require_audio: bool,
15
+ }
16
+
17
+ impl Default for Options {
18
+ fn default() -> Self {
19
+ Options { frames_wait: 8.0, screenshot: None, require_audio: false }
20
+ }
21
+ }
22
+
23
+ /// Whether `playwright install chromium` has been run (browser cache dir exists).
24
+ pub fn chromium_installed() -> bool {
25
+ let dir = if let Ok(p) = std::env::var("PLAYWRIGHT_BROWSERS_PATH") {
26
+ PathBuf::from(p)
27
+ } else {
28
+ let home = PathBuf::from(std::env::var("HOME").unwrap_or_default());
29
+ if cfg!(target_os = "macos") {
30
+ home.join("Library/Caches/ms-playwright")
31
+ } else if cfg!(target_os = "windows") {
32
+ PathBuf::from(std::env::var("LOCALAPPDATA").unwrap_or_default()).join("ms-playwright")
33
+ } else {
34
+ home.join(".cache/ms-playwright")
35
+ }
36
+ };
37
+ std::fs::read_dir(dir)
38
+ .map(|rd| rd.flatten().any(|e| e.file_name().to_string_lossy().starts_with("chromium")))
39
+ .unwrap_or(false)
40
+ }
41
+
42
+ pub fn repo_root() -> PathBuf {
43
+ Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().expect("repo root")
44
+ }
45
+
46
+ /// Builds plum-cli and exports `file` as a web build into `out`.
47
+ pub fn build_web(file: &Path, out: &Path) -> Result<(), String> {
48
+ let root = repo_root();
49
+ let st = Command::new("cargo")
50
+ .args(["build", "-p", "plum-cli"])
51
+ .current_dir(&root)
52
+ .status()
53
+ .map_err(|e| format!("cargo build: {e}"))?;
54
+ if !st.success() {
55
+ return Err("cargo build -p plum-cli failed".into());
56
+ }
57
+ let file = file.canonicalize().map_err(|e| format!("{}: {e}", file.display()))?;
58
+ let o = Command::new(root.join("target/debug/plum"))
59
+ .args(["game", "--web"])
60
+ .arg(&file)
61
+ .arg("-o")
62
+ .arg(out)
63
+ .current_dir(&root)
64
+ .output()
65
+ .map_err(|e| format!("plum game --web: {e}"))?;
66
+ if !o.status.success() {
67
+ return Err(format!(
68
+ "plum game --web failed:\n{}{}",
69
+ String::from_utf8_lossy(&o.stdout),
70
+ String::from_utf8_lossy(&o.stderr)
71
+ ));
72
+ }
73
+ Ok(())
74
+ }
75
+
76
+ fn mime(path: &Path) -> &'static str {
77
+ match path.extension().and_then(|e| e.to_str()).unwrap_or("") {
78
+ "wasm" => "application/wasm",
79
+ "js" | "mjs" => "text/javascript",
80
+ "html" => "text/html",
81
+ "png" => "image/png",
82
+ "wav" => "audio/wav",
83
+ "ogg" => "audio/ogg",
84
+ "txt" => "text/plain",
85
+ "json" => "application/json",
86
+ _ => "application/octet-stream",
87
+ }
88
+ }
89
+
90
+ fn respond(mut s: TcpStream, root: &Path) {
91
+ let mut buf = [0u8; 8192];
92
+ let n = s.read(&mut buf).unwrap_or(0);
93
+ let req = String::from_utf8_lossy(&buf[..n]);
94
+ let target = req.split_whitespace().nth(1).unwrap_or("/");
95
+ let rel = target.split(['?', '#']).next().unwrap_or("/").trim_start_matches('/');
96
+ let rel = if rel.is_empty() { "index.html" } else { rel };
97
+ let ok = !rel.split('/').any(|c| c == ".." || c.contains('\\'));
98
+ let path = root.join(rel);
99
+ match std::fs::read(&path).ok().filter(|_| ok) {
100
+ Some(body) => {
101
+ let head = format!(
102
+ "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
103
+ mime(&path),
104
+ body.len()
105
+ );
106
+ let _ = s.write_all(head.as_bytes());
107
+ let _ = s.write_all(&body);
108
+ }
109
+ None => {
110
+ let _ = s.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
111
+ }
112
+ }
113
+ }
114
+
115
+ /// Serves `root` on 127.0.0.1:0; returns the port. Stops when `stop` is set.
116
+ pub fn serve(root: PathBuf, stop: Arc<AtomicBool>) -> u16 {
117
+ let l = TcpListener::bind("127.0.0.1:0").expect("bind");
118
+ let port = l.local_addr().unwrap().port();
119
+ std::thread::spawn(move || {
120
+ for s in l.incoming() {
121
+ if stop.load(Ordering::Relaxed) {
122
+ break;
123
+ }
124
+ if let Ok(s) = s {
125
+ let root = root.clone();
126
+ std::thread::spawn(move || respond(s, &root));
127
+ }
128
+ }
129
+ });
130
+ port
131
+ }
132
+
133
+ pub struct Shot {
134
+ pub w: u32,
135
+ pub h: u32,
136
+ pub rgba: Vec<u8>,
137
+ }
138
+
139
+ pub fn decode(png: &[u8]) -> Result<Shot, String> {
140
+ let img = image::load_from_memory(png).map_err(|e| format!("decode screenshot: {e}"))?.to_rgba8();
141
+ Ok(Shot { w: img.width(), h: img.height(), rgba: img.into_raw() })
142
+ }
143
+
144
+ /// Number of distinct colours (capped at `cap`).
145
+ pub fn distinct_colours(s: &Shot, cap: usize) -> usize {
146
+ let mut set = std::collections::HashSet::new();
147
+ for p in s.rgba.chunks_exact(4) {
148
+ set.insert([p[0], p[1], p[2]]);
149
+ if set.len() >= cap {
150
+ break;
151
+ }
152
+ }
153
+ set.len()
154
+ }
155
+
156
+ /// Fraction-free count of pixels that differ between two same-sized shots.
157
+ pub fn differing_pixels(a: &Shot, b: &Shot) -> usize {
158
+ if (a.w, a.h) != (b.w, b.h) {
159
+ return usize::MAX;
160
+ }
161
+ a.rgba.chunks_exact(4).zip(b.rgba.chunks_exact(4)).filter(|(x, y)| x != y).count()
162
+ }
163
+
164
+ const AUDIO_HOOK: &str = r#"(() => {
165
+ window.__plumAudio = [];
166
+ for (const k of ["AudioContext", "webkitAudioContext"]) {
167
+ const Orig = window[k];
168
+ if (!Orig) continue;
169
+ window[k] = function (...a) { const c = new Orig(...a); window.__plumAudio.push(c); return c; };
170
+ window[k].prototype = Orig.prototype;
171
+ }
172
+ })()"#;
173
+
174
+ const AUDIO_PROBE: &str = "() => (window.__plumAudio || []).map(c => c.state)";
175
+
176
+ /// Runs the full check; Ok(report) on success, Err(message) on any failure.
177
+ pub async fn run(file: &Path, opts: &Options) -> Result<String, String> {
178
+ let tmp = std::env::temp_dir().join(format!("plum-web-test-{}", std::process::id()));
179
+ let _ = std::fs::remove_dir_all(&tmp);
180
+ std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
181
+ let res = run_in(file, opts, &tmp).await;
182
+ let _ = std::fs::remove_dir_all(&tmp);
183
+ res
184
+ }
185
+
186
+ async fn run_in(file: &Path, opts: &Options, dir: &Path) -> Result<String, String> {
187
+ build_web(file, dir)?;
188
+ let stop = Arc::new(AtomicBool::new(false));
189
+ let port = serve(dir.to_path_buf(), stop.clone());
190
+ let res = check(port, opts).await;
191
+ stop.store(true, Ordering::Relaxed);
192
+ let _ = TcpStream::connect(("127.0.0.1", port)); // unblock accept
193
+ res
194
+ }
195
+
196
+ fn pe<E: std::fmt::Display>(what: &'static str) -> impl FnOnce(E) -> String {
197
+ move |e| format!("{what}: {e}")
198
+ }
199
+
200
+ async fn check(port: u16, opts: &Options) -> Result<String, String> {
201
+ let pw = Playwright::launch().await.map_err(pe("launch playwright"))?;
202
+ let launch = LaunchOptions::new().headless(true).args(
203
+ ["--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist", "--autoplay-policy=no-user-gesture-required"]
204
+ .map(String::from)
205
+ .to_vec(),
206
+ );
207
+ let browser = pw.chromium().launch_with_options(launch).await.map_err(pe("launch chromium"))?;
208
+ let out = check_page(&browser, port, opts).await;
209
+ let _ = browser.close().await;
210
+ out
211
+ }
212
+
213
+ async fn check_page(browser: &playwright_rs::protocol::Browser, port: u16, opts: &Options) -> Result<String, String> {
214
+ let page = browser.new_page().await.map_err(pe("new page"))?;
215
+ let errors: Arc<Mutex<Vec<String>>> = Arc::default();
216
+ let logs: Arc<Mutex<Vec<String>>> = Arc::default();
217
+ {
218
+ let (errors, logs) = (errors.clone(), logs.clone());
219
+ page.on_console(move |m| {
220
+ let (errors, logs) = (errors.clone(), logs.clone());
221
+ async move {
222
+ let line = format!("console.{}: {}", m.type_(), m.text());
223
+ if m.type_() == "error" && !m.text().contains("register_plugin is not defined") {
224
+ errors.lock().unwrap().push(line.clone());
225
+ }
226
+ logs.lock().unwrap().push(line);
227
+ Ok(())
228
+ }
229
+ })
230
+ .await
231
+ .map_err(pe("on_console"))?;
232
+ }
233
+ {
234
+ let errors = errors.clone();
235
+ page.on_pageerror(move |m| {
236
+ let errors = errors.clone();
237
+ async move {
238
+ if !m.contains("register_plugin is not defined") {
239
+ errors.lock().unwrap().push(format!("pageerror: {m}"));
240
+ }
241
+ Ok(())
242
+ }
243
+ })
244
+ .await
245
+ .map_err(pe("on_pageerror"))?;
246
+ }
247
+ page.add_init_script(AUDIO_HOOK).await.map_err(pe("init script"))?;
248
+ page.goto(&format!("http://127.0.0.1:{port}/"), None).await.map_err(pe("goto"))?;
249
+
250
+ // Give the wasm time to load, then provide a user gesture on the canvas.
251
+ tokio::time::sleep(Duration::from_secs_f64((opts.frames_wait / 2.0).min(3.0))).await;
252
+ let size: Vec<f64> = page
253
+ .evaluate("() => { const r = document.getElementById('glcanvas').getBoundingClientRect(); return [r.width, r.height]; }", None::<&()>)
254
+ .await
255
+ .map_err(pe("canvas lookup (is there a #glcanvas?)"))?;
256
+ page.mouse().click(size[0] / 2.0, size[1] / 2.0, None).await.map_err(pe("click"))?;
257
+ tokio::time::sleep(Duration::from_secs_f64((opts.frames_wait - (opts.frames_wait / 2.0).min(3.0)).max(0.0))).await;
258
+
259
+ let png1 = page.screenshot(None).await.map_err(pe("screenshot"))?;
260
+ tokio::time::sleep(Duration::from_secs(1)).await;
261
+ let png2 = page.screenshot(None).await.map_err(pe("screenshot"))?;
262
+ let audio: Vec<String> = page.evaluate(AUDIO_PROBE, None::<&()>).await.unwrap_or_default();
263
+
264
+ if let Some(p) = &opts.screenshot {
265
+ std::fs::write(p, &png2).map_err(|e| format!("write {}: {e}", p.display()))?;
266
+ }
267
+ let (a, b) = (decode(&png1)?, decode(&png2)?);
268
+ let colours = distinct_colours(&b, 1000);
269
+ let diff = differing_pixels(&a, &b);
270
+ let errs = errors.lock().unwrap().clone();
271
+
272
+ let mut report = format!(
273
+ "canvas {}x{}, distinct colours: {colours}{}, pixels changed over 1s: {}\naudio: {}",
274
+ b.w,
275
+ b.h,
276
+ if colours >= 1000 { "+" } else { "" },
277
+ if diff == usize::MAX { "size changed".into() } else { diff.to_string() },
278
+ if audio.is_empty() { "no AudioContext created".to_string() } else { format!("AudioContext states {audio:?}") },
279
+ );
280
+ let mut failures = vec![];
281
+ if colours <= 1 {
282
+ failures.push("canvas is blank (uniformly one colour)".to_string());
283
+ }
284
+ if diff == 0 {
285
+ failures.push("canvas did not change between snapshots 1s apart (game not animating)".to_string());
286
+ }
287
+ failures.extend(errs.iter().cloned());
288
+ let audio_ok = audio.iter().any(|s| s == "running");
289
+ if !audio_ok {
290
+ if opts.require_audio {
291
+ failures.push("--require-audio: no running AudioContext".to_string());
292
+ } else {
293
+ report.push_str("\nWARNING: no running AudioContext (informational)");
294
+ }
295
+ }
296
+ if failures.is_empty() {
297
+ Ok(report)
298
+ } else {
299
+ let logs = logs.lock().unwrap().join("\n");
300
+ Err(format!("{}\n{report}\n--- console ---\n{logs}", failures.join("\n")))
301
+ }
302
+ }
plum-game/web-test/src/main.rs ADDED
@@ -0,0 +1,42 @@
1
+ use plum_web_test::{chromium_installed, run, Options};
2
+ use std::path::PathBuf;
3
+
4
+ const USAGE: &str = "usage: plum-web-test <file.plum> [--frames-wait SECS] [--screenshot OUT.png] [--require-audio]";
5
+
6
+ #[tokio::main]
7
+ async fn main() {
8
+ let mut file: Option<PathBuf> = None;
9
+ let mut opts = Options::default();
10
+ let mut args = std::env::args().skip(1);
11
+ while let Some(a) = args.next() {
12
+ match a.as_str() {
13
+ "--frames-wait" => {
14
+ opts.frames_wait = args.next().and_then(|v| v.parse().ok()).unwrap_or_else(|| fail("--frames-wait needs a number"));
15
+ }
16
+ "--screenshot" => opts.screenshot = Some(args.next().map(PathBuf::from).unwrap_or_else(|| fail("--screenshot needs a path"))),
17
+ "--require-audio" => opts.require_audio = true,
18
+ "-h" | "--help" => {
19
+ println!("{USAGE}");
20
+ return;
21
+ }
22
+ s if s.starts_with("--") => fail(&format!("unknown flag {s}\n{USAGE}")),
23
+ _ => file = Some(PathBuf::from(a)),
24
+ }
25
+ }
26
+ let Some(file) = file else { fail(USAGE) };
27
+ if !chromium_installed() {
28
+ fail("Playwright Chromium is not installed; see plum-game/web-test/README.md (one-time setup)");
29
+ }
30
+ match run(&file, &opts).await {
31
+ Ok(report) => println!("{report}\nPASS"),
32
+ Err(e) => {
33
+ eprintln!("FAIL: {e}");
34
+ std::process::exit(1);
35
+ }
36
+ }
37
+ }
38
+
39
+ fn fail(msg: &str) -> ! {
40
+ eprintln!("{msg}");
41
+ std::process::exit(2);
42
+ }
plum-game/web-test/tests/web_check.rs ADDED
@@ -0,0 +1,23 @@
1
+ use plum_web_test::{chromium_installed, repo_root, run, Options};
2
+
3
+ #[test]
4
+ fn orb_run_renders_and_animates() {
5
+ if !chromium_installed() {
6
+ eprintln!("SKIP: Playwright Chromium not installed (see plum-game/web-test/README.md)");
7
+ return;
8
+ }
9
+ let rt = tokio::runtime::Runtime::new().unwrap();
10
+ let file = repo_root().join("plum-examples/game.plum");
11
+ let report = rt.block_on(run(&file, &Options::default())).unwrap_or_else(|e| panic!("{e}"));
12
+ println!("{report}");
13
+ }
14
+
15
+ #[test]
16
+ fn image_helpers() {
17
+ use plum_web_test::{differing_pixels, distinct_colours, Shot};
18
+ let a = Shot { w: 2, h: 1, rgba: vec![1, 2, 3, 255, 1, 2, 3, 255] };
19
+ let b = Shot { w: 2, h: 1, rgba: vec![1, 2, 3, 255, 9, 2, 3, 255] };
20
+ assert_eq!(distinct_colours(&a, 10), 1);
21
+ assert_eq!(distinct_colours(&b, 10), 2);
22
+ assert_eq!(differing_pixels(&a, &b), 1);
23
+ }