test(web): add rust web browser smoke

This commit is contained in:
Kayshen-X 2026-06-20 19:24:59 +08:00
parent 26c39966e7
commit 521e44c92c
22 changed files with 523 additions and 150 deletions

View file

@ -130,19 +130,16 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
key: wasm32
# Step 1b §1.2 wasm32-clean compile guard — without `skia` feature this
# is a stub mount (validates host canvas, does not paint). The real
# render artifact uses the SAME wasm32-unknown-unknown target plus
# `--features skia` (post C-hard.2 lock-in 2026-05-09) and ships
# via a separate Phase A job once the CI-side C-hard pipeline
# (brew emscripten install + EMSDK + .wasm.a symlink hack +
# wasm-bindgen + wasm-opt) lands. Until then this job only proves
# the public surface compiles wasm32-clean.
# Step 1b wasm32-clean compile guard. The default `web` feature is a
# stub mount that validates the canvas and does not paint; it stays here
# to prove the public surface remains wasm32-clean without the production
# bundle deps. The real deployable renderer is built in
# wasm-bundle-build.yml with the `canvaskit` feature and the
# tools/check-wasm-bundle.sh gate.
- run: cargo build -p op-host-web --target wasm32-unknown-unknown --release --no-default-features --features web
# Artifact upload deliberately removed: the stub `.wasm` from this
# path would mislead downstream consumers into thinking the shell can
# render. Restore the upload step once the skia-enabled build runs on
# this CI target.
# render. The deployable artifact is uploaded by wasm-bundle-build.yml.
mobile-check:
name: ${{ matrix.label }} (cargo check only)

View file

@ -58,10 +58,10 @@ name: Rust release artifacts
# The `build` job builds the real `op-host-desktop` crate (winit + skia-safe
# runner that drives the Rust editor); its shipped executable keeps the
# stable `openpencil-desktop` name. The `op` CLI (crates/op-cli, binary name
# `op`) is built in the same cargo invocation. The wasm web bundle is
# currently DEFERRED (see comment block below); when the CI-side C-hard
# pipeline (emscripten install + EMSDK + symlink hack + wasm-bindgen +
# wasm-opt) lands it will re-enter the matrix.
# `op`) is built in the same cargo invocation. The Rust web bundle is built by
# `.github/workflows/wasm-bundle-build.yml` as a separate deployable CanvasKit
# artifact; this workflow currently keeps the native desktop release matrix
# independent from that web artifact.
on:
push:
@ -340,30 +340,20 @@ jobs:
OpenPencil-*-linux.deb
if-no-files-found: ignore
# Phase 1b release wasm artifact: explicitly DEFERRED (not silently
# dropped). The local C-hard pipeline (vendor/skia-safe-op fork +
# crates/wasm-libc-shim) does produce a runtime-loadable
# wasm32-unknown-unknown bundle today (verified 2026-05-09: 0 env.*
# imports, 597 KiB gzip). Re-introducing the release job requires
# CI-side automation that we have NOT yet built:
# 1. install brew emscripten 5.0.7 + EMSDK shim on ubuntu-latest
# 2. set EMSDK env var for the cargo build
# 3. run `cargo build … --features skia --release`
# 4. apply the `.wasm.a` → `.a` symlink hack in the
# skia-bindings out/ dir (manual today; tracked as a follow-up
# automation item — fold into `vendor/skia-safe-op` build.rs)
# 5. run `wasm-bindgen --target web` + `wasm-opt -Oz`
# 6. browser-side manual smoke (Phase E in spec) before publishing
#
# Until that pipeline lands, tagged releases ship desktop artifacts
# only. This is a CONSCIOUS choice — see plan §Cheat-sheet for the
# tracking item.
# Web bundle release note:
# The deployable Rust web bundle is produced by
# `.github/workflows/wasm-bundle-build.yml`, which builds the current
# CanvasKit feature set, runs `tools/check-wasm-bundle.sh`, size-gates the
# wasm output, and uploads the `op-web-bundle` artifact. This desktop release
# workflow still ships only native desktop + CLI artifacts; attach or merge the
# `op-web-bundle` artifact here only when tagged releases are meant to publish
# the standalone web bundle from the same draft.
release-draft:
name: Create / update GitHub Release draft
# `wasm` job deferred (see comment block above); when the
# CI-side C-hard pipeline lands, re-add the job and append its
# name to this `needs:` list.
# Native release artifacts only. The deployable Rust web bundle is built by
# wasm-bundle-build.yml and can be attached separately when release policy
# calls for publishing the web bundle from this draft.
needs: [build]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')

View file

@ -89,16 +89,22 @@ jobs:
echo "Installing wasm-bindgen-cli $version"
cargo install wasm-bindgen-cli --version "$version" --locked
- name: Install binaryen (wasm-opt)
- name: Install binaryen + headless server runtime deps
run: |
sudo apt-get update
sudo apt-get install -y binaryen
sudo apt-get install -y \
binaryen \
pkg-config \
libfreetype-dev \
libfontconfig1-dev \
fonts-noto-cjk
wasm-opt --version
- name: Verify node + gzip are present (script prerequisites)
- name: Verify node + gzip + Chrome are present (script prerequisites)
run: |
node --version
gzip --version | head -n1
google-chrome --version || chromium --version || chromium-browser --version
# Single source of truth: run the local gate script verbatim. It performs
# cargo build (canvaskit) -> wasm-bindgen --target web -> 0-env-import
@ -109,6 +115,9 @@ jobs:
- name: Build + size-gate the canvaskit bundle
run: bash tools/check-wasm-bundle.sh
- name: Browser smoke the canvaskit bundle + daemon
run: OPENPENCIL_SKIP_WASM_BUILD=1 bash tools/check-web-browser-smoke.sh
# Assemble the deployable layout the daemon's `web_static.rs` expects: the
# wasm-bindgen `pkg/` output PLUS the vendored CanvasKit artifact under a
# `canvaskit/` subdir, all inside a single `web-bundle/` directory. This

View file

@ -4,8 +4,9 @@
//! - `GET /` (and `/index.html`) — the embedded host page
//! (`web_static/index.html`, baked in via `include_str!` so serving works
//! from any cwd). The page loads the wasm-bindgen JS glue from `/pkg/`
//! and calls `mount('op')`; the hidden IME textarea is created by
//! `mount()` itself, so the page only carries the `<canvas>`.
//! and calls `mount_ck('op')`; the hidden IME textarea is created by the
//! CanvasKit mount itself, so the page only carries the `<canvas>`.
//! - `GET /smoke/step-1b.html` — the CanvasKit-only browser smoke harness.
//! - `GET /pkg/<file>` — the wasm-bindgen output files from the resolved
//! bundle directory, with correct MIME types (`application/wasm`,
//! `text/javascript`).
@ -30,6 +31,10 @@ const INDEX_HTML: &str = include_str!("web_static/index.html");
/// 404 help page served when the wasm bundle cannot be found.
const MISSING_BUNDLE_HTML: &str = include_str!("web_static/missing_bundle.html");
/// CanvasKit-only browser smoke harness. Kept in the web crate next to the
/// bundle it exercises; embedded here so the daemon can serve it from any cwd.
const SMOKE_HTML: &str = include_str!("../../op-host-web/smoke/step-1b.html");
/// The simple-icons brand-logo catalog (~4.8 MB). Embedded here so it can be
/// both (a) registered with the shared icon catalog at native GUI startup and
/// (b) served to the web client, which deliberately omits it from the wasm
@ -191,6 +196,13 @@ pub fn handle_static_request(path: &str, bundle_dir: Option<&Path>) -> Option<St
None => missing_bundle_reply(),
});
}
if path == "/smoke/step-1b.html" {
return Some(StaticReply {
status: "200 OK",
content_type: "text/html; charset=utf-8",
body: SMOKE_HTML.as_bytes().to_vec(),
});
}
if let Some(file) = path.strip_prefix("/pkg/") {
// Serve flat bundle files AND nested ones: wasm-bindgen with a JS
// `module = "..."` snippet emits `snippets/<hash>/src/<file>.js`, which
@ -416,10 +428,14 @@ mod tests {
assert_eq!(reply.status, "200 OK");
assert_eq!(reply.content_type, "text/html; charset=utf-8");
let body = String::from_utf8(reply.body).expect("utf8");
// The host page loads the glue and mounts on the canvas.
// The host page loads the glue, resets through the daemon, and mounts
// the production CanvasKit shell on the canvas.
assert!(body.contains("/pkg/op_host_web.js"), "{body}");
assert!(body.contains("fetch('/api/mcp/sync-reset'"), "{body}");
assert!(body.contains("mount('op')"), "{body}");
assert!(body.contains("await mod.mount_ck('op')"), "{body}");
assert!(body.contains("data-op-smoke"), "{body}");
assert!(!body.contains("mod.mount('op')"), "{body}");
assert!(!body.contains("EMSDK"), "{body}");
let _ = std::fs::remove_dir_all(&dir);
}
@ -443,13 +459,27 @@ mod tests {
body.contains("canvas.style.width = `${cssWidth}px`"),
"{body}"
);
assert!(body.contains("requestIdleCallback"), "{body}");
assert!(
body.contains("sizeCanvasForDisplay(STEADY_CANVAS_PIXEL_BUDGET)"),
"{body}"
);
assert!(body.contains("window.requestAnimationFrame"), "{body}");
assert!(body.contains("window.__opShell.resize()"), "{body}");
assert!(body.contains("await mod.mount_ck('op')"), "{body}");
assert!(!body.contains("window.__opShell.resize()"), "{body}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn smoke_page_serves_canvaskit_mount_harness() {
let dir = stub_bundle("smoke");
let reply = handle_static_request("/smoke/step-1b.html", Some(&dir)).expect("static route");
assert_eq!(reply.status, "200 OK");
assert_eq!(reply.content_type, "text/html; charset=utf-8");
let body = String::from_utf8(reply.body).expect("utf8");
assert!(body.contains("mod.mount_ck('op')"), "{body}");
assert!(body.contains("data-op-smoke"), "{body}");
assert!(body.contains("--features canvaskit"), "{body}");
assert!(!body.contains("mod.mount('op')"), "{body}");
assert!(!body.contains("EMSDK"), "{body}");
let _ = std::fs::remove_dir_all(&dir);
}

View file

@ -40,14 +40,17 @@
bundle (JS glue + .wasm) is served from `/pkg/*` by the same daemon.
The hidden IME composition <textarea> the web shell needs is created
by `mount()` itself (op-host-web `lib.rs`), so this page only has to
provide the <canvas>.
by `mount_ck()` itself, so this page only has to provide the <canvas>.
-->
<canvas id="op"></canvas>
<pre id="op-error"></pre>
<script type="module">
const errorPane = document.getElementById('op-error');
const markSmoke = (state) => {
document.documentElement.setAttribute('data-op-smoke', state);
};
const showError = (msg) => {
markSmoke('error');
errorPane.textContent = msg;
errorPane.style.display = 'block';
};
@ -86,45 +89,22 @@
};
// Size the canvas to the window BEFORE mount — the shell reads the
// canvas width/height attributes when it builds its raster surface.
markSmoke('booting');
sizeCanvasForDisplay(BOOT_CANVAS_PIXEL_BUDGET);
try {
await resetSyncDocument();
const mod = await import('/pkg/op_host_web.js');
await mod.default();
// The CanvasKit bundle (renders skia on WebGL2 via /canvaskit/) exposes
// `mount_ck` and owns its own resize via DOM listeners; the legacy
// embedded-skia bundle exposes `mount` + a `WebShell.resize()` handle.
if (typeof mod.mount_ck === 'function') {
sizeCanvasForDisplay(STEADY_CANVAS_PIXEL_BUDGET);
await mod.mount_ck('op');
} else {
// Keep the returned WebShell alive for the page lifetime: it owns
// the DOM listeners + closures into the wasm module.
window.__opShell = mod.mount('op');
const idle = window.requestIdleCallback || ((cb) => window.setTimeout(cb, 250));
idle(() => {
if (sizeCanvasForDisplay(STEADY_CANVAS_PIXEL_BUDGET)) {
window.__opShell.resize();
}
});
let resizeRaf = 0;
window.addEventListener('resize', () => {
if (resizeRaf) return;
resizeRaf = window.requestAnimationFrame(() => {
resizeRaf = 0;
if (sizeCanvasForDisplay(STEADY_CANVAS_PIXEL_BUDGET)) {
window.__opShell.resize();
}
});
});
}
markSmoke('ok');
} catch (e) {
showError(
[
'OpenPencil web shell failed to load.',
'',
'Expected the wasm-bindgen bundle at /pkg/op_host_web.js.',
'Rebuild it with tools/check-wasm-bundle.sh (needs EMSDK), or',
'Rebuild it with tools/check-wasm-bundle.sh, or',
'point the daemon at an existing bundle directory via the',
'OPENPENCIL_WEB_BUNDLE_DIR environment variable.',
'',

View file

@ -20,36 +20,32 @@ op-editor-ui = { path = "../op-editor-ui" }
# single source of truth; `op-pen-loader` bridges it into the
# read-only paint `Document` the shared widgets consume.
#
# Both are `optional` + behind the `skia` feature for the SAME reason
# `skia-safe` is: the host (`widget_host`) is `#[cfg(feature="skia")]`-
# gated, so the skia-free CI stub baseline
# (`--no-default-features --features web`) never needs them. This
# matters because `op-pen-loader` pulls `jian-skia` transitively
# (`pen_document_to_document` runs `SkiaMeasure` for text layout);
# keeping it optional preserves the wasm32-clean stub baseline.
# These stay optional and are pulled only by `canvaskit`, so the wasm32-clean
# stub baseline (`--no-default-features --features web`) never compiles the
# full editor pipeline. `op-pen-loader` disables `skia-measure` below; the web
# shell uses CanvasKit/browser text measurement instead of the native Skia
# measurement path.
op-editor-core = { path = "../op-editor-core", optional = true }
op-editor-host-core = { path = "../op-editor-host-core", optional = true, default-features = false }
# default-features = false drops `skia-measure`; the `skia` feature re-enables
# it so only the CanvasKit build stays skia-free (estimate text measurement).
# default-features = false drops `skia-measure` for the browser bundle.
op-pen-loader = { path = "../op-pen-loader", optional = true, default-features = false }
# P4b web AI-streaming foundation: the codegen/AI pipeline. Optional + behind
# the `codegen` feature (which also pulls `skia`) so the wasm32-clean stub
# baseline (`--no-default-features --features web`) never compiles it. NOTE:
# the new web modules it backs (web_ai_transport / raf_pump / web_clipboard)
# are UNVERIFIED in-browser — run tools/check-wasm-bundle.sh and a browser smoke test.
# Web AI/codegen foundation. Optional + behind `canvaskit`, so the wasm32-clean
# stub baseline never compiles it. End-to-end browser AI still depends on the
# desktop daemon routes; unit tests cover the pure SSE parsing and bundle checks
# cover wasm linkability.
op-codegen = { path = "../op-codegen", features = ["ai"], optional = true }
# P5 web codegen session: `build_codegen_input` resolves the selection into
# `PenNode` subtrees (jian-ops-schema), serializes them (serde_json), and
# constructs the pipeline `CodegenInput` whose param enums come from op-ai.
# All optional + behind `codegen` so the wasm32-clean stub baseline never
# pulls them. NOTE: UNVERIFIED in-browser; use tools/check-wasm-bundle.sh.
# All optional + behind `canvaskit` so the wasm32-clean stub baseline never
# pulls them.
op-ai = { path = "../op-ai", optional = true }
jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema", optional = true }
serde_json = { workspace = true, optional = true }
# Browser file-IO P0: Figma `.fig` parsing + clipboard-HTML decoding for the
# web shell. Pure-Rust (flate2/miniz_oxide + ruzstd + base64 — wasm32-clean,
# no C toolchain), so the same crate the desktop host uses compiles for the
# browser bundle. Optional + behind `codegen` with the rest of the document
# browser bundle. Optional + behind `canvaskit` with the rest of the document
# pipeline deps so the wasm32-clean stub baseline never pulls it.
op-figma = { path = "../op-figma", optional = true }
# Step 1b §3.6: handwritten DOM mirror consumes accesskit::TreeUpdate (0.24).
@ -79,7 +75,7 @@ features = [
"Clipboard",
# Browser file-IO P0 (paste / drag-drop / hidden file input): the
# ClipboardEvent / DataTransfer / DragEvent / File* / HtmlInputElement
# bindings below are only *called* from the `codegen`-gated dom_io glue,
# bindings below are only *called* from the `canvaskit`-gated dom_io glue,
# but web-sys feature flags are compile-time binding toggles — they keep
# the `web` stub baseline compiling unchanged.
"ClipboardEvent",

View file

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Step 1b WebShell Smoke</title>
<title>CanvasKit WebShell Smoke</title>
<style>
html,
body {
@ -36,51 +36,52 @@
<div id="status"></div>
<canvas id="op" width="960" height="640"></canvas>
<script type="module">
// Phase A smoke harness for the WebShell red-rect demo.
// Smoke harness for the production CanvasKit WebShell.
//
// CURRENT STATE (post C-hard.2, 2026-05-09):
// - Skia C++ + skia-bindings + skia-safe + shell-web build to
// wasm32-unknown-unknown via the vendor/skia-safe-op fork
// (build_support/platform/wasm_unknown.rs).
// - The libc / libcxx / libm shim crate (crates/wasm-libc-shim)
// resolves the previously-missing env.* imports. Verified via
// `WebAssembly.Module.imports()`: 22 imports, all from
// `./op_host_web_bg.js`; 0 env.* imports.
// - The bundle is structurally LinkError-free; a LinkError here
// is now a REGRESSION signal, not the expected state.
// CURRENT STATE:
// - `tools/check-wasm-bundle.sh` builds `op-host-web` with the
// `canvaskit` feature, runs wasm-bindgen, asserts 0 env.* imports,
// runs wasm-opt, and size-gates the wasm bundle.
// - CanvasKit itself is served separately from `/canvaskit/`.
// - A LinkError here is a regression in the current CanvasKit bundle.
//
// What this harness verifies:
// - module loads (LinkError ⇒ regression in libc shim or fork)
// - mount() returns a live WebShell
// - paint_phase_a draws a red rect via skia raster + put_image_data
// - module loads
// - mount_ck() resolves and paints the Rust editor shell through CanvasKit
//
// What this harness does NOT verify (Phase E manual smoke):
// - the red rect is actually visible to a human in a browser tab
// - latency is within the 16ms / 60fps budget
// - DPI scale + canvas resize behave correctly
// What this harness does NOT verify:
// - all daemon-backed routes are available
// - AI / file / export endpoints work end to end
// - performance is within frame budget under a large document
//
// Round 2 C-R2-1 fix: assign the returned WebShell to
// `window.__opShell` so the GC keeps it alive for the page
// lifetime; the shell may hold closures into the wasm module.
// Keep the returned promise reachable for the page lifetime; the shell
// holds browser event closures into the wasm module.
const status = document.getElementById('status');
const markSmoke = (state) => {
document.documentElement.setAttribute('data-op-smoke', state);
};
const log = (cls, msg) => {
const block = document.createElement('pre');
block.className = cls;
block.textContent = msg;
status.appendChild(block);
};
markSmoke('booting');
try {
const mod = await import('../pkg/op_host_web.js');
await mod.default();
window.__opShell = mod.mount('op');
log('ok', 'WebShell mounted; canvas should show centered red rect.');
window.__opShell = mod.mount_ck('op');
await window.__opShell;
markSmoke('ok');
log('ok', 'CanvasKit WebShell mounted; the editor canvas should be visible.');
} catch (e) {
markSmoke('error');
const isLinkError =
e instanceof WebAssembly.LinkError ||
(e && e.constructor && e.constructor.name === 'LinkError') ||
(e && e.message && e.message.startsWith('WebAssembly.instantiate()'));
const header = isLinkError
? 'WebShell wasm bundle failed to link — REGRESSION (post C-hard.2 the bundle is supposed to be LinkError-free).'
? 'CanvasKit WebShell wasm bundle failed to link — REGRESSION.'
: 'WebShell loader could not be imported.';
log(
'error',
@ -90,20 +91,17 @@
'Expected file: ../pkg/op_host_web.js',
'',
'How to rebuild the bundle (when the file is missing or stale):',
' 1. brew install emscripten # 5.0.7 + EMSDK shim',
' 2. mkdir -p ~/.emsdk/upstream && \\',
' ln -sfn /opt/homebrew/opt/emscripten/libexec ~/.emsdk/upstream/emscripten',
' 3. EMSDK="$HOME/.emsdk" cargo build -p op-host-web \\',
' --target wasm32-unknown-unknown --features skia --release',
' 4. (one-time) ln -sf libfoo.wasm.a libfoo.a in target/.../skia-bindings-*/out/skia/',
' 5. wasm-bindgen --target web \\',
' --out-dir crates/op-host-web/pkg \\',
' target/wasm32-unknown-unknown/release/op_host_web.wasm',
' 1. bash tools/check-wasm-bundle.sh',
' # equivalent cargo step uses --features canvaskit',
' 2. serve crates/op-host-web/pkg at /pkg/',
' 3. serve crates/op-host-web/assets/canvaskit at /canvaskit/',
'',
'For the normal local path, run:',
' op start --web',
'',
'A LinkError after a clean rebuild means a new env.* import',
'snuck back in (skia upgrade, new shim gap, etc). Inspect via',
' wasm-objdump --details --section=Import ../pkg/op_host_web_bg.wasm',
'and add the missing symbol to crates/wasm-libc-shim/src/imp.rs.',
'snuck back in. Run tools/check-wasm-bundle.sh and inspect',
'the import guard failure.',
'',
'Underlying error: ' + (e && e.message ? e.message : String(e)),
].join('\n'),

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser boundary: FileReader, Blob downloads, paste, and drag/drop require
// browser smoke; pure ingest/export behavior is covered in file_actions tests.
//! Browser file-IO glue — the web consumer of
//! `editor_ui.pending_file_action` plus the DOM paste / drag-drop
//! listeners. The pure serialize / ingest logic lives in

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser boundary: these pure helpers are unit-tested; DOM picker/download
// behavior lives in dom_io and still needs browser smoke.
//! Pure (DOM-free) document IO helpers behind the browser file-IO
//! glue (`dom_io`). Everything here is plain Rust over
//! `op_editor_core` / `op_pen_loader` / `op_figma`, so the unit tests

View file

@ -1,4 +1,5 @@
// UNVERIFIED: needs EMSDK wasm32 build + browser; run tools/check-wasm-bundle.sh
// Browser/daemon boundary: Iconify XHR uses the public CORS API while the brand
// catalog comes from the daemon-served asset; parsing/apply logic is unit-tested.
//! Web Iconify bridge — the browser consumer of the icon picker's
//! `icon_picker_load_more_request` flag.
//!

View file

@ -33,8 +33,8 @@ mod widget_host;
// drive the CanvasKit `CkInner` through one trait.
#[cfg(feature = "canvaskit")]
mod repaint_ctx;
// Pure web_sys IO (no skia) — compiled always so the `web` stub still
// compile-checks on wasm32 without EMSDK.
// Pure web_sys IO (no native Skia/C toolchain) — compiled always so the `web`
// stub still compile-checks on wasm32.
mod live_sync;
#[cfg(feature = "canvaskit")]
mod live_sync_glue;

View file

@ -3,8 +3,8 @@
//! talk to the web-canvas daemon (`op-host-desktop`'s `web_canvas_server`,
//! run via `--serve-web` / `op start --web`).
//!
//! This module is PURE `web_sys` IO (no skia, no `op_editor_core`), so it
//! compile-checks on the wasm32 web stub WITHOUT an EMSDK toolchain — i.e. the
//! This module is PURE `web_sys` IO (no native Skia/C toolchain and no
//! `op_editor_core`), so it compile-checks on the wasm32 web stub. The
//! highest-risk part of the live-sync glue (the `web_sys` XHR/interval calls)
//! is verified by `cargo check -p op-host-web --target wasm32-unknown-unknown`.
//! The protocol decisions (version gating, push baselines, apply + repaint)

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser boundary: requestAnimationFrame scheduling needs browser smoke; the
// CanvasKit bundle gate covers wasm linkability.
//! A `requestAnimationFrame` loop that ticks while work is in flight.
//!
//! Used to drain streamed AI deltas into the transcript ~once per frame (the

View file

@ -2,13 +2,11 @@
//! live-sync, codegen, file IO, icon search, fonts) and whichever rendering
//! host owns the shell.
//!
//! Both mount paths wrap their state in `Rc<RefCell<_>>` and expose the same
//! two things the integration modules need: the shared `WidgetHost` (where all
//! `EditorState` lives) and a `repaint()` that re-paints through that path's
//! backend. The skia raster mount (`Inner`, `lib.rs`) and the CanvasKit GPU
//! mount (`CkInner`, `canvaskit.rs`) each implement this trait, so the modules
//! can be written once against `Rc<RefCell<impl RepaintContext>>` instead of a
//! concrete `Inner`.
//! The production CanvasKit mount wraps its state in `Rc<RefCell<_>>` and
//! exposes the two things the integration modules need: the shared `WidgetHost`
//! (where all `EditorState` lives) and a `repaint()` that re-paints through the
//! CanvasKit backend. Keeping the trait lets those modules stay independent of
//! the concrete `CkInner` type.
use crate::widget_host::WidgetHost;
use wasm_bindgen::JsValue;

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser/daemon boundary: XHR SSE needs a running desktop daemon; pure SSE
// parsing is unit-tested and the CanvasKit bundle gate covers wasm linkability.
//! XHR-based SSE transport to the desktop daemon's AI SSE endpoints.
//!
//! Mirrors `live_sync.rs` (no `wasm-bindgen-futures`): an `XmlHttpRequest`

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser/daemon boundary: chat streams through /api/ai/standard on the desktop
// daemon; this module keeps that web architecture instead of bypassing it.
//! Web chat session — drives a real AI turn through the desktop daemon's
//! `/api/ai/standard` route instead of the offline echo stub.
//!

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser boundary: clipboard writes and download clicks require real browser
// gestures; the CanvasKit bundle gate covers wasm linkability.
//! Browser clipboard write + file download (Blob + anchor).
//!
//! The two browser-side actions the codegen panel needs: copy generated source

View file

@ -1,4 +1,5 @@
// UNVERIFIED in-browser: run tools/check-wasm-bundle.sh and a browser smoke test.
// Browser boundary: Local Font Access permission and CanvasKit font-byte loading
// need browser smoke; pure classifiers and fallback routing are unit-tested.
//! Browser Local Font Access bridge for the web host.
//!
//! The wasm Skia backend cannot see operating-system fonts by itself. The

View file

@ -19,3 +19,145 @@ fn rust_check_runs_canvaskit_widget_host_tests() {
"rust-check must enable the production CanvasKit web host feature"
);
}
#[test]
fn release_workflow_documents_current_canvaskit_bundle_path() {
let workflow = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../.github/workflows/rust-release.yml"
))
.expect("rust-release workflow is readable");
assert!(
workflow.contains("wasm-bundle-build.yml"),
"release workflow notes should point at the current CanvasKit bundle workflow"
);
assert!(
!workflow.contains("--features skia"),
"release workflow must not describe the retired skia wasm build path"
);
assert!(
!workflow.contains("EMSDK"),
"release workflow must not describe the retired EMSDK wasm build path"
);
}
#[test]
fn web_smoke_page_uses_current_canvaskit_bundle_command() {
let smoke = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/smoke/step-1b.html"))
.expect("web smoke page is readable");
assert!(
smoke.contains("--features canvaskit"),
"web smoke page should show the current CanvasKit bundle build command"
);
assert!(
smoke.contains("mod.mount_ck('op')"),
"web smoke page should mount the production CanvasKit shell"
);
assert!(
!smoke.contains("mod.mount('op')"),
"web smoke page must not mount the default wasm stub"
);
assert!(
!smoke.contains("--features skia"),
"web smoke page must not point users at the retired skia wasm build"
);
assert!(
!smoke.contains("EMSDK"),
"web smoke page must not require the retired EMSDK setup"
);
}
#[test]
fn web_integration_notes_avoid_legacy_ambiguous_markers() {
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let files = [
"Cargo.toml",
"src/dom_io.rs",
"src/file_actions.rs",
"src/iconify_web.rs",
"src/lib.rs",
"src/live_sync.rs",
"src/raf_pump.rs",
"src/web_ai_transport.rs",
"src/web_chat.rs",
"src/web_clipboard.rs",
"src/web_fonts.rs",
];
for file in files {
let text = std::fs::read_to_string(manifest_dir.join(file))
.unwrap_or_else(|err| panic!("{file} is readable: {err}"));
assert!(
!text.contains("UNVERIFIED in-browser"),
"{file} should name the concrete smoke/daemon boundary instead of a blanket UNVERIFIED marker"
);
assert!(
!text.contains("EMSDK"),
"{file} should describe the current CanvasKit/web-sys boundary instead of the retired EMSDK path"
);
}
}
#[test]
fn browser_smoke_script_and_ci_cover_canvas_and_daemon_paths() {
let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let script = std::fs::read_to_string(repo.join("tools/check-web-browser-smoke.sh"))
.expect("browser smoke script is readable");
let workflow = std::fs::read_to_string(repo.join(".github/workflows/wasm-bundle-build.yml"))
.expect("wasm bundle workflow is readable");
assert!(
script.contains("--dump-dom"),
"browser smoke should drive a real headless browser, not only curl"
);
assert!(
script.contains("/smoke/step-1b.html"),
"browser smoke should cover the pure CanvasKit mount harness"
);
assert!(
script.contains("/api/mcp/server"),
"browser smoke should cover the daemon health path"
);
assert!(
script.contains("data-op-smoke=\"ok\""),
"browser smoke should assert the DOM success marker written after mount"
);
assert!(
workflow.contains("tools/check-web-browser-smoke.sh"),
"bundle workflow should run the browser smoke after building the bundle"
);
}
#[test]
fn web_rust_publish_has_docker_and_start_script_entrypoints() {
let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let dockerfile =
std::fs::read_to_string(repo.join("Dockerfile.web-rust")).expect("Dockerfile readable");
let start_script = std::fs::read_to_string(repo.join("scripts/start-web-rust.sh"))
.expect("web rust start script is readable");
let package =
std::fs::read_to_string(repo.join("package.json")).expect("package.json readable");
assert!(
dockerfile.contains("op-host-web-server --serve-web"),
"web Rust Docker image should publish through the headless serve-web daemon"
);
assert!(
start_script.contains("tools/check-wasm-bundle.sh"),
"start script should build or verify the deployable CanvasKit bundle"
);
assert!(
start_script.contains("op-host-web-server") && start_script.contains("--serve-web"),
"start script should publish through op-host-web-server --serve-web"
);
assert!(
start_script.contains("OPENPENCIL_WEB_BUNDLE_DIR"),
"start script should point the daemon at the built web bundle explicitly"
);
assert!(
package.contains("\"shell:web:serve\""),
"package scripts should expose the web Rust start script"
);
}

View file

@ -53,7 +53,8 @@
"cargo:clippy": "cargo clippy --workspace --all-targets -- -D warnings",
"cargo:wasm-check": "cargo check --target wasm32-unknown-unknown -p op-host-web --no-default-features --features web",
"cargo:deny": "cargo deny check && cargo deny --target wasm32-unknown-unknown check bans",
"shell:wasm:dev": "echo 'Build the browser bundle: bash tools/check-wasm-bundle.sh (op-host-web --features canvaskit -> wasm-bindgen -> wasm-opt). Serve it: op start --web (raw form: openpencil-desktop --serve-web <port>)'"
"shell:wasm:dev": "echo 'Build the browser bundle: bash tools/check-wasm-bundle.sh (op-host-web --features canvaskit -> wasm-bindgen -> wasm-opt). Serve it: op start --web (raw form: openpencil-desktop --serve-web <port>)'",
"shell:web:serve": "bash scripts/start-web-rust.sh"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.18.2",

40
scripts/start-web-rust.sh Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Build and serve the Rust CanvasKit web editor without Docker.
#
# This is the script-publish counterpart to Dockerfile.web-rust. It builds the
# deployable wasm bundle, builds the GUI-free op-host-web-server daemon, then
# serves the browser editor through `--serve-web`.
set -euo pipefail
cd "$(dirname "$0")/.."
PORT="${OPENPENCIL_SERVE_PORT:-3100}"
HOST="${OPENPENCIL_SERVE_HOST:-127.0.0.1}"
DOC_PATH="${1:-}"
if [ "${OPENPENCIL_SKIP_WASM_BUILD:-0}" != "1" ]; then
bash tools/check-wasm-bundle.sh
fi
if [ "${OPENPENCIL_SKIP_SERVER_BUILD:-0}" != "1" ]; then
cargo build -p op-host-web-server --release
fi
SERVER_BIN="${OPENPENCIL_WEB_SERVER_BIN:-target/release/op-host-web-server}"
if [ ! -x "$SERVER_BIN" ]; then
echo "error: server binary is not executable: $SERVER_BIN" >&2
exit 1
fi
export OPENPENCIL_WEB_BUNDLE_DIR="${OPENPENCIL_WEB_BUNDLE_DIR:-$PWD/crates/op-host-web/pkg}"
export OPENPENCIL_CANVASKIT_DIR="${OPENPENCIL_CANVASKIT_DIR:-$PWD/crates/op-host-web/assets/canvaskit}"
echo "OpenPencil Rust web editor: http://${HOST}:${PORT}/"
echo "bundle: ${OPENPENCIL_WEB_BUNDLE_DIR}"
echo "canvaskit: ${OPENPENCIL_CANVASKIT_DIR}"
if [ -n "$DOC_PATH" ]; then
exec "$SERVER_BIN" --serve-web "$PORT" "$DOC_PATH" --host "$HOST"
else
exec "$SERVER_BIN" --serve-web "$PORT" --host "$HOST"
fi

184
tools/check-web-browser-smoke.sh Executable file
View file

@ -0,0 +1,184 @@
#!/usr/bin/env bash
# Browser smoke for the Rust CanvasKit web bundle.
#
# Covers both browser paths:
# 1. `/smoke/step-1b.html` — pure CanvasKit mount harness.
# 2. `/` — daemon-served host page, including `/api/mcp/sync-reset`.
#
# The script intentionally uses Chrome/Chromium headless directly instead of a
# new test dependency. The pages write `data-op-smoke="ok"` only after the wasm
# bundle has loaded and `mount_ck()` has resolved.
set -euo pipefail
cd "$(dirname "$0")/.."
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
need() {
command -v "$1" >/dev/null 2>&1 || {
printf 'missing prerequisite: %s\n' "$1" >&2
exit 2
}
}
find_chrome() {
if [ -n "${OPENPENCIL_CHROME_BIN:-}" ]; then
printf '%s\n' "$OPENPENCIL_CHROME_BIN"
return
fi
for bin in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$bin" >/dev/null 2>&1; then
command -v "$bin"
return
fi
done
for app in \
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
"/Applications/Chromium.app/Contents/MacOS/Chromium"; do
if [ -x "$app" ]; then
printf '%s\n' "$app"
return
fi
done
return 1
}
pick_port() {
python3 - <<'PY'
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}
wait_for_health() {
local url="$1"
local tries="${OPENPENCIL_BROWSER_SMOKE_WAIT_TRIES:-100}"
for _ in $(seq 1 "$tries"); do
if curl -fsS "$url" >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
assert_contains() {
local file="$1"
local needle="$2"
local label="$3"
if ! grep -Fq "$needle" "$file"; then
printf 'FAIL: %s\nexpected to find: %s\n--- %s ---\n' "$label" "$needle" "$file" >&2
sed -n '1,220p' "$file" >&2
return 1
fi
}
chrome_dump() {
local url="$1"
local out="$2"
local profile="$TMP_DIR/chrome-$(basename "$out" .html)"
local timeout="${OPENPENCIL_BROWSER_SMOKE_TIMEOUT_SECS:-45}"
mkdir -p "$profile"
"$CHROME_BIN" \
--headless=new \
--no-sandbox \
--disable-dev-shm-usage \
--disable-background-networking \
--disable-component-update \
--disable-default-apps \
--disable-sync \
--ignore-gpu-blocklist \
--metrics-recording-only \
--no-default-browser-check \
--no-first-run \
--use-gl=swiftshader \
--enable-unsafe-swiftshader \
--window-size=1280,800 \
--user-data-dir="$profile" \
--virtual-time-budget="${OPENPENCIL_BROWSER_SMOKE_VIRTUAL_TIME_MS:-15000}" \
--dump-dom "$url" >"$out" 2>"$out.stderr" &
local chrome_pid=$!
for _ in $(seq 1 "$timeout"); do
if grep -Fq 'data-op-smoke="ok"' "$out" 2>/dev/null; then
kill "$chrome_pid" >/dev/null 2>&1 || true
wait "$chrome_pid" >/dev/null 2>&1 || true
return 0
fi
if ! kill -0 "$chrome_pid" >/dev/null 2>&1; then
wait "$chrome_pid"
return $?
fi
sleep 1
done
kill "$chrome_pid" >/dev/null 2>&1 || true
wait "$chrome_pid" >/dev/null 2>&1 || true
printf 'headless Chrome timed out for %s after %ss\n' "$url" "$timeout" >&2
return 124
}
need curl
need python3
CHROME_BIN="$(find_chrome)" || {
printf 'missing prerequisite: Chrome/Chromium (set OPENPENCIL_CHROME_BIN)\n' >&2
exit 2
}
if [ "${OPENPENCIL_SKIP_WASM_BUILD:-0}" != "1" ]; then
bash tools/check-wasm-bundle.sh
fi
SERVER_BIN="${OPENPENCIL_WEB_SERVER_BIN:-target/release/op-host-web-server}"
if [ "${OPENPENCIL_SKIP_SERVER_BUILD:-0}" != "1" ]; then
cargo build -p op-host-web-server --release >/dev/null
fi
[ -x "$SERVER_BIN" ] || fail "server binary is not executable: $SERVER_BIN"
TMP_DIR="$(mktemp -d)"
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" >/dev/null 2>&1; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" >/dev/null 2>&1 || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
PORT="${OPENPENCIL_WEB_SMOKE_PORT:-$(pick_port)}"
BASE_URL="http://127.0.0.1:${PORT}"
SERVER_LOG="$TMP_DIR/server.log"
OPENPENCIL_WEB_BUNDLE_DIR="${OPENPENCIL_WEB_BUNDLE_DIR:-$PWD/crates/op-host-web/pkg}" \
OPENPENCIL_CANVASKIT_DIR="${OPENPENCIL_CANVASKIT_DIR:-$PWD/crates/op-host-web/assets/canvaskit}" \
"$SERVER_BIN" --serve-web "$PORT" --host 127.0.0.1 >"$SERVER_LOG" 2>&1 &
SERVER_PID="$!"
wait_for_health "$BASE_URL/api/mcp/server" || {
printf 'server did not become healthy\n--- server log ---\n' >&2
cat "$SERVER_LOG" >&2
exit 1
}
health="$TMP_DIR/health.json"
curl -fsS "$BASE_URL/api/mcp/server" >"$health"
assert_contains "$health" '"mode":"web-canvas"' "daemon health should report web-canvas mode"
curl -fsS "$BASE_URL/pkg/op_host_web.js" >/dev/null
curl -fsS "$BASE_URL/canvaskit/canvaskit.js" >/dev/null
curl -fsS "$BASE_URL/assets/iconify-catalog-brands.json" >/dev/null
pure="$TMP_DIR/pure-smoke.html"
chrome_dump "$BASE_URL/smoke/step-1b.html" "$pure"
assert_contains "$pure" 'data-op-smoke="ok"' "pure CanvasKit smoke should mount"
daemon="$TMP_DIR/daemon-smoke.html"
chrome_dump "$BASE_URL/" "$daemon"
assert_contains "$daemon" 'data-op-smoke="ok"' "daemon host page should mount"
printf 'PASS: browser smoke mounted CanvasKit via /smoke/step-1b.html and daemon /\n'