WAI Studio on the web: one front end, two hosts (the core in WebAssembly; wai dev at /_wai/), actions from the operation catalogue with a drift check #522
42 changed files with 12113 additions and 9 deletions
7
.github/workflows/wai-conformance.yml
vendored
7
.github/workflows/wai-conformance.yml
vendored
|
|
@ -600,12 +600,15 @@ jobs:
|
|||
# of the object or of the group's receipt refused; `wai create`, every template held to
|
||||
# wai/examples/ file for file and byte for byte. The binary's own tests: `wai dev`'s origin
|
||||
# (a service's prefix stripped, the relay's upgrade carried both ways, nothing served from
|
||||
# outside its roots) and the project file's reader.
|
||||
# outside its roots; WAI Studio at /_wai/, its API to this origin alone) and the project
|
||||
# file's reader. tests/cli_studio.rs: `wai dev` against a stand-in relay and room service
|
||||
# serves Studio at /_wai/, every socket the stack listens on is on loopback, and the
|
||||
# local wai runs an operation for the page.
|
||||
- name: The floor build, and wai hello, create, dev and verify
|
||||
working-directory: wai/wai-rs
|
||||
run: |
|
||||
cargo test --no-default-features --features floor --test floor
|
||||
cargo test --no-default-features --features quickstart --bin wai --test floor --test cli_dx --test cli_fleet
|
||||
cargo test --no-default-features --features quickstart --bin wai --test floor --test cli_dx --test cli_fleet --test cli_studio
|
||||
# tests/cli_http.rs runs `wai http publish`, `codecs`, `origin` and `bench` as a user
|
||||
# does: objects named by their content hash with their head proofs, a sequence's union
|
||||
# and floor, the origin's headers, ranges and If-Range on loopback, and a fetch by
|
||||
|
|
|
|||
190
.github/workflows/wai-studio.yml
vendored
Normal file
190
.github/workflows/wai-studio.yml
vendored
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
name: WAI Studio
|
||||
|
||||
# WAI Studio on the web (wai/wai-studio): one front end, its core in WebAssembly, served by
|
||||
# `wai dev` at /_wai/ and hosted by the WAI site.
|
||||
#
|
||||
# - catalogue · linux: Studio's actions hold to the operation catalogue (scripts/actions.mjs
|
||||
# --check: an action with no operation, an operation placed for the browser with no action,
|
||||
# a mismatched name), the unit tests that need no core build (the generation and every rule
|
||||
# of the drift check broken in a copy, the palette, the local core's calls), and the core's
|
||||
# own tests and clippy (core/, wai-studio-core, on the http-conformance vectors);
|
||||
# - browser · macos: e2e/run.sh builds wai, the relay, the room service, the core, the room
|
||||
# client's packages, the quantum engine, Quantum Studio and Studio; runs every unit test,
|
||||
# the core's calls among them, and the drift check; then e2e/studio.e2e.mjs against a
|
||||
# `wai dev` stack in Chromium and Firefox: each panel on both hosts, Inspect and Verify on
|
||||
# conformance vectors with the results their cases expect, a room joined as a viewer, the
|
||||
# palette, axe (WCAG 2.2 A and AA), no console error or failed request;
|
||||
# - webkit · linux: the same in WebKit, in the browser image of the Playwright release
|
||||
# package-lock.json pins (by digest). The macOS runner is started from SSH, and a process of
|
||||
# an SSH session there cannot open WebKit's helper processes: WebKit never launches.
|
||||
#
|
||||
# `wai dev`'s side (/_wai/ on its origin, nothing listening beyond loopback) is tested in the
|
||||
# WAI conformance workflow's quickstart step (tests/cli_studio.rs and the binary's own tests).
|
||||
#
|
||||
# Push filters match the push's delta, so every path Studio builds on is listed.
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'wai/wai-studio/**'
|
||||
- 'wai/wai-rs/**'
|
||||
- 'wai/catalogue/**'
|
||||
- 'wai/quantum-studio/**'
|
||||
- 'wai/wai-web/**'
|
||||
- 'wai/wai-quantum/**'
|
||||
- 'wai/wai-ops/**'
|
||||
- 'wai/wai-live-web/**'
|
||||
- 'wai/quickstart/build-web.sh'
|
||||
- 'wai/http-conformance/**'
|
||||
- 'wai/pack-conformance/stills/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-web/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-room/**'
|
||||
- 'rust-toolchain.toml'
|
||||
- '.github/workflows/wai-studio.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'wai/wai-studio/**'
|
||||
- 'wai/wai-rs/**'
|
||||
- 'wai/catalogue/**'
|
||||
- 'wai/quantum-studio/**'
|
||||
- 'wai/wai-web/**'
|
||||
- 'wai/wai-quantum/**'
|
||||
- 'wai/wai-ops/**'
|
||||
- 'wai/wai-live-web/**'
|
||||
- 'wai/quickstart/build-web.sh'
|
||||
- 'wai/http-conformance/**'
|
||||
- 'wai/pack-conformance/stills/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-web/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-relay/**'
|
||||
- 'joule-code/joule-code-rs/crates/joule-moq-room/**'
|
||||
- 'rust-toolchain.toml'
|
||||
- '.github/workflows/wai-studio.yml'
|
||||
|
||||
jobs:
|
||||
# The path filters gate the trigger; this job gates the work from the pull request's own
|
||||
# diff (scripts/pr-touches-paths.py, reading the list above), as wai-pack.yml explains.
|
||||
relevant:
|
||||
name: does this change touch it
|
||||
runs-on: linux-arm64
|
||||
outputs:
|
||||
run: ${{ steps.gate.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-studio.yml \
|
||||
"${{ github.event.pull_request.base.sha }}" \
|
||||
"${{ github.event.pull_request.head.sha }}")
|
||||
echo "this change touches WAI Studio: $r"
|
||||
echo "run=$r" >> "$GITHUB_OUTPUT"
|
||||
|
||||
catalogue:
|
||||
name: the catalogue's actions, the unit tests and the core · linux
|
||||
needs: relevant
|
||||
if: needs.relevant.outputs.run == 'true'
|
||||
runs-on: linux
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Rust 1.98.0
|
||||
shell: bash
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
command -v rustup >/dev/null 2>&1 || \
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
|
||||
rustup toolchain install 1.98.0 --profile minimal --component clippy
|
||||
rustup default 1.98.0
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Studio's actions hold to the catalogue
|
||||
working-directory: wai/wai-studio
|
||||
run: node scripts/actions.mjs --check
|
||||
# The core's calls (test/core.test.mjs) need the core built for the web; the browser
|
||||
# job runs them. These need nothing built.
|
||||
- name: Unit tests
|
||||
shell: bash
|
||||
working-directory: wai/wai-studio
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --test --test-reporter=tap test/actions.test.mjs test/palette.test.mjs test/local-core.test.mjs 2>&1 | tee "$RUNNER_TEMP/unit.log"
|
||||
grep -qE '^# pass [1-9]' "$RUNNER_TEMP/unit.log" && grep -qE '^# fail 0$' "$RUNNER_TEMP/unit.log" || { echo "::error::no unit test ran, or one failed"; exit 1; }
|
||||
- name: The core's tests and clippy
|
||||
shell: bash
|
||||
working-directory: wai/wai-studio/core
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo test --locked 2>&1 | tee "$RUNNER_TEMP/core.log"
|
||||
grep -qE '^test result: ok\. [1-9][0-9]* passed; 0 failed' "$RUNNER_TEMP/core.log" || { echo "::error::no test ran"; exit 1; }
|
||||
cargo clippy --locked --all-targets -- -D warnings
|
||||
|
||||
browser:
|
||||
name: Studio in Chromium and Firefox · macos
|
||||
needs: relevant
|
||||
if: needs.relevant.outputs.run == 'true'
|
||||
runs-on: macos
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# launchd's PATH omits cargo and node on the macOS host runner.
|
||||
- name: Put cargo and node on PATH
|
||||
shell: bash
|
||||
run: |
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
echo "/opt/homebrew/bin" >> "$GITHUB_PATH"
|
||||
- name: wasm32 target and wasm-bindgen CLI 0.2.127
|
||||
shell: bash
|
||||
run: |
|
||||
rustup target add wasm32-unknown-unknown
|
||||
command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q ' 0\.2\.127$' || cargo install --locked wasm-bindgen-cli --version 0.2.127
|
||||
- name: The browsers Playwright drives
|
||||
shell: bash
|
||||
working-directory: wai/wai-studio
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npx playwright install chromium firefox
|
||||
- name: Build everything, the unit tests, the drift check, and Studio in Chromium and Firefox
|
||||
shell: bash
|
||||
env:
|
||||
STUDIO_OUT: ${{ runner.temp }}/wai-studio-e2e
|
||||
run: nice -n 10 bash wai/wai-studio/e2e/run.sh --browsers chromium,firefox
|
||||
|
||||
webkit:
|
||||
name: Studio in WebKit · linux
|
||||
needs: relevant
|
||||
if: needs.relevant.outputs.run == 'true'
|
||||
runs-on: linux
|
||||
timeout-minutes: 150
|
||||
# Playwright 1.60.0's image (package-lock.json pins 1.60.0): Node, and WebKit with every
|
||||
# library it needs.
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright@sha256:9bd26ad900bb5e0f4dee75839e957a89ae89c2b7ab1e76050e559790e946b948
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
# The floor's zstd is C, compiled for wasm32 by clang and archived by llvm-ar.
|
||||
- name: Rust 1.98.0 with the wasm32 target, clang and llvm, wasm-bindgen CLI 0.2.127
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends curl ca-certificates build-essential pkg-config clang llvm python3 git > /dev/null
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
|
||||
"$HOME/.cargo/bin/rustup" target add wasm32-unknown-unknown
|
||||
"$HOME/.cargo/bin/cargo" install --locked wasm-bindgen-cli --version 0.2.127
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- name: Build everything, the unit tests, the drift check, and Studio in WebKit
|
||||
shell: bash
|
||||
env:
|
||||
STUDIO_OUT: ${{ runner.temp }}/wai-studio-e2e
|
||||
run: bash wai/wai-studio/e2e/run.sh --browsers webkit
|
||||
|
|
@ -6,6 +6,27 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### WAI Studio on the web: one front end, its core in WebAssembly, served by `wai dev` at `/_wai/`
|
||||
|
||||
- **[`wai-studio`](wai-studio/)**: one front end with two hosts, chosen when the page starts.
|
||||
Hosted, its core runs in the tab and nothing is uploaded; served by `wai dev` at `/_wai/`, the
|
||||
local `wai` does what a tab does not hold. Its panels: Inspect (a `.wai` file's envelope,
|
||||
capabilities, provenance, SPEC §4 verdict and receipts), Verify and receipts, Encode, decode,
|
||||
package (the floor in the tab, every codec the local `wai` holds through it), Rooms (a room
|
||||
shown and joined as a viewer, a pre-call test of a relay), Run (`wai dev` only: the stack, its
|
||||
services on loopback, their logs) and Quantum (Quantum Studio as a panel). Its command palette
|
||||
lists every operation of the catalogue by its id, and says where one runs that its host does
|
||||
not.
|
||||
- **The actions come from the operation catalogue**: `scripts/actions.mjs` generates Studio's
|
||||
action table from `ops.json`, and `--check` fails on an action with no operation, an operation
|
||||
placed for the browser with no action, or a mismatched name.
|
||||
- **The core** (`wai-studio/core`): the `wai` crate with the floor and the receipt checks, bound
|
||||
for WebAssembly, each call an operation named `noun_verb` and answering the command line's JSON
|
||||
envelope.
|
||||
- **`wai dev` serves Studio at `/_wai/`** on the origin it already serves, when Studio's files
|
||||
are found (`$WAI_STUDIO`, or `share/wai/studio` beside the binary), with the dev stack's own
|
||||
routes for the page under `/_wai/api/`, answered to this origin alone. It opens no listener.
|
||||
|
||||
### `wai breaking` and `wai conform`: the compatibility gate, and conformance anyone can run
|
||||
|
||||
- **`wai breaking --against <git-ref>`** (operation `compat.check`) reports what the working
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const where = (s) => (s.lines?.length ? (s.lines.length === 1 ? `line ${s.lines[
|
|||
export function critiquePanel({ onShow, onFix, onMark }) {
|
||||
const head = el('div', { class: 'qs-chips', id: 'qs-measures' });
|
||||
const list = el('ul', { class: 'qs-smells', id: 'qs-smells' });
|
||||
const node = el('div', { class: 'qs-critique', id: 'qs-critique', 'aria-label': 'The engine’s critique' }, head, list);
|
||||
const node = el('div', { class: 'qs-critique', id: 'qs-critique', role: 'group', 'aria-label': 'The engine’s critique' }, head, list);
|
||||
let last = null; // the latest critique
|
||||
let base = null; // the measures when the program last ran
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
//! relay's WebSocket upgrade), prints how long it took, and stops: exit 0 when every check
|
||||
//! held. Without it, `wai dev` runs until Ctrl-C (which stops the relay and the service
|
||||
//! with it) or `--for` seconds.
|
||||
//!
|
||||
//! The same origin serves WAI Studio at `/_wai/` when its files are found ([`studio`]): the
|
||||
//! page, and the dev stack's own routes for it under `/_wai/api/`. It opens no listener.
|
||||
|
||||
use super::{flag, opt, project};
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -23,6 +26,9 @@ use std::process::{Child, Command, Stdio};
|
|||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[path = "studio.rs"]
|
||||
mod studio;
|
||||
|
||||
pub const USAGE: &str = "wai dev [--port P] [--dir D] [--site DIR] [--watch FILE [--watch-ms MS]] [--call] [--remote]\n [--show [--audience N]] [--messaging] [--e2ee] [--publishers N] [--check [--json]] [--for S]\n a local relay, room service and room page on one origin (`wai dev --help`)";
|
||||
|
||||
const HELP: &str = "wai dev — a relay, a room service and the room page on one origin, on this machine
|
||||
|
|
@ -47,8 +53,9 @@ const HELP: &str = "wai dev — a relay, a room service and the room page on one
|
|||
--relay BIN joule-moq-relay (beside this binary, then $PATH; $WAI_RELAY_BIN)
|
||||
--room BIN joule-moq-room (beside this binary, then $PATH; $WAI_ROOM_BIN)
|
||||
|
||||
A wai.json in the current directory (`wai create` writes one) gives the defaults; a flag
|
||||
wins over it. Ctrl-C stops everything.";
|
||||
WAI Studio is served at /_wai/ when its files are found ($WAI_STUDIO, or share/wai/studio
|
||||
beside this binary). A wai.json in the current directory (`wai create` writes one) gives
|
||||
the defaults; a flag wins over it. Ctrl-C stops everything.";
|
||||
|
||||
/// The relay's WebSocket path on the origin.
|
||||
const RELAY_PATH: &str = "/moq";
|
||||
|
|
@ -77,6 +84,8 @@ struct Opts {
|
|||
site: Option<PathBuf>,
|
||||
page: Option<String>,
|
||||
web: PathBuf,
|
||||
/// WAI Studio's files, served at `/_wai/`, when they are found.
|
||||
studio: Option<PathBuf>,
|
||||
relay: PathBuf,
|
||||
room: PathBuf,
|
||||
publishers: u32,
|
||||
|
|
@ -176,13 +185,14 @@ fn options(args: &[String]) -> Result<Opts, String> {
|
|||
}
|
||||
let exe_dir = std::env::current_exe().ok().and_then(|e| std::fs::canonicalize(e).ok()).and_then(|e| e.parent().map(Path::to_path_buf));
|
||||
let web = find_web(opt(args, "--web"), exe_dir.as_deref())?;
|
||||
let studio = studio::find(exe_dir.as_deref());
|
||||
let relay = find_bin("joule-moq-relay", opt(args, "--relay"), "WAI_RELAY_BIN", exe_dir.as_deref())?;
|
||||
let room = find_bin("joule-moq-room", opt(args, "--room"), "WAI_ROOM_BIN", exe_dir.as_deref())?;
|
||||
let dir = opt(args, "--dir").map(PathBuf::from).unwrap_or_else(|| cwd.join(".wai").join("dev"));
|
||||
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||
let dir = std::fs::canonicalize(&dir).map_err(|e| e.to_string())?;
|
||||
let for_s = num("--for")?;
|
||||
Ok(Opts { port, dir, site, page: d.page.clone(), web, relay, room, publishers, mode, messaging, e2ee, check: flag(args, "--check"), json: flag(args, "--json"), for_s })
|
||||
Ok(Opts { port, dir, site, page: d.page.clone(), web, studio, relay, room, publishers, mode, messaging, e2ee, check: flag(args, "--check"), json: flag(args, "--json"), for_s })
|
||||
}
|
||||
|
||||
/// The duration of a WAV file (RIFF/WAVE, PCM or not): its data chunk over its byte rate.
|
||||
|
|
@ -411,12 +421,16 @@ fn run(o: Opts, started: Instant) -> Result<bool, String> {
|
|||
std::fs::write(&room_cfg, room_toml(&o, svc, ws)).map_err(|e| e.to_string())?;
|
||||
let mut procs: Vec<Proc> = Vec::new();
|
||||
let mut times: Vec<(&str, f64)> = Vec::new();
|
||||
// The loopback address each process listens on, in the order they start.
|
||||
let mut listens: Vec<SocketAddr> = Vec::new();
|
||||
let result = (|| -> Result<Front, String> {
|
||||
procs.push(Proc::start("joule-moq-relay", &o.relay, &["--config", &relay_cfg.to_string_lossy()], o.dir.join("relay.log"), &[])?);
|
||||
listens.push(([127, 0, 0, 1], ws).into());
|
||||
wait_for("the relay's WebSocket binding", 30_000, &mut procs, || TcpStream::connect(("127.0.0.1", ws)).is_ok())?;
|
||||
times.push(("relay", started.elapsed().as_secs_f64()));
|
||||
procs.push(Proc::start("joule-moq-room", &o.room, &["--config", &room_cfg.to_string_lossy()], o.dir.join("room.log"), &[])?);
|
||||
let svc_addr: SocketAddr = ([127, 0, 0, 1], svc).into();
|
||||
listens.push(svc_addr);
|
||||
wait_for("the room service's clock on the relay", 30_000, &mut procs, || {
|
||||
http(svc_addr, "GET", "/health", &[], b"").is_ok_and(|(s, b)| s == 200 && serde_json::from_slice::<Value>(&b).is_ok_and(|v| v["relay"] == true))
|
||||
})?;
|
||||
|
|
@ -429,11 +443,12 @@ fn run(o: Opts, started: Instant) -> Result<bool, String> {
|
|||
std::fs::write(&mcfg, conversation_toml(&o, mport)).map_err(|e| e.to_string())?;
|
||||
procs.push(Proc::start("joule-moq-room (conversation)", &o.room, &["--config", &mcfg.to_string_lossy()], o.dir.join("conversation.log"), &[])?);
|
||||
let maddr: SocketAddr = ([127, 0, 0, 1], mport).into();
|
||||
listens.push(maddr);
|
||||
wait_for("the conversation's service", 30_000, &mut procs, || http(maddr, "GET", "/msg", &[], b"").is_ok_and(|(s, _)| s == 200))?;
|
||||
times.push(("conversation", started.elapsed().as_secs_f64()));
|
||||
services.push((MSG_API.to_string(), maddr));
|
||||
}
|
||||
Ok(Front { site: o.site.clone(), web: o.web.clone(), services, relay: ([127, 0, 0, 1], ws).into(), home: home(&o) })
|
||||
Ok(Front { site: o.site.clone(), web: o.web.clone(), services, relay: ([127, 0, 0, 1], ws).into(), home: home(&o), studio: studio::Studio::new(o.studio.clone(), &o.dir) })
|
||||
})();
|
||||
let routes = match result {
|
||||
Ok(r) => Arc::new(r),
|
||||
|
|
@ -442,6 +457,7 @@ fn run(o: Opts, started: Instant) -> Result<bool, String> {
|
|||
return Err(format!("{e}\n(logs and configuration in {})", o.dir.display()));
|
||||
}
|
||||
};
|
||||
routes.studio.ready(origin.port(), stack_state(&o, origin, &procs, &listens, ×));
|
||||
{
|
||||
let r = routes.clone();
|
||||
std::thread::spawn(move || serve(front, r));
|
||||
|
|
@ -500,6 +516,46 @@ fn home(o: &Opts) -> String {
|
|||
}
|
||||
}
|
||||
|
||||
/// The mode's name.
|
||||
fn mode_name(m: &Mode) -> &'static str {
|
||||
match m {
|
||||
Mode::Room => "room",
|
||||
Mode::Watch { .. } => "watch",
|
||||
Mode::Call => "call",
|
||||
Mode::Remote => "remote",
|
||||
Mode::Show { .. } => "show",
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack as it came up, for Studio's Run panel (`GET /_wai/api/state`): the origin and
|
||||
/// its routes, and each process with the loopback address it listens on and its log.
|
||||
fn stack_state(o: &Opts, origin: SocketAddr, procs: &[Proc], listens: &[SocketAddr], times: &[(&str, f64)]) -> Value {
|
||||
let base = format!("http://{origin}");
|
||||
let mut services = vec![json!({ "name": "wai dev (the origin)", "pid": std::process::id(), "listen": origin.to_string(), "log": null })];
|
||||
for (p, at) in procs.iter().zip(listens) {
|
||||
let log = p.log.file_stem().map(|s| s.to_string_lossy().into_owned());
|
||||
services.push(json!({ "name": p.name, "pid": p.child.id(), "listen": at.to_string(), "log": log }));
|
||||
}
|
||||
let mut pages = vec![json!({ "name": "The room page", "href": home(o) })];
|
||||
if o.messaging {
|
||||
pages.push(json!({ "name": "Messages", "href": format!("/room/messages.html?service={MSG_API}") }));
|
||||
}
|
||||
json!({
|
||||
"ready": true,
|
||||
"origin": base,
|
||||
"room_api": format!("{base}{ROOM_API}"),
|
||||
"relay": format!("ws://{origin}{RELAY_PATH}"),
|
||||
"dir": o.dir.display().to_string(),
|
||||
"mode": mode_name(&o.mode),
|
||||
"publishers": o.publishers,
|
||||
"messaging": o.messaging,
|
||||
"e2ee": o.e2ee,
|
||||
"services": services,
|
||||
"steps": times.iter().map(|(n, t)| json!({ "step": n, "at_s": (t * 1000.0).round() / 1000.0 })).collect::<Vec<_>>(),
|
||||
"pages": pages,
|
||||
})
|
||||
}
|
||||
|
||||
fn announce(o: &Opts, base: &str) {
|
||||
println!("wai dev: ready on {base}");
|
||||
if let Some(s) = &o.site {
|
||||
|
|
@ -525,6 +581,9 @@ fn announce(o: &Opts, base: &str) {
|
|||
if o.messaging {
|
||||
println!(" messages {base}/room/messages.html?service={MSG_API}");
|
||||
}
|
||||
if o.studio.is_some() {
|
||||
println!(" studio {base}/_wai/ WAI Studio, with the local wai");
|
||||
}
|
||||
println!(" room API {base}{ROOM_API}/room");
|
||||
println!(" relay ws://{}{RELAY_PATH} (WebSocket, subprotocol moqt-22)", base.trim_start_matches("http://"));
|
||||
println!(" keys, logs {}", o.dir.display());
|
||||
|
|
@ -576,6 +635,13 @@ fn check(o: &Opts, origin: SocketAddr) -> Vec<Check> {
|
|||
}));
|
||||
}
|
||||
c("the relay's WebSocket binding (moqt-22)", websocket(origin).map_err(|e| e.to_string()));
|
||||
if o.studio.is_some() {
|
||||
c("WAI Studio at /_wai/", get(origin, "/_wai/").and_then(|(s, b)| if s == 200 && String::from_utf8_lossy(&b).contains("wai-studio") { Ok(format!("/_wai/, {} bytes", b.len())) } else { Err(format!("/_wai/: {s}")) }));
|
||||
c("the stack's state for Studio", http(origin, "GET", "/_wai/api/state", &[("x-wai-studio", "1")], b"").map_err(|e| e.to_string()).and_then(|(s, b)| {
|
||||
let v: Value = serde_json::from_slice(&b).map_err(|_| format!("{s}: not JSON"))?;
|
||||
if s == 200 && v["ready"] == true { Ok(format!("{} services on loopback", v["services"].as_array().map_or(0, Vec::len))) } else { Err(format!("{s}: {v}")) }
|
||||
}));
|
||||
}
|
||||
if o.messaging {
|
||||
c("the conversation", get(origin, &format!("{MSG_API}/msg")).and_then(|(s, _)| if s == 200 { Ok("/msg".into()) } else { Err(format!("{s}")) }));
|
||||
}
|
||||
|
|
@ -662,6 +728,8 @@ struct Front {
|
|||
services: Vec<(String, SocketAddr)>,
|
||||
relay: SocketAddr,
|
||||
home: String,
|
||||
/// WAI Studio at `/_wai/`, and the stack's routes for it.
|
||||
studio: studio::Studio,
|
||||
}
|
||||
|
||||
/// The longest request head the origin reads.
|
||||
|
|
@ -673,11 +741,34 @@ fn serve(l: TcpListener, f: Arc<Front>) {
|
|||
for c in l.incoming().flatten() {
|
||||
let f = f.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rest = c.try_clone();
|
||||
let _ = handle(c, &f);
|
||||
if let Ok(c) = rest {
|
||||
linger(c);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Close a connection whose answer has been written. A refusal can go out before the
|
||||
/// request's body is read, and a socket closed with bytes still unread is reset, which can
|
||||
/// take the answer with it: half-close, then read what is left, for a second at most.
|
||||
fn linger(mut c: TcpStream) {
|
||||
let _ = c.shutdown(Shutdown::Write);
|
||||
let until = Instant::now() + Duration::from_secs(1);
|
||||
let mut sink = [0u8; 8192];
|
||||
loop {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || c.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match c.read(&mut sink) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Head {
|
||||
method: String,
|
||||
target: String,
|
||||
|
|
@ -739,6 +830,10 @@ fn handle(mut c: TcpStream, f: &Front) -> std::io::Result<()> {
|
|||
if upgrade && (path == RELAY_PATH || path.starts_with(&format!("{RELAY_PATH}/"))) {
|
||||
return tunnel(c, &h, &rest, f.relay);
|
||||
}
|
||||
// WAI Studio and the dev stack's routes for it, before a site of one's own could shadow them.
|
||||
if path == studio::PREFIX || path.starts_with(&format!("{}/", studio::PREFIX)) {
|
||||
return studio::handle(&mut c, &h, rest, &path, &f.studio);
|
||||
}
|
||||
for (prefix, addr) in &f.services {
|
||||
if path == *prefix || path.starts_with(&format!("{prefix}/")) {
|
||||
return proxy(c, &h, rest, prefix, *addr);
|
||||
|
|
@ -943,7 +1038,13 @@ mod tests {
|
|||
std::fs::create_dir_all(web.join(".wai/dev")).unwrap();
|
||||
std::fs::write(web.join(".wai/dev/room-keys.toml"), "never served").unwrap();
|
||||
std::fs::write(web.join("room/.env"), "never served").unwrap();
|
||||
let f = Arc::new(Front { site: None, web, services: vec![(ROOM_API.into(), svc)], relay, home: "/room/?service=/room-api".into() });
|
||||
// WAI Studio's files, and one outside them that is never served.
|
||||
let studio_files = dir.join("studio");
|
||||
std::fs::create_dir_all(studio_files.join("pkg")).unwrap();
|
||||
std::fs::write(studio_files.join("index.html"), "<main id=wai-studio></main><script src=wai-studio.js></script>").unwrap();
|
||||
std::fs::write(studio_files.join("pkg/wai_studio_core_bg.wasm"), b"\0asm").unwrap();
|
||||
let s = studio::Studio::new(Some(studio_files), dir);
|
||||
let f = Arc::new(Front { site: None, web, services: vec![(ROOM_API.into(), svc)], relay, home: "/room/?service=/room-api".into(), studio: s });
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let a = l.local_addr().unwrap();
|
||||
std::thread::spawn(move || serve(l, f));
|
||||
|
|
@ -1005,6 +1106,65 @@ mod tests {
|
|||
assert_eq!(get(a, "/nothing.js").unwrap().0, 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_origin_serves_studio_at_wai_and_its_api_to_this_origin_alone() {
|
||||
let d = tmp("studio");
|
||||
let (a, _, _) = front(&d);
|
||||
let (s, b) = get(a, "/_wai/").unwrap();
|
||||
assert_eq!((s, String::from_utf8_lossy(&b).contains("wai-studio.js")), (200, true));
|
||||
assert_eq!(get(a, "/_wai").unwrap().0, 301);
|
||||
assert_eq!(get(a, "/_wai/pkg/wai_studio_core_bg.wasm").unwrap(), (200, b"\0asm".to_vec()));
|
||||
for bad in ["/_wai/../secret.txt", "/_wai/%2e%2e/secret.txt", "/_wai/.hidden", "/_wai/nothing.js"] {
|
||||
let (s, b) = get(a, bad).unwrap();
|
||||
assert!(s == 400 || s == 404, "{bad}: {s}");
|
||||
assert!(!String::from_utf8_lossy(&b).contains("never served"), "{bad}");
|
||||
}
|
||||
assert_eq!(http(a, "POST", "/_wai/", &[], b"").unwrap().0, 405);
|
||||
// The API before the stack is up: there is no origin to name yet, so nothing answers.
|
||||
assert_eq!(http(a, "GET", "/_wai/api/state", &[("x-wai-studio", "1")], b"").unwrap().0, 403);
|
||||
assert_eq!(http(a, "GET", "/_wai/api/state", &[], b"").unwrap().0, 403);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn studios_api_answers_this_origin_once_the_stack_is_up() {
|
||||
let d = tmp("studio-api");
|
||||
let (svc, _) = upstream();
|
||||
let (relay, _) = upstream();
|
||||
let web = d.join("web");
|
||||
std::fs::create_dir_all(web.join("room")).unwrap();
|
||||
std::fs::write(d.join("relay.log"), "relay line 1\nrelay line 2\n").unwrap();
|
||||
std::fs::write(d.join("sessions.jsonl"), "{\"session\":1}\nnot json\n{\"session\":2}\n").unwrap();
|
||||
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let a = l.local_addr().unwrap();
|
||||
let s = studio::Studio::new(None, &d);
|
||||
s.ready(a.port(), json!({ "ready": true, "services": [] }));
|
||||
let f = Arc::new(Front { site: None, web, services: vec![(ROOM_API.into(), svc)], relay, home: "/".into(), studio: s });
|
||||
std::thread::spawn(move || serve(l, f));
|
||||
let hd: [(&str, &str); 1] = [("x-wai-studio", "1")];
|
||||
let (st, b) = http(a, "GET", "/_wai/api/state", &hd, b"").unwrap();
|
||||
let v: Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!((st, v["ready"].as_bool(), v["logs"][0].as_str()), (200, Some(true), Some("relay")), "{v}");
|
||||
assert!(v["operations"].as_array().unwrap().iter().any(|o| o == "envelope.inspect"), "{v}");
|
||||
let (st, b) = http(a, "GET", "/_wai/api/log?name=relay&from=0", &hd, b"").unwrap();
|
||||
let v: Value = serde_json::from_slice(&b).unwrap();
|
||||
assert_eq!((st, v["text"].as_str(), v["next"].as_u64()), (200, Some("relay line 1\nrelay line 2\n"), Some(26)));
|
||||
assert_eq!(http(a, "GET", "/_wai/api/log?name=../room-keys", &hd, b"").unwrap().0, 404);
|
||||
let (_, b) = http(a, "GET", "/_wai/api/sessions", &hd, b"").unwrap();
|
||||
assert_eq!(serde_json::from_slice::<Value>(&b).unwrap()["sessions"], json!([{ "session": 1 }, { "session": 2 }]));
|
||||
// Studio's files are not installed: /_wai/ says how to get them.
|
||||
let (st, b) = get(a, "/_wai/").unwrap();
|
||||
assert!(st == 404 && String::from_utf8_lossy(&b).contains("WAI_STUDIO"));
|
||||
// Another origin's page, and a name for this machine that is not this origin's.
|
||||
assert_eq!(http(a, "POST", "/_wai/api/op/envelope.inspect", &[("x-wai-studio", "1"), ("origin", "https://evil.example")], b"x").unwrap().0, 403);
|
||||
let mut c = TcpStream::connect(a).unwrap();
|
||||
c.write_all(b"GET /_wai/api/state HTTP/1.1\r\nHost: rebound.example:80\r\nX-WAI-Studio: 1\r\nConnection: close\r\n\r\n").unwrap();
|
||||
let mut got = String::new();
|
||||
c.read_to_string(&mut got).unwrap();
|
||||
assert!(got.starts_with("HTTP/1.1 403"), "{got}");
|
||||
// An operation the local wai does not run for the page.
|
||||
assert_eq!(http(a, "POST", "/_wai/api/op/project.create", &hd, b"").unwrap().0, 404);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wav_files_duration_is_read_from_its_header() {
|
||||
let d = tmp("wav");
|
||||
|
|
@ -1031,7 +1191,7 @@ mod tests {
|
|||
#[test]
|
||||
fn the_configuration_names_the_origins_paths() {
|
||||
let o = Opts {
|
||||
port: 8080, dir: PathBuf::from("/d"), site: None, page: None, web: PathBuf::from("/w"), relay: PathBuf::from("/r"), room: PathBuf::from("/m"),
|
||||
port: 8080, dir: PathBuf::from("/d"), site: None, page: None, web: PathBuf::from("/w"), studio: None, relay: PathBuf::from("/r"), room: PathBuf::from("/m"),
|
||||
publishers: 3, mode: Mode::Room, messaging: false, e2ee: false, check: false, json: false, for_s: None,
|
||||
};
|
||||
let r = room_toml(&o, 9000, 9001);
|
||||
|
|
|
|||
411
wai/wai-rs/src/bin/wai_cli/dx/studio.rs
Normal file
411
wai/wai-rs/src/bin/wai_cli/dx/studio.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
//! WAI Studio, served by `wai dev` at `/_wai/` on the origin it already serves on loopback:
|
||||
//! no listener of its own. The page's files are Studio's build (`wai/wai-studio`): from
|
||||
//! `$WAI_STUDIO`, else `share/wai/studio` beside this binary's directory (an installed
|
||||
//! archive's layout). Beside them, under `/_wai/api/`, the dev stack's own routes for the
|
||||
//! page:
|
||||
//!
|
||||
//! - `GET /_wai/api/state`: the stack as `wai dev` started it: its origin, room service and
|
||||
//! relay, each service with its process and the loopback address it listens on, its logs,
|
||||
//! and what the local `wai` decodes and encodes;
|
||||
//! - `GET /_wai/api/log?name=<relay|room|conversation>&from=<byte>`: a log from a byte on;
|
||||
//! - `GET /_wai/api/sessions`: the relay's session records (`sessions.jsonl`);
|
||||
//! - `POST /_wai/api/op/<id>`: one of [`LOCAL_OPS`], run by this `wai` on the bytes the page
|
||||
//! sends, in a directory of its own under the dev directory, removed after. The answer is
|
||||
//! its exit status, what it printed and the files it wrote.
|
||||
//!
|
||||
//! Every API request must name this origin as its host (`127.0.0.1:<port>`, `localhost:<port>`
|
||||
//! or `[::1]:<port>`), which a page reached through another name cannot; must carry
|
||||
//! `X-WAI-Studio: 1`, a header no page of another origin can send without a preflight this
|
||||
//! origin never answers; and, when it states an origin or a fetch site, must state this one.
|
||||
|
||||
use super::{Head, respond};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Read;
|
||||
use std::net::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Studio's path on the origin.
|
||||
pub(super) const PREFIX: &str = "/_wai";
|
||||
const API: &str = "/_wai/api";
|
||||
|
||||
/// The operations the local `wai` runs for the page, by their catalogue ids, each with the
|
||||
/// command it runs. Studio's drift check (`wai/wai-studio/scripts/actions.mjs`) holds this
|
||||
/// table to the catalogue and to Studio's bindings.
|
||||
const LOCAL_OPS: &[(&str, &str)] = &[
|
||||
("envelope.inspect", "inspect"),
|
||||
("media.decode", "decode"),
|
||||
("media.encode", "encode"),
|
||||
];
|
||||
|
||||
/// The codecs `wai encode` takes in this build, and what each encodes from.
|
||||
fn encodes() -> &'static [&'static str] {
|
||||
if cfg!(feature = "codecs") {
|
||||
&["png", "jpeg", "avif", "jxl", "jxl-lossless", "opus", "flac", "zstd", "xz"]
|
||||
} else if cfg!(feature = "floor") {
|
||||
&["png", "zstd"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
const IMAGE: &[&str] = &["png", "jpeg", "avif", "jxl", "jxl-lossless"];
|
||||
const AUDIO: &[&str] = &["opus", "flac"];
|
||||
|
||||
/// The longest body the page sends an operation.
|
||||
const MAX_INPUT: usize = 64 << 20;
|
||||
/// The most an operation answers with: what it wrote, all of it.
|
||||
const MAX_OUTPUT: usize = 64 << 20;
|
||||
/// What it printed, each stream, at most.
|
||||
const MAX_PRINTED: usize = 1 << 20;
|
||||
/// How long an operation may run.
|
||||
const OP_SECONDS: u64 = 120;
|
||||
|
||||
pub(super) struct Studio {
|
||||
/// Studio's files (`index.html` and the rest), when they are found.
|
||||
pub(super) files: Option<PathBuf>,
|
||||
/// Where operations run: `<dev dir>/studio/`.
|
||||
work: PathBuf,
|
||||
/// The dev directory: keys, configuration, logs and the relay's session records.
|
||||
dir: PathBuf,
|
||||
/// The origin's port, which every API request must name.
|
||||
port: Mutex<u16>,
|
||||
state: Mutex<Value>,
|
||||
started: Instant,
|
||||
exe: Option<PathBuf>,
|
||||
one_at_a_time: Mutex<()>,
|
||||
next: AtomicU64,
|
||||
}
|
||||
|
||||
impl Studio {
|
||||
pub(super) fn new(files: Option<PathBuf>, dir: &Path) -> Studio {
|
||||
Studio {
|
||||
files,
|
||||
work: dir.join("studio"),
|
||||
dir: dir.to_path_buf(),
|
||||
port: Mutex::new(0),
|
||||
state: Mutex::new(Value::Null),
|
||||
started: Instant::now(),
|
||||
exe: std::env::current_exe().ok(),
|
||||
one_at_a_time: Mutex::new(()),
|
||||
next: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The stack is up on `port`: its state, as `GET /_wai/api/state` gives it.
|
||||
pub(super) fn ready(&self, port: u16, mut state: Value) {
|
||||
*self.port.lock().unwrap_or_else(|e| e.into_inner()) = port;
|
||||
let logs: Vec<&str> = ["relay", "room", "conversation"].into_iter().filter(|n| self.dir.join(format!("{n}.log")).is_file()).collect();
|
||||
state["logs"] = json!(logs);
|
||||
state["decodes"] = json!(crate::native_capabilities());
|
||||
state["encodes"] = json!(encodes());
|
||||
state["operations"] = json!(LOCAL_OPS.iter().map(|(id, _)| id).collect::<Vec<_>>());
|
||||
*self.state.lock().unwrap_or_else(|e| e.into_inner()) = state;
|
||||
}
|
||||
}
|
||||
|
||||
/// Studio's files: `$WAI_STUDIO`, or `share/wai/studio` beside the binary's directory, each
|
||||
/// taken when it holds `index.html`.
|
||||
pub(super) fn find(exe_dir: Option<&Path>) -> Option<PathBuf> {
|
||||
let candidates: Vec<PathBuf> = match std::env::var_os("WAI_STUDIO") {
|
||||
Some(p) => vec![PathBuf::from(p)],
|
||||
None => exe_dir.map(|d| vec![d.join("../share/wai/studio"), d.join("share/wai/studio")]).unwrap_or_default(),
|
||||
};
|
||||
candidates.into_iter().find(|c| c.join("index.html").is_file()).and_then(|c| std::fs::canonicalize(c).ok())
|
||||
}
|
||||
|
||||
/// A request under `/_wai`.
|
||||
pub(super) fn handle(c: &mut TcpStream, h: &Head, rest: Vec<u8>, path: &str, s: &Studio) -> std::io::Result<()> {
|
||||
if path == API || path.starts_with(&format!("{API}/")) {
|
||||
return api(c, h, rest, &path[API.len()..], s);
|
||||
}
|
||||
let head_only = h.method == "HEAD";
|
||||
if h.method != "GET" && !head_only {
|
||||
return respond(c, "405 Method Not Allowed", "text/plain", b"method\n", false, &[("Allow", "GET, HEAD")]);
|
||||
}
|
||||
if path == PREFIX {
|
||||
return respond(c, "301 Moved Permanently", "text/plain", b"", head_only, &[("Location", "/_wai/")]);
|
||||
}
|
||||
let Some(root) = &s.files else {
|
||||
return respond(
|
||||
c,
|
||||
"404 Not Found",
|
||||
"text/plain; charset=utf-8",
|
||||
b"WAI Studio's files are not here: build them (wai/wai-studio: node build.mjs), then give their dist/ as WAI_STUDIO, or install the archive that holds share/wai/studio\n",
|
||||
head_only,
|
||||
&[],
|
||||
);
|
||||
};
|
||||
let Some(rel) = super::clean(&path[PREFIX.len()..]) else {
|
||||
return respond(c, "400 Bad Request", "text/plain", b"path\n", head_only, &[]);
|
||||
};
|
||||
let mut p = root.join(&rel);
|
||||
if p.is_dir() {
|
||||
p = p.join("index.html");
|
||||
}
|
||||
if !p.is_file() {
|
||||
return respond(c, "404 Not Found", "text/plain", b"not found\n", head_only, &[]);
|
||||
}
|
||||
let body = std::fs::read(&p)?;
|
||||
respond(c, "200 OK", super::mime(&p), &body, head_only, &[])
|
||||
}
|
||||
|
||||
fn answer(c: &mut TcpStream, status: &str, v: &Value) -> std::io::Result<()> {
|
||||
respond(c, status, "application/json", format!("{v}\n").as_bytes(), false, &[])
|
||||
}
|
||||
|
||||
/// Why a request to the API is refused, or `None`: it must name this origin as its host,
|
||||
/// carry `X-WAI-Studio: 1`, and state no other origin or fetch site.
|
||||
fn refused(h: &Head, port: u16) -> Option<&'static str> {
|
||||
let ours = [format!("127.0.0.1:{port}"), format!("localhost:{port}"), format!("[::1]:{port}")];
|
||||
if port == 0 || !h.header("host").is_some_and(|v| ours.iter().any(|o| o.eq_ignore_ascii_case(v.trim()))) {
|
||||
return Some("this origin answers requests that name it as their host");
|
||||
}
|
||||
if h.header("x-wai-studio").map(str::trim) != Some("1") {
|
||||
return Some("a request to the dev stack's API carries X-WAI-Studio: 1");
|
||||
}
|
||||
if let Some(o) = h.header("origin")
|
||||
&& !ours.iter().any(|x| o.trim().eq_ignore_ascii_case(&format!("http://{x}")))
|
||||
{
|
||||
return Some("a request from a page of another origin");
|
||||
}
|
||||
if h.header("sec-fetch-site").is_some_and(|v| !v.trim().eq_ignore_ascii_case("same-origin")) {
|
||||
return Some("a request from a page of another origin");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn query<'a>(target: &'a str, key: &str) -> Option<&'a str> {
|
||||
target.split_once('?')?.1.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v))
|
||||
}
|
||||
|
||||
fn api(c: &mut TcpStream, h: &Head, rest: Vec<u8>, sub: &str, s: &Studio) -> std::io::Result<()> {
|
||||
let port = *s.port.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(why) = refused(h, port) {
|
||||
return answer(c, "403 Forbidden", &json!({ "error": why }));
|
||||
}
|
||||
match (h.method.as_str(), sub) {
|
||||
("GET", "/state") => {
|
||||
let mut v = s.state.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
if v.is_null() {
|
||||
return answer(c, "503 Service Unavailable", &json!({ "error": "the stack is starting" }));
|
||||
}
|
||||
v["uptime_s"] = json!(s.started.elapsed().as_secs_f64());
|
||||
answer(c, "200 OK", &v)
|
||||
}
|
||||
("GET", "/log") => {
|
||||
let name = query(&h.target, "name").unwrap_or("");
|
||||
if !["relay", "room", "conversation"].contains(&name) {
|
||||
return answer(c, "404 Not Found", &json!({ "error": "no such log" }));
|
||||
}
|
||||
let from: u64 = query(&h.target, "from").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let bytes = std::fs::read(s.dir.join(format!("{name}.log"))).unwrap_or_default();
|
||||
let size = bytes.len() as u64;
|
||||
// A log read from its last 64 KiB when the page asks from further back.
|
||||
let at = if size.saturating_sub(from) > 64 << 10 { size - (64 << 10) } else { from.min(size) };
|
||||
let text = String::from_utf8_lossy(&bytes[at as usize..]).into_owned();
|
||||
answer(c, "200 OK", &json!({ "name": name, "from": at, "next": size, "text": text }))
|
||||
}
|
||||
("GET", "/sessions") => {
|
||||
let t = std::fs::read_to_string(s.dir.join("sessions.jsonl")).unwrap_or_default();
|
||||
let lines: Vec<Value> = t.lines().filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||
let last = lines[lines.len().saturating_sub(50)..].to_vec();
|
||||
answer(c, "200 OK", &json!({ "sessions": last }))
|
||||
}
|
||||
("POST", op) if op.starts_with("/op/") => {
|
||||
let id = op.trim_start_matches("/op/").split('?').next().unwrap_or("");
|
||||
if h.header("transfer-encoding").is_some() {
|
||||
return answer(c, "411 Length Required", &json!({ "error": "a body with a length" }));
|
||||
}
|
||||
let len: usize = h.header("content-length").and_then(|v| v.trim().parse().ok()).unwrap_or(0);
|
||||
if len > MAX_INPUT {
|
||||
return answer(c, "413 Content Too Large", &json!({ "error": "the input is larger than the page may send" }));
|
||||
}
|
||||
let mut body = rest;
|
||||
body.truncate(len);
|
||||
let mut chunk = [0u8; 65536];
|
||||
while body.len() < len {
|
||||
let n = c.read(&mut chunk[..(len - body.len()).min(65536)])?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
match run_op(s, id, &h.target, &body) {
|
||||
Ok(v) => answer(c, "200 OK", &v),
|
||||
Err((status, why)) => answer(c, status, &json!({ "error": why })),
|
||||
}
|
||||
}
|
||||
_ => answer(c, "404 Not Found", &json!({ "error": "no such route" })),
|
||||
}
|
||||
}
|
||||
|
||||
/// One operation, run by this `wai` in a directory of its own.
|
||||
fn run_op(s: &Studio, id: &str, target: &str, body: &[u8]) -> Result<Value, (&'static str, String)> {
|
||||
let Some((_, verb)) = LOCAL_OPS.iter().find(|(i, _)| *i == id) else {
|
||||
return Err(("404 Not Found", format!("{id}: not an operation the local wai runs for the page")));
|
||||
};
|
||||
let exe = s.exe.clone().ok_or(("500 Internal Server Error", "this binary's path is unknown".to_string()))?;
|
||||
let _one = s.one_at_a_time.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let n = s.next.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = s.work.join(format!("op-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
|
||||
let r = (|| {
|
||||
let write = |name: &str, b: &[u8]| std::fs::write(dir.join(name), b).map_err(|e| ("500 Internal Server Error", e.to_string()));
|
||||
let num = |k: &str| query(target, k).and_then(|v| v.parse::<u32>().ok());
|
||||
const NOTHING: &[&str] = &[];
|
||||
const DECODED: &[&str] = &["out", "out.meta"];
|
||||
const ENCODED: &[&str] = &["out.wai"];
|
||||
let (argv, outputs): (Vec<String>, &[&str]) = match *verb {
|
||||
"inspect" => {
|
||||
write("in.wai", body)?;
|
||||
(vec!["inspect".into(), "in.wai".into()], NOTHING)
|
||||
}
|
||||
"decode" => {
|
||||
write("in.wai", body)?;
|
||||
(vec!["decode".into(), "in.wai".into(), "-o".into(), "out".into()], DECODED)
|
||||
}
|
||||
_ => {
|
||||
let codec = query(target, "codec").unwrap_or("");
|
||||
if !encodes().contains(&codec) {
|
||||
return Err(("400 Bad Request", format!("{codec}: this wai encodes {}", encodes().join(", "))));
|
||||
}
|
||||
let input = if IMAGE.contains(&codec) {
|
||||
let (Some(w), Some(h)) = (num("width"), num("height")) else {
|
||||
return Err(("400 Bad Request", "a picture takes its width and height".into()));
|
||||
};
|
||||
if (w as usize) * (h as usize) * 3 != body.len() {
|
||||
return Err(("400 Bad Request", format!("{} bytes is not {w} × {h} packed RGB8", body.len())));
|
||||
}
|
||||
write("in.rgb", body)?;
|
||||
write("in.rgb.meta", format!("{h} {w}\n").as_bytes())?;
|
||||
"in.rgb"
|
||||
} else if AUDIO.contains(&codec) {
|
||||
let Some(rate) = num("sample_rate") else {
|
||||
return Err(("400 Bad Request", "a sound takes its sample rate".into()));
|
||||
};
|
||||
write("in.f32", body)?;
|
||||
write("in.f32.meta", format!("{rate}\n").as_bytes())?;
|
||||
"in.f32"
|
||||
} else {
|
||||
write("in.bin", body)?;
|
||||
"in.bin"
|
||||
};
|
||||
let q = num("quality").unwrap_or(75).clamp(1, 100);
|
||||
(vec!["encode".into(), codec.into(), input.into(), "-o".into(), "out.wai".into(), "-q".into(), q.to_string()], ENCODED)
|
||||
}
|
||||
};
|
||||
let out = std::fs::File::create(dir.join(".stdout")).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
|
||||
let err = std::fs::File::create(dir.join(".stderr")).map_err(|e| ("500 Internal Server Error", e.to_string()))?;
|
||||
let mut child = Command::new(&exe)
|
||||
.args(&argv)
|
||||
.current_dir(&dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(out)
|
||||
.stderr(err)
|
||||
.env("NO_COLOR", "1")
|
||||
.spawn()
|
||||
.map_err(|e| ("500 Internal Server Error", format!("{}: {e}", exe.display())))?;
|
||||
let end = Instant::now() + Duration::from_secs(OP_SECONDS);
|
||||
let status = loop {
|
||||
if let Some(st) = child.try_wait().map_err(|e| ("500 Internal Server Error", e.to_string()))? {
|
||||
break st;
|
||||
}
|
||||
if Instant::now() > end {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(("504 Gateway Timeout", format!("{id}: not done in {OP_SECONDS} s")));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
};
|
||||
let printed = |name: &str| {
|
||||
let mut b = std::fs::read(dir.join(name)).unwrap_or_default();
|
||||
b.truncate(MAX_PRINTED);
|
||||
String::from_utf8_lossy(&b).into_owned()
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0usize;
|
||||
for name in outputs {
|
||||
if let Ok(b) = std::fs::read(dir.join(name)) {
|
||||
total += b.len();
|
||||
if total > MAX_OUTPUT {
|
||||
return Err(("413 Content Too Large", format!("what {id} wrote is larger than the page takes; `wai {}` writes it", argv.join(" "))));
|
||||
}
|
||||
files.push(json!({ "name": name, "data": base64(&b) }));
|
||||
}
|
||||
}
|
||||
let mut shown = vec!["wai".to_string()];
|
||||
shown.extend(argv);
|
||||
Ok(json!({ "op": id, "ok": status.success(), "exit": status.code(), "argv": shown, "stdout": printed(".stdout"), "stderr": printed(".stderr"), "outputs": files }))
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
r
|
||||
}
|
||||
|
||||
/// Standard base64 with padding (RFC 4648 §4).
|
||||
fn base64(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::with_capacity(b.len().div_ceil(3) * 4);
|
||||
for ch in b.chunks(3) {
|
||||
let n = (u32::from(ch[0]) << 16) | (u32::from(*ch.get(1).unwrap_or(&0)) << 8) | u32::from(*ch.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
if i <= ch.len() {
|
||||
s.push(T[(n >> (18 - 6 * i) & 63) as usize] as char);
|
||||
} else {
|
||||
s.push('=');
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_is_rfc_4648s() {
|
||||
// RFC 4648 §10's test vectors.
|
||||
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
||||
assert_eq!(base64(i.as_bytes()), o);
|
||||
}
|
||||
}
|
||||
|
||||
fn head(method: &str, target: &str, headers: &[(&str, &str)]) -> Head {
|
||||
Head { method: method.into(), target: target.into(), headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_api_answers_only_this_origin_by_its_own_name() {
|
||||
let ok = [("Host", "127.0.0.1:8080"), ("X-WAI-Studio", "1")];
|
||||
assert_eq!(refused(&head("GET", "/_wai/api/state", &ok), 8080), None);
|
||||
assert_eq!(refused(&head("GET", "/", &[("Host", "localhost:8080"), ("X-WAI-Studio", "1"), ("Origin", "http://localhost:8080"), ("Sec-Fetch-Site", "same-origin")]), 8080), None);
|
||||
// Another name for this machine (a rebound name), another port, no host at all.
|
||||
for host in ["evil.example:8080", "127.0.0.1:8081", "127.0.0.1"] {
|
||||
assert!(refused(&head("GET", "/", &[("Host", host), ("X-WAI-Studio", "1")]), 8080).is_some(), "{host}");
|
||||
}
|
||||
assert!(refused(&head("GET", "/", &[("X-WAI-Studio", "1")]), 8080).is_some());
|
||||
// Without the header a page of another origin cannot send unasked.
|
||||
assert!(refused(&head("POST", "/", &[("Host", "127.0.0.1:8080")]), 8080).is_some());
|
||||
// A page of another origin, by its Origin or its fetch site.
|
||||
assert!(refused(&head("POST", "/", &[("Host", "127.0.0.1:8080"), ("X-WAI-Studio", "1"), ("Origin", "https://evil.example")]), 8080).is_some());
|
||||
assert!(refused(&head("POST", "/", &[("Host", "127.0.0.1:8080"), ("X-WAI-Studio", "1"), ("Sec-Fetch-Site", "cross-site")]), 8080).is_some());
|
||||
// Before the stack is up there is no origin to name.
|
||||
assert!(refused(&head("GET", "/", &ok), 0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_operations_are_operations_of_the_catalogue_by_name() {
|
||||
for (id, verb) in LOCAL_OPS {
|
||||
let (noun, v) = id.split_once('.').expect("noun.verb");
|
||||
assert!(!noun.is_empty() && !v.contains('.'), "{id}");
|
||||
assert!(["inspect", "decode", "encode"].contains(verb), "{id} runs `wai {verb}`");
|
||||
}
|
||||
assert_eq!(query("/op/x?codec=png&width=3", "width"), Some("3"));
|
||||
assert_eq!(query("/op/x?codec=png", "height"), None);
|
||||
}
|
||||
}
|
||||
215
wai/wai-rs/tests/cli_studio.rs
Normal file
215
wai/wai-rs/tests/cli_studio.rs
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
//! `wai dev` serves WAI Studio at `/_wai/`, on the origin it already serves on loopback, and
|
||||
//! nothing new listens beyond loopback: run as a person runs it, against a stand-in relay and
|
||||
//! room service (tests/fixtures/dev-stack/fake-stack.py), with Studio's files from
|
||||
//! `$WAI_STUDIO`.
|
||||
//!
|
||||
//! - `/_wai/` answers with Studio's page, and `/_wai/api/state` with the stack;
|
||||
//! - every TCP socket the stack listens on, `wai dev`'s and each service's, is bound to a
|
||||
//! loopback address, and `wai dev` itself listens on one socket, the origin;
|
||||
//! - the local wai runs an operation for the page: `envelope.inspect` on an envelope the
|
||||
//! page sends, and, in a build with the floor, `media.encode` and `media.decode`.
|
||||
//!
|
||||
//! It runs where a process's listening sockets can be read: Linux (/proc) and macOS (lsof).
|
||||
#![cfg_attr(not(any(target_os = "linux", target_os = "macos")), allow(dead_code, unused_imports))]
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wai-studio-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn fake() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dev-stack/fake-stack.py")
|
||||
}
|
||||
|
||||
/// `wai dev` started in `dir`, and the origin it printed.
|
||||
fn start(dir: &Path, studio: &Path) -> (Child, SocketAddr) {
|
||||
let web = dir.join("web");
|
||||
std::fs::create_dir_all(web.join("room")).unwrap();
|
||||
std::fs::write(web.join("room/index.html"), "<script type=module src=app.js></script>").unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wai"))
|
||||
.current_dir(dir)
|
||||
.args(["dev", "--port", "0", "--for", "120", "--dir", "dev"])
|
||||
.arg("--web").arg(&web)
|
||||
.arg("--relay").arg(fake())
|
||||
.arg("--room").arg(fake())
|
||||
.env("WAI_STUDIO", studio)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.expect("wai dev runs");
|
||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
||||
let end = Instant::now() + Duration::from_secs(60);
|
||||
let origin = loop {
|
||||
assert!(Instant::now() < end, "wai dev did not come up");
|
||||
let l = lines.next().expect("wai dev printed its origin").unwrap();
|
||||
if let Some(rest) = l.strip_prefix("wai dev: ready on http://") {
|
||||
break rest.trim().parse::<SocketAddr>().unwrap();
|
||||
}
|
||||
};
|
||||
// The rest of what it prints is drained, so it never blocks on a full pipe.
|
||||
std::thread::spawn(move || for _ in lines {});
|
||||
(child, origin)
|
||||
}
|
||||
|
||||
/// One HTTP/1.1 request, and the answer's status and body.
|
||||
fn http(addr: SocketAddr, method: &str, path: &str, headers: &[(&str, &str)], body: &[u8]) -> (u16, Vec<u8>) {
|
||||
let mut s = TcpStream::connect(addr).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(150))).unwrap();
|
||||
let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len());
|
||||
for (k, v) in headers {
|
||||
req += &format!("{k}: {v}\r\n");
|
||||
}
|
||||
s.write_all(format!("{req}\r\n").as_bytes()).unwrap();
|
||||
s.write_all(body).unwrap();
|
||||
let mut all = Vec::new();
|
||||
s.read_to_end(&mut all).unwrap();
|
||||
let end = all.windows(4).position(|w| w == b"\r\n\r\n").expect("a response head");
|
||||
let status = String::from_utf8_lossy(&all[..end]).split_whitespace().nth(1).unwrap().parse().unwrap();
|
||||
(status, all[end + 4..].to_vec())
|
||||
}
|
||||
|
||||
const API: [(&str, &str); 1] = [("x-wai-studio", "1")];
|
||||
|
||||
fn json(addr: SocketAddr, method: &str, path: &str, body: &[u8]) -> serde_json::Value {
|
||||
let (s, b) = http(addr, method, path, &API, body);
|
||||
assert_eq!(s, 200, "{method} {path}: {}", String::from_utf8_lossy(&b));
|
||||
serde_json::from_slice(&b).unwrap()
|
||||
}
|
||||
|
||||
/// The local addresses of the TCP sockets process `pid` listens on.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn listening(pid: u32) -> Vec<String> {
|
||||
let mut inodes = std::collections::HashSet::new();
|
||||
for e in std::fs::read_dir(format!("/proc/{pid}/fd")).unwrap().flatten() {
|
||||
if let Ok(l) = std::fs::read_link(e.path())
|
||||
&& let Some(i) = l.to_string_lossy().strip_prefix("socket:[").and_then(|r| r.strip_suffix(']'))
|
||||
{
|
||||
inodes.insert(i.to_string());
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
|
||||
for line in std::fs::read_to_string(table).unwrap_or_default().lines().skip(1) {
|
||||
let f: Vec<&str> = line.split_whitespace().collect();
|
||||
// local_address, rem_address, st (0A: LISTEN), …, inode.
|
||||
if f.len() > 9 && f[3] == "0A" && inodes.contains(f[9]) {
|
||||
let (ip, port) = f[1].split_once(':').unwrap();
|
||||
let port = u16::from_str_radix(port, 16).unwrap();
|
||||
let ip = if ip.len() == 8 {
|
||||
let n = u32::from_str_radix(ip, 16).unwrap();
|
||||
std::net::Ipv4Addr::from(n.to_le_bytes()).to_string()
|
||||
} else {
|
||||
let b: Vec<u8> = (0..16).map(|i| u8::from_str_radix(&ip[2 * i..2 * i + 2], 16).unwrap()).collect();
|
||||
let mut w = [0u8; 16];
|
||||
for g in 0..4 {
|
||||
for k in 0..4 {
|
||||
w[4 * g + k] = b[4 * g + 3 - k];
|
||||
}
|
||||
}
|
||||
format!("[{}]", std::net::Ipv6Addr::from(w))
|
||||
};
|
||||
out.push(format!("{ip}:{port}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn listening(pid: u32) -> Vec<String> {
|
||||
let o = Command::new("lsof").args(["-nP", "-a", "-p", &pid.to_string(), "-iTCP", "-sTCP:LISTEN", "-Fn"]).output().expect("lsof");
|
||||
String::from_utf8_lossy(&o.stdout).lines().filter_map(|l| l.strip_prefix('n')).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
fn loopback(addr: &str) -> bool {
|
||||
addr.starts_with("127.") || addr.starts_with("[::1]:")
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
#[test]
|
||||
fn wai_dev_serves_studio_at_wai_and_nothing_listens_beyond_loopback() {
|
||||
let d = tmp("dev");
|
||||
let studio = d.join("studio");
|
||||
std::fs::create_dir_all(&studio).unwrap();
|
||||
std::fs::write(studio.join("index.html"), "<main id=wai-studio data-host=local></main><script type=module src=wai-studio.js></script>").unwrap();
|
||||
let (mut wai, origin) = start(&d, &studio);
|
||||
assert!(origin.ip().is_loopback(), "the origin: {origin}");
|
||||
|
||||
let (s, page) = http(origin, "GET", "/_wai/", &[], b"");
|
||||
assert_eq!((s, String::from_utf8_lossy(&page).contains("wai-studio.js")), (200, true), "/_wai/ answers with Studio's page");
|
||||
let state = json(origin, "GET", "/_wai/api/state", b"");
|
||||
assert_eq!(state["ready"], true, "{state}");
|
||||
let services = state["services"].as_array().unwrap();
|
||||
assert_eq!(services.len(), 3, "the origin, the relay and the room service: {state}");
|
||||
|
||||
// Every socket each process listens on is on loopback; wai dev's own is the origin alone.
|
||||
for svc in services {
|
||||
let pid = svc["pid"].as_u64().unwrap() as u32;
|
||||
let addrs = listening(pid);
|
||||
assert!(!addrs.is_empty(), "{} (pid {pid}) listens", svc["name"]);
|
||||
for a in &addrs {
|
||||
assert!(loopback(a), "{} (pid {pid}) listens on {a}, beyond loopback", svc["name"]);
|
||||
}
|
||||
assert!(loopback(svc["listen"].as_str().unwrap()), "{svc}");
|
||||
if pid == wai.id() {
|
||||
assert_eq!(addrs, vec![origin.to_string()], "wai dev listens on its origin alone");
|
||||
}
|
||||
}
|
||||
|
||||
// The local wai runs an operation for the page: an envelope it wraps itself, inspected.
|
||||
let env = d.join("x.wai");
|
||||
std::fs::write(d.join("x.txt"), "WAI Studio").unwrap();
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(&d).args(["wrap", "wai.text.zstd", "x.txt", "-o"]).arg(&env).output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let r = json(origin, "POST", "/_wai/api/op/envelope.inspect", &std::fs::read(&env).unwrap());
|
||||
assert_eq!((r["op"].as_str(), r["ok"].as_bool()), (Some("envelope.inspect"), Some(true)), "{r}");
|
||||
assert!(r["stdout"].as_str().unwrap().contains("wai.text.zstd"), "{r}");
|
||||
assert_eq!(r["argv"], serde_json::json!(["wai", "inspect", "in.wai"]));
|
||||
|
||||
if cfg!(feature = "floor") {
|
||||
let text = "the floor, through the local wai ".repeat(40);
|
||||
let r = json(origin, "POST", "/_wai/api/op/media.encode?codec=zstd&quality=5", text.as_bytes());
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let wai_b64 = r["outputs"][0]["data"].as_str().unwrap();
|
||||
let envelope = b64(wai_b64);
|
||||
let r = json(origin, "POST", "/_wai/api/op/media.decode", &envelope);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let out = r["outputs"].as_array().unwrap().iter().find(|o| o["name"] == "out").unwrap();
|
||||
assert_eq!(b64(out["data"].as_str().unwrap()), text.as_bytes(), "decoded byte for byte");
|
||||
}
|
||||
// Nothing of an operation is left behind.
|
||||
let left: Vec<_> = std::fs::read_dir(d.join("dev/studio")).map(|r| r.flatten().map(|e| e.file_name()).collect()).unwrap_or_default();
|
||||
assert!(left.is_empty(), "{left:?}");
|
||||
|
||||
let _ = wai.kill();
|
||||
let _ = wai.wait();
|
||||
for svc in services.iter().skip(1) {
|
||||
let _ = Command::new("kill").arg(svc["pid"].as_u64().unwrap().to_string()).status();
|
||||
}
|
||||
}
|
||||
|
||||
fn b64(s: &str) -> Vec<u8> {
|
||||
let val = |c: u8| match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => panic!("not base64: {c}"),
|
||||
};
|
||||
let b: Vec<u8> = s.bytes().filter(|c| *c != b'=').collect();
|
||||
let mut out = Vec::new();
|
||||
for ch in b.chunks(4) {
|
||||
let n = ch.iter().enumerate().fold(0u32, |n, (i, c)| n | (u32::from(val(*c)) << (18 - 6 * i)));
|
||||
out.extend_from_slice(&n.to_be_bytes()[1..ch.len()]);
|
||||
}
|
||||
out
|
||||
}
|
||||
81
wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py
vendored
Executable file
81
wai/wai-rs/tests/fixtures/dev-stack/fake-stack.py
vendored
Executable file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""A stand-in for the relay and the room service, for tests/cli_studio.rs: what `wai dev`
|
||||
asks of each, and nothing more.
|
||||
|
||||
fake-stack.py keygen|relay-lines <keys.toml> prints relay lines (comments here)
|
||||
fake-stack.py --config <relay.toml|room.toml> listens where the configuration says
|
||||
|
||||
A relay's configuration names `websocket_listen`; it accepts connections and closes them. A
|
||||
room service's names `listen`; it answers GET /health as a service whose clock is on the
|
||||
relay, and GET /room with an empty room. Either exits when the process that started it is
|
||||
gone. Standard library only.
|
||||
"""
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
|
||||
def watch_parent():
|
||||
parent = os.getppid()
|
||||
while True:
|
||||
time.sleep(0.5)
|
||||
if os.getppid() != parent:
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def listen_addr(config, key):
|
||||
text = open(config, encoding="utf-8").read()
|
||||
m = re.search(r'^%s\s*=\s*"([^"]+)"' % key, text, re.M)
|
||||
host, port = m.group(1).rsplit(":", 1)
|
||||
return host, int(port)
|
||||
|
||||
|
||||
class Room(http.server.BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/health"):
|
||||
body = {"ok": True, "relay": True, "room": "00" * 32}
|
||||
elif self.path.startswith("/room"):
|
||||
body = {"room": "00" * 32, "t0_us": 0, "descriptor": "", "grants": []}
|
||||
else:
|
||||
self.send_error(404)
|
||||
return
|
||||
b = json.dumps(body).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def main(argv):
|
||||
if argv[:1] in (["keygen"], ["relay-lines"]):
|
||||
open(argv[1], "a").close()
|
||||
print("# authority %s\n# clock %s" % ("11" * 32, "22" * 32))
|
||||
return 0
|
||||
if argv[:1] != ["--config"]:
|
||||
print("fake-stack.py: keygen|relay-lines <keys> or --config <file>", file=sys.stderr)
|
||||
return 2
|
||||
threading.Thread(target=watch_parent, daemon=True).start()
|
||||
config = argv[1]
|
||||
if "websocket_listen" in open(config, encoding="utf-8").read():
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind(listen_addr(config, "websocket_listen"))
|
||||
s.listen(16)
|
||||
while True:
|
||||
c, _ = s.accept()
|
||||
c.close()
|
||||
http.server.ThreadingHTTPServer(listen_addr(config, "listen"), Room).serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
4
wai/wai-studio/.gitignore
vendored
Normal file
4
wai/wai-studio/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
test/screenshots/
|
||||
e2e/screenshots/
|
||||
133
wai/wai-studio/README.md
Normal file
133
wai/wai-studio/README.md
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
# WAI Studio on the web
|
||||
|
||||
One front end for the WAI toolchain, with two hosts, chosen when the page starts:
|
||||
|
||||
- **hosted**: the core in WebAssembly, in the tab. What you open is read there; nothing is
|
||||
uploaded, and there is no account. The WAI site serves it at `/studio/`.
|
||||
- **local**: served by `wai dev` at `/_wai/`, on the origin it already serves on loopback.
|
||||
The core in WebAssembly does what a tab can, and the local `wai` does the rest, reached
|
||||
through the dev stack's own routes (`/_wai/api/`).
|
||||
|
||||
## The panels
|
||||
|
||||
| Panel | Hosted | Through `wai dev` |
|
||||
|---|---|---|
|
||||
| Inspect | A `.wai` file opened or dropped: its envelope, its capabilities with the registry's row for each, its provenance (what it refines, what it was derived from, its security), SPEC §4's verdict at this sink, and its receipts when its receipt index is opened with it | The same, and the local `wai`'s own report, with every codec it holds |
|
||||
| Verify and receipts | Receipt indexes with their group receipts, and the object where it is opened too, checked in the tab by the verifier `wai verify` runs, each line with its reason code | The same |
|
||||
| Encode, decode, package | The mandatory floor: decode PNG, FLAC and zstd, encode PNG and zstd; any registered capability's bytes wrapped in an envelope | The same, and every codec the local `wai` holds |
|
||||
| Rooms | A room's signed description from its service; join it as a viewer, through the room client; a pre-call test of a relay (its WebSocket binding and its SETUP, timed) | The same, against the dev room |
|
||||
| Run | — | The stack `wai dev` started: its routes, each service and the loopback address it listens on, the room service's health, the relay's session records, each log as it grows |
|
||||
| Quantum | Quantum Studio (`../quantum-studio/`), unchanged, as a panel | The same |
|
||||
|
||||
The **command palette** (the top bar's button, or Ctrl+K, ⌘K on macOS) lists every operation
|
||||
of the WAI catalogue by its id. One this host runs opens the panel that runs it; one it does
|
||||
not says where it runs: its command line, its `wai mcp` tool, the service route it is served
|
||||
at, or Studio served by `wai dev`.
|
||||
|
||||
## The actions come from the catalogue
|
||||
|
||||
The operation catalogue (`../catalogue/ops.json`, written by `wai ops export` from the table
|
||||
in `wai-rs`) names every operation. `scripts/actions.mjs` generates Studio's action table from
|
||||
it (`src/actions.generated.js`), and `src/bindings.js` binds by hand the operations Studio
|
||||
runs: their panel, and how each host runs them (in the tab, through the local `wai`, or by the
|
||||
page). `node scripts/actions.mjs --check` fails on drift:
|
||||
|
||||
- an action with no operation: a binding, a call of the core, or an operation `wai dev` runs
|
||||
for the page, whose id the catalogue does not hold;
|
||||
- an operation placed `client` or `both` (it runs on this side of a service, as a page does)
|
||||
with no action;
|
||||
- a mismatched name: the generated table not what the catalogue generates; a binding run in
|
||||
the tab whose call is not the core's `noun_verb`; a call of the core or an operation of
|
||||
`wai dev` bound to another host than the one that runs it; an operation id in the source that
|
||||
names no operation.
|
||||
|
||||
Until the catalogue is on main, `ops.pending.json` is a copy of its export from the pull
|
||||
request that brings it, and the check reads that; once `../catalogue/ops.json` exists, the
|
||||
check reads it and fails until the copy is deleted.
|
||||
|
||||
## The core
|
||||
|
||||
`core/` (`wai-studio-core`) is the `wai` crate the command line is built from, with the floor
|
||||
and the receipt checks, bound for WebAssembly. Each call is an operation of the catalogue,
|
||||
named `noun_verb`, and answers the JSON envelope (`envelope/1`) the command line prints with
|
||||
`--json`: `envelope_inspect`, `envelope_wrap`, `envelope_unwrap`, `media_decode`,
|
||||
`media_encode`, `receipt_verify`, `registry_list`. It reads no file and opens no connection.
|
||||
|
||||
```sh
|
||||
cd core && cargo build --locked --release --target wasm32-unknown-unknown
|
||||
wasm-bindgen --target web --out-dir pkg --out-name wai_studio_core \
|
||||
"$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/wai_studio_core.wasm"
|
||||
```
|
||||
|
||||
The floor's zstd is C: its archive for wasm32 needs an archiver that indexes WebAssembly
|
||||
objects, `llvm-ar` (`AR_wasm32_unknown_unknown`); macOS's own `ar` writes one the linker finds
|
||||
no symbol in. `e2e/run.sh` picks `llvm-ar` where there is one.
|
||||
|
||||
## Building and hosting
|
||||
|
||||
```sh
|
||||
npm ci && node build.mjs --core-pkg core/pkg [--quantum ../quantum-studio/dist]
|
||||
```
|
||||
|
||||
writes `dist/`: `wai-studio.js`, `wai-studio-worker.js`, `wai-studio.css`, `tokens.css`, the
|
||||
core in `pkg/`, Quantum Studio in `quantum/` when given, `index.html` (the local host's page)
|
||||
and `hosted.html` (the hosted host standing alone), and `BUILD.json` with each file's SHA-256.
|
||||
|
||||
`wai dev` serves `dist/` at `/_wai/` when it finds it: `$WAI_STUDIO`, or `share/wai/studio`
|
||||
beside the `wai` binary. A host page mounts Studio on an element:
|
||||
|
||||
```html
|
||||
<div id="wai-studio" data-host="hosted" data-embedded
|
||||
data-core-pkg="/studio/pkg/wai_studio_core.js" data-worker="/studio/wai-studio-worker.js"
|
||||
data-service="/room-api" data-room-page="/studio/room.html"
|
||||
data-moq-transport="/room/room-transport.js" data-moq-session="/room/room-session.js"
|
||||
data-moq-pkg="/room/moq/pkg/wai_room_moq.js"
|
||||
data-quantum-script="/quantum-studio/studio.js" data-quantum-css="/quantum-studio/studio.css"
|
||||
data-quantum-pkg="/quantum-ide/pkg/wai_quantum_ide.js" data-quantum-worker="/quantum-studio/worker.js"
|
||||
data-quantum-service="/quantum-jobs"></div>
|
||||
<script type="module" src="/studio/wai-studio.js"></script>
|
||||
```
|
||||
|
||||
## The dev stack's routes for Studio
|
||||
|
||||
`wai dev` opens no listener for Studio. Under `/_wai/api/` on its origin:
|
||||
|
||||
| Route | What it answers |
|
||||
|---|---|
|
||||
| `GET /_wai/api/state` | the stack: origin, room service, relay, each service with its process and loopback address, its logs, what the local `wai` decodes and encodes |
|
||||
| `GET /_wai/api/log?name=&from=` | a log (`relay`, `room`, `conversation`) from a byte on |
|
||||
| `GET /_wai/api/sessions` | the relay's session records |
|
||||
| `POST /_wai/api/op/<id>` | `envelope.inspect`, `media.decode` or `media.encode`, run by the local `wai` on the bytes sent, in a directory of its own, removed after |
|
||||
|
||||
Every request must name the origin as its host (`127.0.0.1:<port>`, `localhost:<port>` or
|
||||
`[::1]:<port>`), carry `X-WAI-Studio: 1`, and state no other origin or fetch site.
|
||||
|
||||
## Testing it
|
||||
|
||||
```sh
|
||||
bash e2e/run.sh [--browsers chromium,webkit,firefox]
|
||||
```
|
||||
|
||||
builds `wai`, the relay, the room service, the core, the room client's packages, the quantum
|
||||
engine, Quantum Studio and Studio; runs the unit tests (`test/`: the generation and every rule
|
||||
of the drift check, the palette's "where it runs", the local core's calls, and each panel's
|
||||
calls on the core) and the drift check; then `e2e/studio.e2e.mjs` against a `wai dev` stack, on
|
||||
both hosts, in Chromium, WebKit and Firefox:
|
||||
|
||||
- each panel the host shows loads, and Run on the local host alone;
|
||||
- Inspect and Verify on vectors of `../http-conformance/`, their results those its cases
|
||||
expect;
|
||||
- the floor decoded and encoded in the tab, a payload wrapped and inspected;
|
||||
- the dev room joined as a viewer, and the pre-call test against its relay;
|
||||
- the stack in Run, every service on loopback;
|
||||
- Quantum Studio mounted;
|
||||
- the palette: every operation, one this host runs opening its panel, one it does not saying
|
||||
where it runs;
|
||||
- axe finds no WCAG 2.2 A or AA violation on any panel; no console error, no failed request;
|
||||
nothing scrolls sideways at a phone's width.
|
||||
|
||||
`core/` has its own tests (`cargo test --locked`), and `wai-rs/tests/cli_studio.rs` runs `wai
|
||||
dev` against a stand-in relay and room service: `/_wai/` answers, every socket the stack
|
||||
listens on is on loopback, and the local `wai` runs an operation for the page.
|
||||
|
||||
Apache-2.0.
|
||||
85
wai/wai-studio/build.mjs
Normal file
85
wai/wai-studio/build.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Build WAI Studio into dist/, one front end for both hosts:
|
||||
//
|
||||
// node build.mjs [--core-pkg DIR] [--quantum DIR]
|
||||
//
|
||||
// wai-studio.js the interface, bundled
|
||||
// wai-studio-worker.js the core's worker
|
||||
// wai-studio.css Studio's styles; tokens.css, the site's design tokens, for a page alone
|
||||
// index.html the local host: the page `wai dev` serves at /_wai/
|
||||
// hosted.html the hosted host standing alone, on an origin that serves the room
|
||||
// client at /room/ and its modules at /moq/ and /pkg/ (as `wai dev` does)
|
||||
// pkg/ --core-pkg: the core built for the web (core/, wasm-bindgen --target
|
||||
// web --out-name wai_studio_core)
|
||||
// quantum/ --quantum: Quantum Studio's dist (wai/quantum-studio: node build.mjs
|
||||
// --pkg <its engine package>), its engine in quantum/pkg/
|
||||
// BUILD.json each file's SHA-256
|
||||
//
|
||||
// The site hosts the same files at /studio/ (its scripts/sync-wai-studio.mjs) with a page of
|
||||
// its own. Every served name is distinct from the site's other files: its deploy names files
|
||||
// by their content, keyed by basename.
|
||||
import { build } from 'esbuild';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dist = join(here, 'dist');
|
||||
const arg = (name) => { const i = process.argv.indexOf(name); return i > 0 ? process.argv[i + 1] : null; };
|
||||
const corePkg = arg('--core-pkg');
|
||||
const quantum = arg('--quantum');
|
||||
|
||||
rmSync(dist, { recursive: true, force: true });
|
||||
mkdirSync(dist, { recursive: true });
|
||||
const common = { bundle: true, format: 'esm', target: ['es2022'], minify: true, legalComments: 'eof', logLevel: 'warning' };
|
||||
await build({ ...common, entryPoints: [join(here, 'src/main.js')], outfile: join(dist, 'wai-studio.js') });
|
||||
await build({ ...common, entryPoints: [join(here, 'src/worker.js')], outfile: join(dist, 'wai-studio-worker.js') });
|
||||
copyFileSync(join(here, 'src/studio.css'), join(dist, 'wai-studio.css'));
|
||||
copyFileSync(join(here, 'src/tokens.css'), join(dist, 'tokens.css'));
|
||||
|
||||
if (corePkg) {
|
||||
mkdirSync(join(dist, 'pkg'));
|
||||
for (const f of ['wai_studio_core.js', 'wai_studio_core_bg.wasm']) copyFileSync(join(corePkg, f), join(dist, 'pkg', f));
|
||||
}
|
||||
if (quantum) {
|
||||
mkdirSync(join(dist, 'quantum'));
|
||||
for (const f of ['studio.js', 'worker.js', 'studio.css']) copyFileSync(join(quantum, f), join(dist, 'quantum', f));
|
||||
if (existsSync(join(quantum, 'pkg'))) cpSync(join(quantum, 'pkg'), join(dist, 'quantum', 'pkg'), { recursive: true });
|
||||
}
|
||||
|
||||
// A page: the mount element with its host's attributes. Quantum's attributes only where its
|
||||
// files are, so a build without them shows no Quantum panel.
|
||||
const page = (title, host, extra) => {
|
||||
const q = quantum
|
||||
? ` data-quantum-script="quantum/studio.js" data-quantum-css="quantum/studio.css" data-quantum-pkg="quantum/pkg/wai_quantum_ide.js" data-quantum-worker="quantum/worker.js"`
|
||||
: '';
|
||||
return `<!doctype html>
|
||||
<html lang="en" data-theme="auto">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${title}</title>
|
||||
<link rel="icon" href="data:," />
|
||||
<link rel="stylesheet" href="tokens.css" />
|
||||
<link rel="stylesheet" href="wai-studio.css" />
|
||||
<style>
|
||||
html, body { margin: 0; background: var(--bg-base); color: var(--text-primary); }
|
||||
body { padding: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main id="wai-studio" data-host="${host}" data-core-pkg="pkg/wai_studio_core.js" data-worker="wai-studio-worker.js"${extra}${q}></main>
|
||||
<script type="module" src="wai-studio.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
};
|
||||
writeFileSync(join(dist, 'index.html'), page('WAI Studio', 'local', ' data-api="/_wai/api" data-service="/room-api" data-room-page="/room/"'));
|
||||
writeFileSync(join(dist, 'hosted.html'), page('WAI Studio', 'hosted', ' data-service="/room-api" data-room-page="/room/"'));
|
||||
|
||||
const files = [];
|
||||
const walk = (d) => { for (const n of readdirSync(d)) { const p = join(d, n); if (statSync(p).isDirectory()) walk(p); else files.push(p); } };
|
||||
walk(dist);
|
||||
const sha256 = Object.fromEntries(files.sort().map((p) => [relative(dist, p), createHash('sha256').update(readFileSync(p)).digest('hex')]));
|
||||
writeFileSync(join(dist, 'BUILD.json'), `${JSON.stringify({ source: 'open-standards wai/wai-studio', core: !!corePkg, quantum: !!quantum, sha256 }, null, 2)}\n`);
|
||||
console.log(`built ${dist}${corePkg ? ', with the core' : ''}${quantum ? ', with Quantum Studio' : ''}`);
|
||||
1
wai/wai-studio/core/.gitignore
vendored
Normal file
1
wai/wai-studio/core/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
952
wai/wai-studio/core/Cargo.lock
generated
Normal file
952
wai/wai-studio/core/Cargo.lock
generated
Normal file
|
|
@ -0,0 +1,952 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
|
||||
|
||||
[[package]]
|
||||
name = "blake3"
|
||||
version = "1.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"cc",
|
||||
"cfg-if",
|
||||
"constant_time_eq",
|
||||
"cpufeatures 0.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"indexmap",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "claxon"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bfbf56724aa9eca8afa4fcfadeb479e722935bb2a0900c2d37e0cc477af0688"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkcs8"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||
dependencies = [
|
||||
"der",
|
||||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
"sha2-asm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2-asm"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signature"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "spki"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wai"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"cbindgen",
|
||||
"claxon",
|
||||
"curve25519-dalek",
|
||||
"ed25519-dalek",
|
||||
"image",
|
||||
"num-bigint",
|
||||
"pkg-config",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"wai-ops",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wai-ops"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "wai-studio-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wai",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.127"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
|
||||
[[package]]
|
||||
name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.14.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "8.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.2+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
32
wai/wai-studio/core/Cargo.toml
Normal file
32
wai/wai-studio/core/Cargo.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[package]
|
||||
name = "wai-studio-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.97"
|
||||
license = "Apache-2.0"
|
||||
publish = false
|
||||
description = "WAI Studio's core in the browser: the wai crate's envelope, registry, floor codecs and receipt checks, bound for WebAssembly. Each call is an operation of the WAI operation catalogue, named by it (noun_verb)."
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
# The wai crate the command line is built from, with the mandatory floor (PNG, FLAC, zstd:
|
||||
# no system library) and the receipt checks `wai verify` runs (http-delivery §8, with the
|
||||
# claim classes of jwp-receipts). No other codec, no network, no file system.
|
||||
wai = { path = "../../wai-rs", default-features = false, features = ["floor", "http", "receipt_claims"] }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
# Pinned to the wasm-bindgen release the WAI site's packages and wai-web are built with.
|
||||
wasm-bindgen = "=0.2.127"
|
||||
js-sys = "0.3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
# wasm-pack's wasm-opt (binaryen) refuses the bulk-memory instructions the toolchain emits, and
|
||||
# rustc has already run LTO and the size optimisations; wai-web skips it the same way.
|
||||
wasm-opt = false
|
||||
591
wai/wai-studio/core/src/lib.rs
Normal file
591
wai/wai-studio/core/src/lib.rs
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
//! WAI Studio's core in the browser: the `wai` crate the command line is built from,
|
||||
//! bound for WebAssembly, with nothing but what runs in a tab.
|
||||
//!
|
||||
//! Each call is an operation of the WAI operation catalogue (`wai/catalogue/ops.json`),
|
||||
//! named as the catalogue names it in a code door, `noun_verb`:
|
||||
//!
|
||||
//! | Call | Operation | What it does in the tab |
|
||||
//! |---|---|---|
|
||||
//! | [`envelope_inspect`] | `envelope.inspect` | what a `WAI1` or `WAI2` envelope holds, and SPEC §4's verdict at this sink |
|
||||
//! | [`envelope_wrap`] | `envelope.wrap` | any registered capability's bytes in a `WAI1` envelope, held to the rules `wai wrap` holds it to |
|
||||
//! | [`envelope_unwrap`] | `envelope.unwrap` | a payload, or a `WAI2` component, byte for byte |
|
||||
//! | [`media_decode`] | `media.decode` | the mandatory floor (PNG, FLAC, zstd), dispatched by SPEC §4 |
|
||||
//! | [`media_encode`] | `media.encode` | PNG from RGB8 pixels, zstd from any bytes |
|
||||
//! | [`receipt_verify`] | `receipt.verify` | receipt indexes (http-delivery §8.2) and their group receipts, checked offline |
|
||||
//! | [`registry_list`] | `registry.list` | the capability registry, each row with what this sink decodes |
|
||||
//!
|
||||
//! This sink advertises the mandatory floor alone ([`decodes`]); a verdict says so. Every
|
||||
//! answer is the JSON envelope the command line prints with `--json`:
|
||||
//! `{"wai": "envelope/1", "op": <id>, "ok": true, "data": ...}`, or `"ok": false` with an
|
||||
//! `error` of `code` and `message`. Nothing here reads a file, opens a connection or keeps
|
||||
//! state: the bytes come from the page and the answer goes back to it.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use wai::codecs;
|
||||
use wai::container::{self, WaiMulti};
|
||||
use wai::{Conditioning, Manifest, ModelRequirement, Wai};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// The operations this core runs, by their catalogue ids. Each has a binding named
|
||||
/// `noun_verb` below.
|
||||
pub const OPERATIONS: &[&str] = &[
|
||||
"envelope.inspect",
|
||||
"envelope.wrap",
|
||||
"envelope.unwrap",
|
||||
"media.decode",
|
||||
"media.encode",
|
||||
"receipt.verify",
|
||||
"registry.list",
|
||||
];
|
||||
|
||||
/// The JSON envelope's version, as the command line prints it with `--json`.
|
||||
pub const ENVELOPE: &str = "envelope/1";
|
||||
|
||||
/// The capabilities this sink decodes: the mandatory floor of SPEC §5.
|
||||
pub fn decodes() -> &'static [&'static str] {
|
||||
wai::floor_capabilities()
|
||||
}
|
||||
|
||||
/// A refusal: a stable code and a sentence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Refusal {
|
||||
pub code: &'static str,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
fn refuse(code: &'static str, message: impl Into<String>) -> Refusal {
|
||||
Refusal { code, message: message.into() }
|
||||
}
|
||||
|
||||
/// An operation's answer: its envelope, and any bytes it made.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Answer {
|
||||
pub data: Value,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// The JSON envelope of `op`'s result.
|
||||
pub fn envelope(op: &str, r: &Result<Answer, Refusal>) -> Value {
|
||||
match r {
|
||||
Ok(a) => json!({ "wai": ENVELOPE, "op": op, "ok": true, "data": a.data }),
|
||||
Err(e) => json!({ "wai": ENVELOPE, "op": op, "ok": false, "error": { "code": e.code, "message": e.message } }),
|
||||
}
|
||||
}
|
||||
|
||||
fn media_for(cap: &str) -> &'static str {
|
||||
codecs::capability_media(cap).map_or("binary", codecs::Media::label)
|
||||
}
|
||||
|
||||
/// The registry's row for `cap`: what the command line's `wai capabilities` lists.
|
||||
pub fn registry_row(cap: &str) -> Value {
|
||||
json!({
|
||||
"capability": cap,
|
||||
"registered": codecs::is_known_capability(cap),
|
||||
"media": codecs::capability_media(cap).map(codecs::Media::label),
|
||||
"fidelity": codecs::capability_tier(cap).map(|t| format!("{t:?}")),
|
||||
"decode": if decodes().contains(&cap) { "here" } else { "sink-supplied" },
|
||||
"license": codecs::capability_license(cap).map(codecs::LicenseClass::label),
|
||||
"replicate": codecs::capability_replicate_class(cap).map(codecs::ReplicateClass::label),
|
||||
"role": codecs::capability_role(cap).unwrap_or("standalone"),
|
||||
"candidate": codecs::is_candidate(cap),
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────── registry.list ─────────────────────────────
|
||||
|
||||
/// `registry.list`: every registered capability, its row, and what this sink decodes.
|
||||
pub fn registry() -> Answer {
|
||||
let rows: Vec<Value> = codecs::known_capabilities().iter().map(|c| registry_row(c)).collect();
|
||||
Answer { data: json!({ "capabilities": rows, "decodes": decodes() }), bytes: Vec::new() }
|
||||
}
|
||||
|
||||
// ───────────────────────────── envelope.inspect ─────────────────────────────
|
||||
|
||||
fn check(r: Result<(), impl std::fmt::Display>) -> Value {
|
||||
match r {
|
||||
Ok(()) => json!({ "ok": true }),
|
||||
Err(e) => json!({ "ok": false, "finding": e.to_string() }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `envelope.inspect`: what an envelope holds and how SPEC §4 dispatches it at a sink that
|
||||
/// advertises the floor and holds no parameter set (a pin that applies is refused,
|
||||
/// `prior_not_held`), as `wai inspect` reports it.
|
||||
pub fn inspect(bytes: &[u8]) -> Result<Answer, Refusal> {
|
||||
let version = container::detect_version(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
let known = decodes();
|
||||
let held = |c: &str| known.contains(&c) && codecs::is_dispatchable(c);
|
||||
let mut d = Map::new();
|
||||
d.insert("size".into(), json!(bytes.len()));
|
||||
// Its content hash (jwp-receipts §2.1), the name http-delivery §5.2 serves it under and
|
||||
// its receipt index's (`<cid>.wai.receipts`).
|
||||
d.insert("cid".into(), json!(hex(&wai::jwp_hash::content_hash(bytes))));
|
||||
if version == 1 {
|
||||
let w = Wai::from_bytes(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
let cap = w.capability();
|
||||
let fb = w.fallback();
|
||||
d.insert("version".into(), json!("WAI1"));
|
||||
d.insert("media".into(), json!(w.media()));
|
||||
d.insert("intent".into(), json!(w.intent()));
|
||||
d.insert("kind".into(), json!(w.kind()));
|
||||
d.insert("capability".into(), json!(cap));
|
||||
d.insert("fallback".into(), json!(fb));
|
||||
d.insert("payload_size".into(), json!(w.payload.len()));
|
||||
d.insert(
|
||||
"pin".into(),
|
||||
match w.prior() {
|
||||
Some(p) => json!({ "text": wai::pin::describe_pin(p), "check": check(w.check_pin()) }),
|
||||
None => Value::Null,
|
||||
},
|
||||
);
|
||||
d.insert(
|
||||
"refines".into(),
|
||||
match w.refines() {
|
||||
None => Value::Null,
|
||||
Some(Ok(r)) => json!({ "ok": true, "base": r.base, "layer": r.layer, "span": r.span.map(|s| json!({ "from": s.from, "count": s.count })) }),
|
||||
Some(Err(e)) => json!({ "ok": false, "finding": e.to_string() }),
|
||||
},
|
||||
);
|
||||
d.insert("derived_from".into(), w.manifest.derived_from.clone().unwrap_or(Value::Null));
|
||||
d.insert("security".into(), serde_json::to_value(&w.manifest.security).unwrap_or(Value::Null));
|
||||
let chosen = w.dispatch_verified(|c| known.contains(&c), container::no_parameter_sets()).ok().map(|x| x.capability.to_string());
|
||||
let resolve = |c: &str, role: &str| {
|
||||
let why = if held(c) {
|
||||
"this sink decodes it"
|
||||
} else if codecs::is_companion_only(c) {
|
||||
"companion-only: never dispatched standalone"
|
||||
} else if codecs::is_candidate(c) {
|
||||
"a candidate: unsupported until its payload is pinned"
|
||||
} else if role == "fallback" {
|
||||
"not decoded here either"
|
||||
} else {
|
||||
"not decoded here"
|
||||
};
|
||||
json!({ "role": role, "capability": c, "held": held(c), "why": why, "registry": registry_row(c) })
|
||||
};
|
||||
let mut res = vec![resolve(cap, "capability")];
|
||||
if let Some(f) = fb {
|
||||
res.push(resolve(f, "fallback"));
|
||||
}
|
||||
d.insert("resolution".into(), Value::Array(res));
|
||||
let (code, text) = match chosen.as_deref() {
|
||||
_ if codecs::is_companion_only(cap) => ("not-conforming", "companion-only capability, never dispatched standalone".to_string()),
|
||||
Some(c) if c == cap => ("decodable", "decodable via its capability".to_string()),
|
||||
Some(c) => ("decodable-fallback", format!("decodable via its fallback {c}, if that decoder accepts this payload (SPEC §4 step 5)")),
|
||||
None if fb.is_some_and(codecs::is_companion_only) => ("inert", "inert here: the capability is missing and the fallback is companion-only".to_string()),
|
||||
None if w.prior().is_some() && [Some(cap), fb].into_iter().flatten().any(|c| known.contains(&c) && container::pin_applies_to(c)) => {
|
||||
("pin-not-held", "not decoded here: its pin applies, and this sink holds no parameter set (prior_not_held)".to_string())
|
||||
}
|
||||
None if fb.is_some() => ("inert", "inert here: neither capability is decoded by this sink".to_string()),
|
||||
None => ("inert", "inert here: the capability is not decoded by this sink, and no fallback is declared".to_string()),
|
||||
};
|
||||
d.insert("verdict".into(), json!({ "code": code, "text": text, "decodes_with": chosen }));
|
||||
d.insert("roles".into(), check(w.check_component_roles()));
|
||||
d.insert("candidates".into(), check(w.check_candidates()));
|
||||
let pinned = [Some(cap), fb].into_iter().flatten().any(|c| codecs::pinned_int_payload(c).is_some());
|
||||
let encrypted = !w.manifest.security.as_ref().is_none_or(container::Security::is_plaintext);
|
||||
d.insert(
|
||||
"int_payload".into(),
|
||||
if !pinned {
|
||||
Value::Null
|
||||
} else if encrypted {
|
||||
json!({ "ok": null, "finding": "encrypted: a sink checks it after decrypting (SPEC §6.4)" })
|
||||
} else {
|
||||
check(w.check_int_payload_framing())
|
||||
},
|
||||
);
|
||||
d.insert("manifest".into(), serde_json::to_value(&w.manifest).unwrap_or(Value::Null));
|
||||
} else {
|
||||
let m = WaiMulti::from_bytes(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
d.insert("version".into(), json!("WAI2"));
|
||||
d.insert("media".into(), json!(m.media()));
|
||||
d.insert("intent".into(), json!(m.intent()));
|
||||
let rows: Vec<Value> = m
|
||||
.manifest
|
||||
.renditions
|
||||
.iter()
|
||||
.zip(m.renditions.iter())
|
||||
.enumerate()
|
||||
.map(|(i, (meta, payload))| {
|
||||
json!({
|
||||
"index": i,
|
||||
"bytes": payload.len(),
|
||||
"capability": meta.capability,
|
||||
"kind": meta.kind,
|
||||
"id": meta.id,
|
||||
"held": known.contains(&meta.capability.as_str()),
|
||||
"role": meta.role.as_deref().unwrap_or("rendition"),
|
||||
"companion_of": meta.companion_of,
|
||||
"companion_of_id": meta.companion_of_id,
|
||||
"sha256": meta.sha256.as_deref().map(|s| json!({ "preview": wai::pin::digest_preview(s), "ok": meta.payload_digest_matches(payload) })),
|
||||
"pin": meta.prior.as_ref().map(wai::pin::describe_pin),
|
||||
"derived_from": meta.derived_from,
|
||||
"registry": registry_row(&meta.capability),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
d.insert("renditions".into(), Value::Array(rows));
|
||||
d.insert(
|
||||
"pick".into(),
|
||||
match m.pick(|c| known.contains(&c), container::no_parameter_sets()) {
|
||||
Ok(p) => json!({ "index": p.index, "capability": p.meta.capability, "skipped": p.skipped.iter().map(|(i, s)| json!({ "index": i, "why": s.to_string() })).collect::<Vec<_>>() }),
|
||||
Err(skips) => json!({ "index": null, "skipped": skips.iter().map(|(i, s)| json!({ "index": i, "why": s.to_string() })).collect::<Vec<_>>() }),
|
||||
},
|
||||
);
|
||||
d.insert(
|
||||
"pin_check".into(),
|
||||
match m.check_pins() {
|
||||
Ok(()) => json!({ "ok": true }),
|
||||
Err((i, f)) => json!({ "ok": false, "index": i, "finding": f.to_string() }),
|
||||
},
|
||||
);
|
||||
d.insert("roles".into(), check(m.check_component_roles()));
|
||||
d.insert("candidates".into(), check(m.check_candidates()));
|
||||
d.insert(
|
||||
"int_payload".into(),
|
||||
match m.check_int_payload_framing() {
|
||||
Ok(()) => Value::Null,
|
||||
Err((i, e)) => json!({ "ok": false, "index": i, "finding": e.to_string() }),
|
||||
},
|
||||
);
|
||||
d.insert("manifest".into(), serde_json::to_value(&m.manifest).unwrap_or(Value::Null));
|
||||
}
|
||||
d.insert("decodes".into(), json!(known));
|
||||
Ok(Answer { data: Value::Object(d), bytes: Vec::new() })
|
||||
}
|
||||
|
||||
// ───────────────────────────── envelope.wrap / unwrap ─────────────────────────────
|
||||
|
||||
/// `envelope.wrap`: `payload` in a `WAI1` envelope naming `cap`, held to the rules `wai
|
||||
/// wrap` holds a `WAI1` envelope to: a registered capability, never refinement-only, no
|
||||
/// candidate anywhere, `replicate` only where SPEC §7 allows it unpinned (over the
|
||||
/// fallback too), the payload in the format SPEC §5 pins an integer capability to, and
|
||||
/// the component roles. A `wai.generate.spectral` instruction object is checked by the
|
||||
/// command line alone, so it is refused here.
|
||||
pub fn wrap(cap: &str, payload: &[u8], fallback: Option<&str>, intent: Option<&str>) -> Result<Answer, Refusal> {
|
||||
if !codecs::is_known_capability(cap) {
|
||||
return Err(refuse("capability.unknown", format!("'{cap}' is not a registered capability")));
|
||||
}
|
||||
if codecs::is_refinement_only(cap) {
|
||||
return Err(refuse("capability.refinement-only", format!("{cap} is refinement-only: a layer names what it refines, which a wrap does not write; `wai stage refine` writes one")));
|
||||
}
|
||||
if [Some(cap), fallback].into_iter().flatten().any(|c| c == "wai.generate.spectral") {
|
||||
return Err(refuse("capability.cli-only", "a wai.generate.spectral payload is an instruction object the command line checks: `wai wrap wai.generate.spectral <object> --intent create`"));
|
||||
}
|
||||
container::check_candidates_v1(cap, fallback).map_err(|e| refuse("capability.candidate", e.to_string()))?;
|
||||
let intent = codecs::unpinned_envelope_intent(cap, intent)
|
||||
.map_err(|why| refuse("intent.replicate-refused", format!("not writing intent=replicate over {cap}: {why} (SPEC §7); choose another intent (`improve`, or `create` for a generator)")))?;
|
||||
if let Some(fb) = fallback {
|
||||
codecs::unpinned_envelope_intent(fb, Some(intent))
|
||||
.map_err(|why| refuse("intent.replicate-refused", format!("not writing intent=replicate over {cap} with fallback {fb}: a sink without {cap} decodes through {fb}, and over {fb}: {why} (SPEC §7)")))?;
|
||||
}
|
||||
let requirement = match fallback {
|
||||
Some(fb) => ModelRequirement::new(cap).with_fallback(fb),
|
||||
None => ModelRequirement::new(cap),
|
||||
};
|
||||
let manifest = Manifest::new(media_for(cap), intent, requirement, Conditioning::new("bytes"));
|
||||
let w = Wai::new(manifest, payload.to_vec());
|
||||
w.check_candidates().map_err(|e| refuse("capability.candidate", e.to_string()))?;
|
||||
w.check_int_payload_framing().map_err(|e| refuse("payload.framing", e.to_string()))?;
|
||||
w.check_component_roles().map_err(|e| refuse("envelope.roles", format!("{e} (SPEC §5)")))?;
|
||||
let bytes = w.to_bytes().map_err(|e| refuse("envelope.write", e.to_string()))?;
|
||||
Ok(Answer {
|
||||
data: json!({ "capability": cap, "fallback": fallback, "intent": intent, "media": media_for(cap), "payload_bytes": payload.len(), "envelope_bytes": bytes.len(), "registry": registry_row(cap) }),
|
||||
bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// `envelope.unwrap`: the payload of a `WAI1` envelope (component 0), or component `i`
|
||||
/// of a `WAI2` one; with no component named, the one SPEC §7.1 selects at a sink that
|
||||
/// advertises everything and holds no parameter set, as `wai unwrap` picks it.
|
||||
pub fn unwrap(bytes: &[u8], component: Option<usize>) -> Result<Answer, Refusal> {
|
||||
let version = container::detect_version(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
if version == 1 {
|
||||
if component.is_some_and(|c| c != 0) {
|
||||
return Err(refuse("component.out-of-range", "a WAI1 envelope carries one payload (component 0)"));
|
||||
}
|
||||
let w = Wai::from_bytes(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
return Ok(Answer { data: json!({ "component": 0, "capability": w.capability(), "bytes": w.payload.len() }), bytes: w.payload.clone() });
|
||||
}
|
||||
let m = WaiMulti::from_bytes(bytes).map_err(|e| refuse("envelope.unreadable", e.to_string()))?;
|
||||
let idx = match component {
|
||||
Some(i) => i,
|
||||
None => m.primary(container::no_parameter_sets()).map(|p| p.index).map_err(|_| refuse("component.none-selectable", "no selectable rendition without a pin; name a component"))?,
|
||||
};
|
||||
let (meta, payload) = m
|
||||
.manifest
|
||||
.renditions
|
||||
.get(idx)
|
||||
.zip(m.renditions.get(idx))
|
||||
.ok_or_else(|| refuse("component.out-of-range", format!("component {idx} out of range ({} components)", m.renditions.len())))?;
|
||||
Ok(Answer { data: json!({ "component": idx, "capability": meta.capability, "bytes": payload.len() }), bytes: payload.clone() })
|
||||
}
|
||||
|
||||
// ───────────────────────────── media.decode / encode ─────────────────────────────
|
||||
|
||||
/// `media.decode`: SPEC §4 dispatch against the floor, then the capability's decoder, as
|
||||
/// `wai decode` writes it: PNG as packed RGB8 (`width`, `height`), FLAC as 32-bit float
|
||||
/// little-endian samples (`sample_rate`), zstd as its bytes.
|
||||
pub fn decode(bytes: &[u8]) -> Result<Answer, Refusal> {
|
||||
let w = Wai::from_bytes(bytes).map_err(|e| refuse("envelope.unreadable", format!("{e} (a WAI2 envelope: unwrap one component, then decode it)")))?;
|
||||
let known = decodes();
|
||||
let roles = w.check_component_roles();
|
||||
let cap = match w.dispatch_verified(|c| known.contains(&c), container::no_parameter_sets()) {
|
||||
Ok(d) => d.capability.to_string(),
|
||||
Err(e) => {
|
||||
return Err(match roles {
|
||||
Err(r) => refuse("envelope.roles", format!("{r} (SPEC §5)")),
|
||||
Ok(()) => refuse(
|
||||
"decode.inert",
|
||||
format!(
|
||||
"inert at this sink ({}): {} is not in the floor this tab decodes{}; `wai decode` through `wai dev`, or the command line built with `codecs`, decodes the rest",
|
||||
e.code(),
|
||||
w.capability(),
|
||||
w.fallback().map(|f| format!(", nor is its fallback {f}")).unwrap_or_default()
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
let warning = roles.err().map(|e| format!("the envelope does not conform: {e} (SPEC §5); decoded via {cap}"));
|
||||
if cap == wai::CAP_IMAGE_PNG {
|
||||
let (rgb, h, w) = codecs::image::png_decode(&w.payload).map_err(|e| refuse("decode.failed", format!("{e:?}")))?;
|
||||
Ok(Answer { data: json!({ "capability": cap, "kind": "image", "format": "rgb8", "width": w, "height": h, "warning": warning }), bytes: rgb })
|
||||
} else if cap == wai::CAP_AUDIO_FLAC {
|
||||
let (samples, sr) = codecs::audio::flac_decode(&w.payload).map_err(|e| refuse("decode.failed", format!("{e:?}")))?;
|
||||
let out: Vec<u8> = samples.iter().flat_map(|f| f.to_le_bytes()).collect();
|
||||
// The samples come interleaved, every channel; STREAMINFO (RFC 9639 §8.2), the first
|
||||
// block after `fLaC`, gives how many: three bits, after the 20 of the sample rate.
|
||||
let channels = w.payload.get(20).map(|b| ((b >> 1) & 7) + 1);
|
||||
Ok(Answer { data: json!({ "capability": cap, "kind": "audio", "format": "f32le", "sample_rate": sr, "channels": channels, "samples": samples.len(), "warning": warning }), bytes: out })
|
||||
} else if cap == wai::CAP_TEXT_ZSTD {
|
||||
let out = codecs::text::zstd_decode(&w.payload).map_err(|e| refuse("decode.failed", format!("{e:?}")))?;
|
||||
Ok(Answer { data: json!({ "capability": cap, "kind": "bytes", "format": "bytes", "length": out.len(), "warning": warning }), bytes: out })
|
||||
} else {
|
||||
Err(refuse("decode.inert", format!("{cap} is not in the floor this tab decodes")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The codecs this core encodes, by the names `wai encode` takes: the floor's two that
|
||||
/// need no system library.
|
||||
pub const ENCODES: &[&str] = &["png", "zstd"];
|
||||
|
||||
/// `media.encode`: as `wai encode <codec>` with the floor: `png` from packed RGB8 pixels
|
||||
/// (`width` × `height`), `zstd` from any bytes at level `quality` clamped to 1–22 (the
|
||||
/// command line's `-q`, 75 when not given). The envelope's intent is `replicate`.
|
||||
pub fn encode(codec: &str, input: &[u8], width: u32, height: u32, quality: u8) -> Result<Answer, Refusal> {
|
||||
let (cap, kind, payload) = match codec {
|
||||
"png" => {
|
||||
let need = (width as usize).checked_mul(height as usize).and_then(|n| n.checked_mul(3));
|
||||
if width == 0 || height == 0 || need != Some(input.len()) {
|
||||
return Err(refuse("input.size", format!("png takes packed RGB8: {} bytes is not {width} × {height} × 3", input.len())));
|
||||
}
|
||||
(wai::CAP_IMAGE_PNG, "png", codecs::image::png_encode(input, height, width).map_err(|e| refuse("encode.failed", format!("{e:?}")))?)
|
||||
}
|
||||
"zstd" => (wai::CAP_TEXT_ZSTD, "zstd", codecs::text::zstd_encode(input, (quality as i32).clamp(1, 22)).map_err(|e| refuse("encode.failed", format!("{e:?}")))?),
|
||||
c if codecs::is_known_capability(&format!("wai.image.{c}")) || ["opus", "flac", "xz", "av1", "av1-lossless", "jxl-lossless"].contains(&c) => {
|
||||
return Err(refuse("encode.not-here", format!("{c}: this tab encodes png and zstd; `wai encode {c}` (a build with `codecs`, or through `wai dev`) encodes it")));
|
||||
}
|
||||
c => return Err(refuse("encode.unknown-codec", format!("unknown codec '{c}'"))),
|
||||
};
|
||||
let manifest = Manifest::new(media_for(cap), "replicate", ModelRequirement::new(cap), Conditioning::new(kind));
|
||||
let bytes = Wai::new(manifest, payload).to_bytes().map_err(|e| refuse("envelope.write", e.to_string()))?;
|
||||
Ok(Answer { data: json!({ "codec": codec, "capability": cap, "input_bytes": input.len(), "envelope_bytes": bytes.len() }), bytes })
|
||||
}
|
||||
|
||||
// ───────────────────────────── receipt.verify ─────────────────────────────
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
fn base(path: &str) -> &str {
|
||||
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||
}
|
||||
|
||||
fn dir(path: &str) -> &str {
|
||||
match path.rfind(['/', '\\']) {
|
||||
Some(i) => &path[..i],
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// One receipt index among `files`, checked as `wai verify` checks it: each delivery item's
|
||||
/// group receipt read from `rcpt/<link hash>.json` beside it, its parents by their links,
|
||||
/// jwp-receipts §3 steps 1–5 against the object `<cid>.wai` where it is among the files
|
||||
/// (`delivered-verified`) or against nothing (`inclusion-verified`), and each claim the
|
||||
/// index lists. Lines are the command line's.
|
||||
fn check_index_file(files: &[(String, Vec<u8>)], index: &str, bytes: &[u8]) -> Value {
|
||||
use wai::http_binding::receipts::{Limits, ObjectHeld, RECEIPT_DIR, check_index};
|
||||
use wai::jwp_delivery::ReceiptChains;
|
||||
use wai::jwp_hash::EstablishedHash;
|
||||
|
||||
let name = base(index);
|
||||
let here = dir(index);
|
||||
let cid_hex = name.strip_suffix(".wai.receipts").unwrap_or("");
|
||||
let line = |ok: bool, text: String, code: Option<&str>| json!({ "ok": ok, "text": text, "code": code });
|
||||
let cid: [u8; 32] = match (0..32).map(|i| cid_hex.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect::<Option<Vec<u8>>>() {
|
||||
Some(v) if cid_hex.len() == 64 => v.try_into().expect("32 bytes"),
|
||||
_ => {
|
||||
return json!({ "index": index, "ok": false, "object_held": false,
|
||||
"lines": [line(false, format!("{name}: an index is named <cid>.wai.receipts, the cid 64 hexadecimal digits"), Some("index-name"))] });
|
||||
}
|
||||
};
|
||||
// The object and the group files beside the index: in the same folder where the page
|
||||
// gave folders, else anywhere among the files by name.
|
||||
let find = |want: &str| -> Option<&Vec<u8>> {
|
||||
files.iter().find(|(p, _)| dir(p) == here && base(p) == want).or_else(|| files.iter().find(|(p, _)| base(p) == want)).map(|(_, b)| b)
|
||||
};
|
||||
let object = find(&format!("{cid_hex}.wai"));
|
||||
let held = match object {
|
||||
Some(b) => ObjectHeld::Object(EstablishedHash::of_envelope(b)),
|
||||
None => ObjectHeld::Nothing,
|
||||
};
|
||||
let rcpt = RECEIPT_DIR.trim_end_matches('/');
|
||||
let mut fetch = |h: &[u8; 32], limit: usize| -> Option<Vec<u8>> {
|
||||
let want = format!("{}.json", hex(h));
|
||||
let in_rcpt = |p: &str| base(dir(p)) == rcpt;
|
||||
let mut b = files
|
||||
.iter()
|
||||
.find(|(p, _)| base(p) == want && in_rcpt(p) && dir(dir(p)) == here)
|
||||
.or_else(|| files.iter().find(|(p, _)| base(p) == want))
|
||||
.map(|(_, b)| b.clone())?;
|
||||
b.truncate(limit);
|
||||
Some(b)
|
||||
};
|
||||
let mut chains = ReceiptChains::new();
|
||||
let mut lines = Vec::new();
|
||||
let mut ok = true;
|
||||
let short = |b: &[u8]| hex(b)[..16].to_string();
|
||||
match check_index(&cid, bytes, &held, &mut fetch, &mut chains, 0, &Limits::default(), None) {
|
||||
Err(e) => {
|
||||
ok = false;
|
||||
lines.push(line(false, format!("the index: {} ({e})", e.code()), Some(e.code())));
|
||||
}
|
||||
Ok(r) => {
|
||||
if r.delivery.is_empty() && r.claims.is_empty() {
|
||||
ok = false;
|
||||
lines.push(line(false, "the index lists nothing".into(), Some("index-empty")));
|
||||
}
|
||||
for d in &r.delivery {
|
||||
match d {
|
||||
Ok(v) => lines.push(line(
|
||||
true,
|
||||
format!(
|
||||
"{}: group {} signed by {}… ({}), {}{}",
|
||||
v.evidence.label(),
|
||||
short(&v.group.link_hash()),
|
||||
short(&v.group.signer_pubkey),
|
||||
v.group.signer_id,
|
||||
v.admission.label(),
|
||||
if matches!(held, ObjectHeld::Nothing) { "; the object is not here, so its delivery is not shown" } else { "" }
|
||||
),
|
||||
Some(v.evidence.label()),
|
||||
)),
|
||||
Err(e) => {
|
||||
ok = false;
|
||||
lines.push(line(false, format!("delivery: {} ({e})", e.code()), Some(e.code())));
|
||||
}
|
||||
}
|
||||
}
|
||||
for c in &r.claims {
|
||||
match c {
|
||||
Ok(v) => lines.push(line(true, format!("claim {}: attests {}", v.claim.class().label(), v.attests.label()), Some(v.claim.class().label()))),
|
||||
Err(e) => {
|
||||
ok = false;
|
||||
lines.push(line(false, format!("claim: {} ({e})", e.code()), Some(e.code())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({ "index": index, "cid": cid_hex, "ok": ok, "object_held": object.is_some(), "lines": lines })
|
||||
}
|
||||
|
||||
/// `receipt.verify`: every receipt index (`*.wai.receipts`) among `files` (each a path, as
|
||||
/// the page has it, and its bytes), checked offline. `ok` when every index held.
|
||||
pub fn verify(files: &[(String, Vec<u8>)]) -> Result<Answer, Refusal> {
|
||||
let mut indexes: Vec<&(String, Vec<u8>)> = files.iter().filter(|(p, _)| p.ends_with(".wai.receipts")).collect();
|
||||
indexes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
if indexes.is_empty() {
|
||||
return Err(refuse("receipt.no-index", "no receipt index (*.wai.receipts) among the files"));
|
||||
}
|
||||
let checked: Vec<Value> = indexes.iter().map(|(p, b)| check_index_file(files, p, b)).collect();
|
||||
let all = checked.iter().all(|c| c["ok"] == true);
|
||||
Ok(Answer { data: json!({ "ok": all, "indexes": checked }), bytes: Vec::new() })
|
||||
}
|
||||
|
||||
// ───────────────────────────── the WebAssembly bindings ─────────────────────────────
|
||||
|
||||
/// What a binding answers: the envelope as JSON text, and the bytes the operation made.
|
||||
#[wasm_bindgen]
|
||||
pub struct Output {
|
||||
json: String,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl Output {
|
||||
/// The JSON envelope (`envelope/1`).
|
||||
pub fn json(&self) -> String {
|
||||
self.json.clone()
|
||||
}
|
||||
/// The bytes the operation made (an envelope, a payload, decoded samples), or none.
|
||||
pub fn bytes(&self) -> Vec<u8> {
|
||||
self.bytes.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn output(op: &str, r: Result<Answer, Refusal>) -> Output {
|
||||
let json = envelope(op, &r).to_string();
|
||||
Output { json, bytes: r.map(|a| a.bytes).unwrap_or_default() }
|
||||
}
|
||||
|
||||
/// The operations this core runs and the capabilities it decodes, as JSON.
|
||||
#[wasm_bindgen]
|
||||
pub fn core_describe() -> String {
|
||||
json!({ "operations": OPERATIONS, "decodes": decodes(), "encodes": ENCODES, "envelope": ENVELOPE, "version": env!("CARGO_PKG_VERSION") }).to_string()
|
||||
}
|
||||
|
||||
/// `envelope.inspect` (see [`inspect`]).
|
||||
#[wasm_bindgen]
|
||||
pub fn envelope_inspect(bytes: &[u8]) -> Output {
|
||||
output("envelope.inspect", inspect(bytes))
|
||||
}
|
||||
|
||||
/// `envelope.wrap` (see [`wrap`]).
|
||||
#[wasm_bindgen]
|
||||
pub fn envelope_wrap(capability: &str, payload: &[u8], fallback: Option<String>, intent: Option<String>) -> Output {
|
||||
output("envelope.wrap", wrap(capability, payload, fallback.as_deref().filter(|s| !s.is_empty()), intent.as_deref().filter(|s| !s.is_empty())))
|
||||
}
|
||||
|
||||
/// `envelope.unwrap` (see [`unwrap`]); a negative component names none.
|
||||
#[wasm_bindgen]
|
||||
pub fn envelope_unwrap(bytes: &[u8], component: i32) -> Output {
|
||||
output("envelope.unwrap", unwrap(bytes, usize::try_from(component).ok()))
|
||||
}
|
||||
|
||||
/// `media.decode` (see [`decode`]).
|
||||
#[wasm_bindgen]
|
||||
pub fn media_decode(bytes: &[u8]) -> Output {
|
||||
output("media.decode", decode(bytes))
|
||||
}
|
||||
|
||||
/// `media.encode` (see [`encode`]).
|
||||
#[wasm_bindgen]
|
||||
pub fn media_encode(codec: &str, input: &[u8], width: u32, height: u32, quality: u8) -> Output {
|
||||
output("media.encode", encode(codec, input, width, height, quality))
|
||||
}
|
||||
|
||||
/// `receipt.verify` (see [`verify`]): `paths[i]` names `files[i]` (a `Uint8Array`).
|
||||
#[wasm_bindgen]
|
||||
pub fn receipt_verify(paths: Vec<String>, files: js_sys::Array) -> Output {
|
||||
let given: Vec<(String, Vec<u8>)> = paths.into_iter().zip(files.iter()).map(|(p, f)| (p, js_sys::Uint8Array::new(&f).to_vec())).collect();
|
||||
output("receipt.verify", verify(&given))
|
||||
}
|
||||
|
||||
/// `registry.list` (see [`registry`]).
|
||||
#[wasm_bindgen]
|
||||
pub fn registry_list() -> Output {
|
||||
output("registry.list", Ok(registry()))
|
||||
}
|
||||
198
wai/wai-studio/core/tests/core.rs
Normal file
198
wai/wai-studio/core/tests/core.rs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
//! The Studio core's calls on vectors of the conformance corpora, each read from the tree:
|
||||
//! http-conformance's objects and receipt indexes, and envelopes this core writes and
|
||||
//! reads back. Every answer is held to what `wai` says of the same bytes.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use wai_studio_core::{OPERATIONS, decode, encode, envelope, inspect, registry, unwrap, verify, wrap};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
fn http(rel: &str) -> Vec<u8> {
|
||||
std::fs::read(wai_dir().join("http-conformance").join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}"))
|
||||
}
|
||||
|
||||
// http-conformance/objects.json: O5 a small WAI1 of zstd, O9b and O9c WAI1 AV1 segments with
|
||||
// receipt indexes, O10 a FLAC base with one, O2 a WAI2 picture in four renditions.
|
||||
const O2: &str = "25130382f5d9c73ae59a04d1fbae1463693f3769c090744ebd5f1e80fa3b0a34";
|
||||
const O5: &str = "e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b";
|
||||
const O9A: &str = "31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3";
|
||||
const O9B: &str = "b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb";
|
||||
const O9C: &str = "a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133";
|
||||
const O10: &str = "1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8";
|
||||
|
||||
fn object(cid: &str) -> Vec<u8> {
|
||||
http(&format!("objects/{cid}.wai"))
|
||||
}
|
||||
|
||||
/// The receipt files of http-conformance's objects, as a page holding the folder has them.
|
||||
fn rcpt_files() -> Vec<(String, Vec<u8>)> {
|
||||
let d = wai_dir().join("http-conformance/objects/rcpt");
|
||||
let mut v: Vec<(String, Vec<u8>)> = std::fs::read_dir(&d).unwrap().flatten().map(|e| (format!("objects/rcpt/{}", e.file_name().to_string_lossy()), std::fs::read(e.path()).unwrap())).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reads_a_floor_envelope_and_finds_it_decodable_here() {
|
||||
let a = inspect(&object(O5)).unwrap();
|
||||
assert_eq!(a.data["version"], "WAI1");
|
||||
assert_eq!(a.data["capability"], "wai.text.zstd");
|
||||
assert_eq!(a.data["verdict"]["code"], "decodable", "{}", a.data);
|
||||
assert_eq!(a.data["resolution"][0]["held"], true);
|
||||
assert_eq!(a.data["roles"]["ok"], true);
|
||||
assert_eq!(a.data["size"], 251);
|
||||
assert_eq!(a.data["cid"], O5, "the content hash is the name it is served under");
|
||||
assert!(a.data["manifest"]["model_requirement"].is_object(), "{}", a.data["manifest"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_finds_a_capability_outside_the_floor_inert_here() {
|
||||
let a = inspect(&object(O9B)).unwrap();
|
||||
assert_eq!(a.data["capability"], "wai.video.av1");
|
||||
assert_eq!(a.data["verdict"]["code"], "inert", "{}", a.data);
|
||||
assert_eq!(a.data["resolution"][0]["held"], false);
|
||||
assert_eq!(a.data["resolution"][0]["registry"]["decode"], "sink-supplied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_reads_a_wai2_envelope_and_picks_the_rendition_this_sink_decodes() {
|
||||
let a = inspect(&object(O2)).unwrap();
|
||||
assert_eq!(a.data["version"], "WAI2");
|
||||
let rows = a.data["renditions"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), 4);
|
||||
let png = rows.iter().find(|r| r["capability"] == "wai.image.png").expect("a PNG rendition");
|
||||
assert_eq!(png["held"], true);
|
||||
assert_eq!(a.data["pick"]["capability"], "wai.image.png", "{}", a.data["pick"]);
|
||||
for r in rows {
|
||||
if let Some(d) = r["sha256"].as_object() {
|
||||
assert_eq!(d["ok"], true, "{r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_refuses_bytes_that_are_not_an_envelope() {
|
||||
let e = inspect(b"not an envelope").unwrap_err();
|
||||
assert_eq!(e.code, "envelope.unreadable");
|
||||
let mut bad = object(O5);
|
||||
bad.truncate(20);
|
||||
assert!(inspect(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_runs_the_floor_and_refuses_what_is_outside_it() {
|
||||
let z = decode(&object(O5)).unwrap();
|
||||
assert_eq!(z.data["kind"], "bytes");
|
||||
assert_eq!(z.data["length"].as_u64().unwrap() as usize, z.bytes.len());
|
||||
let f = decode(&object(O10)).unwrap();
|
||||
assert_eq!((f.data["capability"].as_str(), f.data["kind"].as_str()), (Some("wai.audio.flac"), Some("audio")));
|
||||
assert!(f.data["sample_rate"].as_u64().unwrap() > 0);
|
||||
let channels = f.data["channels"].as_u64().unwrap();
|
||||
assert!((1..=8).contains(&channels) && f.data["samples"].as_u64().unwrap().is_multiple_of(channels), "{}", f.data);
|
||||
assert_eq!(f.bytes.len(), 4 * f.data["samples"].as_u64().unwrap() as usize);
|
||||
let e = decode(&object(O9B)).unwrap_err();
|
||||
assert_eq!(e.code, "decode.inert", "{e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_then_decode_gives_the_pixels_and_bytes_back() {
|
||||
let (w, h) = (7u32, 5u32);
|
||||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 37 % 251) as u8).collect();
|
||||
let e = encode("png", &rgb, w, h, 75).unwrap();
|
||||
assert_eq!(e.data["capability"], "wai.image.png");
|
||||
let i = inspect(&e.bytes).unwrap();
|
||||
assert_eq!((i.data["intent"].as_str(), i.data["verdict"]["code"].as_str()), (Some("replicate"), Some("decodable")));
|
||||
let d = decode(&e.bytes).unwrap();
|
||||
assert_eq!((d.data["width"].as_u64(), d.data["height"].as_u64()), (Some(7), Some(5)));
|
||||
assert_eq!(d.bytes, rgb, "PNG is lossless");
|
||||
let text = b"WAI Studio, the floor in a tab".repeat(20);
|
||||
let z = encode("zstd", &text, 0, 0, 75).unwrap();
|
||||
assert_eq!(decode(&z.bytes).unwrap().bytes, text);
|
||||
assert_eq!(encode("png", &rgb[1..], w, h, 75).unwrap_err().code, "input.size");
|
||||
assert_eq!(encode("avif", &rgb, w, h, 75).unwrap_err().code, "encode.not-here");
|
||||
assert_eq!(encode("nope", &rgb, w, h, 75).unwrap_err().code, "encode.unknown-codec");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_and_unwrap_keep_the_payload_byte_for_byte_and_keep_wai_wraps_rules() {
|
||||
let payload = b"\x89PNG not really, but carried as bytes".to_vec();
|
||||
let w = wrap("wai.text.zstd", &payload, None, None).unwrap();
|
||||
assert_eq!(w.data["intent"], "replicate");
|
||||
let u = unwrap(&w.bytes, None).unwrap();
|
||||
assert_eq!(u.bytes, payload);
|
||||
assert_eq!(unwrap(&w.bytes, Some(1)).unwrap_err().code, "component.out-of-range");
|
||||
assert_eq!(wrap("wai.not.registered", &payload, None, None).unwrap_err().code, "capability.unknown");
|
||||
// replicate over a learned codec, unpinned: SPEC §7 refuses it, and another intent carries it.
|
||||
assert_eq!(wrap("wai.neural.mlicpp", &payload, None, None).unwrap_err().code, "intent.replicate-refused");
|
||||
assert!(wrap("wai.neural.mlicpp", &payload, None, Some("improve")).is_ok());
|
||||
// WAI2: a component by index, and with none named the one SPEC §7.1 selects.
|
||||
let o2 = object(O2);
|
||||
let first = unwrap(&o2, Some(0)).unwrap();
|
||||
assert!(!first.bytes.is_empty());
|
||||
assert!(unwrap(&o2, None).is_ok());
|
||||
assert_eq!(unwrap(&o2, Some(99)).unwrap_err().code, "component.out-of-range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_checks_receipt_indexes_as_wai_verify_does() {
|
||||
// O9c held whole: its group and the two parents before it, delivered-verified.
|
||||
let mut files = rcpt_files();
|
||||
files.push((format!("objects/{O9C}.wai"), object(O9C)));
|
||||
files.push((format!("objects/{O9C}.wai.receipts"), http(&format!("objects/{O9C}.wai.receipts"))));
|
||||
let a = verify(&files).unwrap();
|
||||
assert_eq!(a.data["ok"], true, "{}", a.data);
|
||||
let ix = &a.data["indexes"][0];
|
||||
assert_eq!((ix["object_held"].as_bool(), ix["lines"][0]["code"].as_str()), (Some(true), Some("delivered-verified")), "{ix}");
|
||||
|
||||
// Without the object, the receipts hold for inclusion alone.
|
||||
let without: Vec<_> = files.iter().filter(|(p, _)| !p.ends_with(".wai")).cloned().collect();
|
||||
let a = verify(&without).unwrap();
|
||||
assert_eq!(a.data["indexes"][0]["lines"][0]["code"], "inclusion-verified", "{}", a.data);
|
||||
|
||||
// A group whose signature was changed (http-conformance receipts_group_bad_signature).
|
||||
let mut bad = rcpt_files();
|
||||
let cases = wai_dir().join("http-conformance/cases/rcpt");
|
||||
for e in std::fs::read_dir(&cases).unwrap().flatten() {
|
||||
bad.push((format!("cases/rcpt/{}", e.file_name().to_string_lossy()), std::fs::read(e.path()).unwrap()));
|
||||
}
|
||||
bad.push((format!("objects/{O9A}.wai"), object(O9A)));
|
||||
bad.push((format!("objects/{O9A}.wai.receipts"), http("cases/receipts/O9a-bad-signature.wai.receipts")));
|
||||
let a = verify(&bad).unwrap();
|
||||
assert_eq!(a.data["ok"], false, "{}", a.data);
|
||||
assert_eq!(a.data["indexes"][0]["lines"][0]["code"], "signature_verify_failed", "{}", a.data);
|
||||
|
||||
// A byte of the object changed: its content hash is not the receipt's.
|
||||
let mut changed = files.clone();
|
||||
let o = changed.iter_mut().find(|(p, _)| p.ends_with(&format!("{O9C}.wai"))).unwrap();
|
||||
let n = o.1.len();
|
||||
o.1[n - 1] ^= 1;
|
||||
assert_eq!(verify(&changed).unwrap().data["ok"], false);
|
||||
|
||||
assert_eq!(verify(&[("x.wai".into(), vec![1])]).unwrap_err().code, "receipt.no-index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_registry_lists_every_capability_and_marks_the_floor_decoded_here() {
|
||||
let r = registry();
|
||||
let rows = r.data["capabilities"].as_array().unwrap();
|
||||
assert_eq!(rows.len(), wai::codecs::known_capabilities().len());
|
||||
for c in wai::floor_capabilities() {
|
||||
let row = rows.iter().find(|x| x["capability"] == *c).unwrap();
|
||||
assert_eq!(row["decode"], "here", "{row}");
|
||||
}
|
||||
assert!(rows.iter().any(|x| x["decode"] == "sink-supplied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_answer_is_the_command_lines_json_envelope() {
|
||||
for op in OPERATIONS {
|
||||
let (noun, verb) = op.split_once('.').unwrap();
|
||||
assert!(!noun.is_empty() && !verb.is_empty() && !verb.contains('.'), "{op} is noun.verb");
|
||||
}
|
||||
let ok = envelope("envelope.inspect", &inspect(&object(O5)));
|
||||
assert_eq!((ok["wai"].as_str(), ok["op"].as_str(), ok["ok"].as_bool()), (Some("envelope/1"), Some("envelope.inspect"), Some(true)));
|
||||
let no = envelope("envelope.inspect", &inspect(b"x"));
|
||||
assert_eq!((no["ok"].as_bool(), no["error"]["code"].as_str()), (Some(false), Some("envelope.unreadable")));
|
||||
}
|
||||
91
wai/wai-studio/e2e/run.sh
Normal file
91
wai/wai-studio/e2e/run.sh
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/bin/bash
|
||||
# Build what WAI Studio's tests need, and run them: the unit tests, the drift check, and the
|
||||
# browser end-to-end run in Chromium, WebKit and Firefox against a `wai dev` stack.
|
||||
#
|
||||
# bash wai/wai-studio/e2e/run.sh [--browsers chromium,webkit,firefox]
|
||||
#
|
||||
# Two phases, both by default (STUDIO_PHASES="build node"): `build` (1 and 2: Rust and
|
||||
# wasm-bindgen) and `node` (3 to 5: Node and the browsers). They may run in different places
|
||||
# sharing the same paths: WebKit runs where its session can open a window (on Linux, the
|
||||
# Playwright image), the builds where Rust is.
|
||||
#
|
||||
# 1. native: `wai` (the archive's build, `quickstart`), the relay and the room service;
|
||||
# 2. wasm, each through wasm-bindgen's web target: Studio's core (core/), the room client's
|
||||
# packages (wai/quickstart/build-web.sh, the files `wai dev` serves), and the quantum
|
||||
# engine (wai-web, feature quantum_engine) for Quantum Studio;
|
||||
# 3. Quantum Studio's build, then Studio's (build.mjs --core-pkg --quantum), its packages
|
||||
# installed from package-lock.json with no install script run;
|
||||
# 4. the unit tests (test/), the core's among them, and the drift check;
|
||||
# 5. e2e/studio.e2e.mjs: `wai dev` serving Studio at /_wai/, both hosts, every panel, in each
|
||||
# browser, and axe on each.
|
||||
#
|
||||
# Environment: CARGO_TARGET_DIR (under which each workspace gets a target directory of its
|
||||
# own: workspaces that build the `wai` crate with different features must not share one, or
|
||||
# one's library is taken for another's), STUDIO_OUT (where
|
||||
# builds and results go), PLAYWRIGHT_BROWSERS_PATH (the browsers Playwright drives),
|
||||
# CARGO_LOCKED (default --locked), STUDIO_PHASES. Nothing listens beyond loopback.
|
||||
set -euo pipefail
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
STUDIO=$(cd "$HERE/.." && pwd)
|
||||
ROOT=$(cd "$STUDIO/../.." && pwd)
|
||||
JC="$ROOT/joule-code/joule-code-rs"
|
||||
LOCKED=${CARGO_LOCKED---locked}
|
||||
TARGET=${CARGO_TARGET_DIR:-$ROOT/target-studio}
|
||||
# A workspace's own target directory: `inside <dir> <name> cargo …`.
|
||||
inside() { local d="$1" n="$2"; shift 2; (cd "$d" && CARGO_TARGET_DIR="$TARGET/$n" "$@"); }
|
||||
OUT=${STUDIO_OUT:-${TMPDIR:-/tmp}/wai-studio-e2e}
|
||||
mkdir -p "$OUT"
|
||||
step() { echo "== $*"; }
|
||||
PHASES=${STUDIO_PHASES:-build node}
|
||||
phase() { case " $PHASES " in *" $1 "*) return 0 ;; *) return 1 ;; esac; }
|
||||
# The binaries by the names this tree gives them.
|
||||
bin() { local t="$1"; shift; for n in "$@"; do [ -x "$TARGET/$t/release/$n" ] && { echo "$TARGET/$t/release/$n"; return; }; done; echo "none of $* was built" >&2; exit 1; }
|
||||
|
||||
if phase build; then
|
||||
command -v wasm-bindgen >/dev/null || { echo "wasm-bindgen CLI 0.2.127 is needed (cargo install --locked wasm-bindgen-cli --version 0.2.127)"; exit 2; }
|
||||
wasm-bindgen --version | grep -q ' 0\.2\.127$' || { echo "the wasm-bindgen CLI must be 0.2.127, the version the crates pin"; exit 2; }
|
||||
|
||||
step "native: wai, the relay, the room service"
|
||||
inside "$ROOT/wai/wai-rs" wai-rs cargo build $LOCKED --release --no-default-features --features quickstart --bin wai
|
||||
inside "$JC" joule-code cargo build $LOCKED --release -p joule-moq-relay
|
||||
inside "$JC/crates/joule-moq-room" joule-moq-room cargo build $LOCKED --release
|
||||
|
||||
step "wasm: Studio's core"
|
||||
# The floor's zstd is C, compiled for wasm32: the archive needs an archiver that indexes
|
||||
# WebAssembly objects (llvm-ar). macOS's own ar writes one the linker finds no symbol in.
|
||||
if [ -z "${AR_wasm32_unknown_unknown:-}" ]; then
|
||||
for a in llvm-ar /opt/homebrew/opt/llvm/bin/llvm-ar /usr/local/opt/llvm/bin/llvm-ar; do
|
||||
if command -v "$a" >/dev/null 2>&1; then export AR_wasm32_unknown_unknown="$(command -v "$a")"; break; fi
|
||||
done
|
||||
fi
|
||||
if [ -z "${CC_wasm32_unknown_unknown:-}" ] && [ -n "${AR_wasm32_unknown_unknown:-}" ] && [ -x "$(dirname "$AR_wasm32_unknown_unknown")/clang" ]; then
|
||||
export CC_wasm32_unknown_unknown="$(dirname "$AR_wasm32_unknown_unknown")/clang"
|
||||
fi
|
||||
inside "$STUDIO/core" core cargo build $LOCKED --release --target wasm32-unknown-unknown
|
||||
rm -rf "$OUT/core-pkg"
|
||||
wasm-bindgen --target web --out-dir "$OUT/core-pkg" --out-name wai_studio_core "$TARGET/core/wasm32-unknown-unknown/release/wai_studio_core.wasm"
|
||||
rm -f "$OUT"/core-pkg/*.d.ts
|
||||
step "wasm: the room client, as wai dev serves it"
|
||||
CARGO_TARGET_DIR="$TARGET/web" bash "$ROOT/wai/quickstart/build-web.sh" "$OUT/web"
|
||||
step "wasm: the quantum engine"
|
||||
inside "$ROOT/wai/wai-web" wai-web cargo build $LOCKED --release --target wasm32-unknown-unknown --features quantum_engine
|
||||
rm -rf "$OUT/quantum-pkg"
|
||||
wasm-bindgen --target web --out-dir "$OUT/quantum-pkg" --out-name wai_quantum_ide "$TARGET/wai-web/wasm32-unknown-unknown/release/wai_web.wasm"
|
||||
rm -f "$OUT"/quantum-pkg/*.d.ts
|
||||
fi
|
||||
|
||||
phase node || exit 0
|
||||
WAI=$(bin wai-rs wai)
|
||||
RELAY=$(bin joule-code wai-relay joule-moq-relay)
|
||||
ROOM=$(bin joule-moq-room wai-room joule-moq-room)
|
||||
step "Quantum Studio, then WAI Studio"
|
||||
(cd "$ROOT/wai/quantum-studio" && npm ci --ignore-scripts --no-audit --no-fund && node build.mjs --pkg "$OUT/quantum-pkg")
|
||||
(cd "$STUDIO" && npm ci --ignore-scripts --no-audit --no-fund && node build.mjs --core-pkg "$OUT/core-pkg" --quantum "$ROOT/wai/quantum-studio/dist")
|
||||
|
||||
step "unit tests, and the drift check"
|
||||
(cd "$STUDIO" && WAI_STUDIO_CORE_PKG="$OUT/core-pkg" node --test --test-reporter=tap test/*.test.mjs | tee "$OUT/unit.log")
|
||||
grep -qE '^# fail 0$' "$OUT/unit.log" && ! grep -qE '^# skipped [1-9]' "$OUT/unit.log" || { echo "a unit test failed or was skipped"; exit 1; }
|
||||
(cd "$STUDIO" && node scripts/actions.mjs --check)
|
||||
|
||||
step "end-to-end"
|
||||
node "$HERE/studio.e2e.mjs" --wai "$WAI" --relay "$RELAY" --room "$ROOM" --web "$OUT/web" --studio "$STUDIO/dist" --wai-dir "$ROOT/wai" --out "$OUT/results" "$@"
|
||||
281
wai/wai-studio/e2e/studio.e2e.mjs
Normal file
281
wai/wai-studio/e2e/studio.e2e.mjs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
// WAI Studio in Chromium, WebKit and Firefox, against a `wai dev` stack on loopback that
|
||||
// serves it at /_wai/ (e2e/run.sh builds everything this needs):
|
||||
//
|
||||
// node e2e/studio.e2e.mjs --wai BIN --relay BIN --room BIN --web DIR --studio DIST
|
||||
// --wai-dir DIR --out DIR [--browsers chromium,webkit,firefox]
|
||||
//
|
||||
// For each browser, the local host (/_wai/) and the hosted host standing alone
|
||||
// (/_wai/hosted.html, the core in the tab and nothing else):
|
||||
// - each panel the host shows loads, and the Run panel only on the local host;
|
||||
// - Inspect reads a vector of http-conformance (O5, with its receipt index and groups):
|
||||
// its verdict here, its content hash, its receipts delivered-verified, and, on the local
|
||||
// host, the local wai's own report;
|
||||
// - Verify checks http-conformance's receipts as its cases expect them: O9c held whole,
|
||||
// delivered-verified (receipts_walk_two), and O9a's index naming a group whose signature
|
||||
// was changed, signature_verify_failed (receipts_group_bad_signature);
|
||||
// - Encode, decode, package: FLAC decoded in the tab, a picture encoded as PNG and read back,
|
||||
// a payload wrapped and inspected;
|
||||
// - Rooms: the room shown, joined as a viewer through the room client against the stack's
|
||||
// room service and relay, and left; the pre-call test against the stack's relay. A viewer
|
||||
// follows the room's clock, whose events track opens with the room's first participant,
|
||||
// so a publisher is in the room first: the room page itself, in Chromium with its fake
|
||||
// camera and microphone, for the whole run;
|
||||
// - Run (local): the stack, every service on loopback, the room service checked, a log;
|
||||
// - Quantum: Quantum Studio mounted, its first example linted by the engine;
|
||||
// - the command palette: every operation by its id, one this host runs opening its panel,
|
||||
// one it does not saying where it runs;
|
||||
// - axe finds no WCAG 2.2 A or AA violation on any panel (WebKit and Firefox too);
|
||||
// - no console error and no failed request; nothing scrolls sideways at a phone's width.
|
||||
// Exit 0 iff every check passes.
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, all) => (x.startsWith('--') ? [...a, [x.slice(2), all[i + 1]]] : a), []));
|
||||
for (const k of ['wai', 'relay', 'room', 'web', 'studio', 'wai-dir', 'out']) if (!args[k]) { console.error(`--${k} is needed`); process.exit(2); }
|
||||
const BROWSERS = (args.browsers || 'chromium,webkit,firefox').split(',');
|
||||
const OUT = resolve(args.out);
|
||||
const WAI = resolve(args['wai-dir']);
|
||||
const HTTP = join(WAI, 'http-conformance');
|
||||
const SHOTS = join(OUT, 'screenshots');
|
||||
rmSync(OUT, { recursive: true, force: true });
|
||||
mkdirSync(SHOTS, { recursive: true });
|
||||
const pw = (await import(process.env.WAI_E2E_DRIVER ?? 'playwright')).default;
|
||||
const AXE = createRequire(import.meta.url).resolve('axe-core/axe.min.js');
|
||||
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'];
|
||||
const LONG = 90_000;
|
||||
|
||||
let fails = 0;
|
||||
const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails += 1; return ok; };
|
||||
|
||||
// http-conformance's vectors (objects.json).
|
||||
const O5 = 'e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b';
|
||||
const O9A = '31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3';
|
||||
const O9C = 'a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133';
|
||||
const O10 = '1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8';
|
||||
const objects = (...names) => names.map((n) => join(HTTP, 'objects', n));
|
||||
const rcpt = (dir) => readdirSync(join(HTTP, dir)).map((n) => join(HTTP, dir, n));
|
||||
const cases = JSON.parse(readFileSync(join(HTTP, 'cases.json'), 'utf8'));
|
||||
const expect = (name) => cases.find((c) => c.name === name)?.expect.checks[0];
|
||||
|
||||
// ---- the stack ----
|
||||
const proj = join(OUT, 'project');
|
||||
mkdirSync(proj, { recursive: true });
|
||||
const dev = spawn(resolve(args.wai), ['dev', '--port', '0', '--dir', join(OUT, 'dev'), '--web', resolve(args.web), '--relay', resolve(args.relay), '--room', resolve(args.room)], {
|
||||
cwd: proj,
|
||||
env: { ...process.env, WAI_STUDIO: resolve(args.studio) },
|
||||
stdio: ['ignore', 'pipe', 'inherit'],
|
||||
});
|
||||
const origin = await new Promise((ok, no) => {
|
||||
const t = setTimeout(() => no(new Error('wai dev did not come up in 60 s')), 60_000);
|
||||
createInterface({ input: dev.stdout }).on('line', (l) => {
|
||||
process.stdout.write(` wai dev | ${l}\n`);
|
||||
const m = l.match(/^wai dev: ready on (http:\/\/127\.0\.0\.1:\d+)/);
|
||||
if (m) { clearTimeout(t); ok(m[1]); }
|
||||
});
|
||||
dev.on('exit', (c) => no(new Error(`wai dev exited (${c})`)));
|
||||
});
|
||||
const stop = () => { try { dev.kill('SIGTERM'); } catch { /* gone */ } };
|
||||
process.on('exit', stop);
|
||||
|
||||
// The room's first participant: the room page, publishing a fake camera and microphone.
|
||||
async function publisher() {
|
||||
const b = await pw.chromium.launch({ headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
|
||||
const c = await b.newContext({ permissions: ['camera', 'microphone'] });
|
||||
const p = await c.newPage();
|
||||
await p.goto(`${origin}/room/?service=/room-api`, { waitUntil: 'domcontentloaded' });
|
||||
await p.waitForFunction(() => window.roomTest?.state?.phase === 'idle', null, { timeout: LONG });
|
||||
await p.click('#join');
|
||||
const ok = await p.waitForFunction(() => window.roomTest.state.phase === 'publishing', null, { timeout: LONG }).then(() => true, () => false);
|
||||
check(ok, `the room's first participant publishes from the room page (${await p.evaluate(() => JSON.stringify({ phase: window.roomTest.state.phase, errors: window.roomTest.state.errors.slice(0, 1) }))})`);
|
||||
return b;
|
||||
}
|
||||
|
||||
async function axe(page, where, frame = null) {
|
||||
const target = frame || page;
|
||||
await target.addScriptTag({ path: AXE });
|
||||
const r = await target.evaluate((tags) => window.axe.run(document, { runOnly: { type: 'tag', values: tags }, iframes: false }), TAGS);
|
||||
const v = r.violations.map((x) => `${x.id} (${x.impact}): ${x.nodes.slice(0, 3).map((n) => n.target.join(' ')).join(', ')}`);
|
||||
check(v.length === 0, `${where}: axe finds no WCAG 2.2 A/AA violation${v.length ? ': ' + v.join(' | ') : ''}`);
|
||||
}
|
||||
|
||||
async function run(browserName, host) {
|
||||
const tag = `${browserName} · ${host}`;
|
||||
const browser = await pw[browserName].launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1360, height: 980 }, acceptDownloads: true });
|
||||
const page = await ctx.newPage();
|
||||
const seen = { errors: [], failed: [] };
|
||||
page.on('console', (m) => { if (m.type() === 'error') seen.errors.push(m.text()); });
|
||||
page.on('pageerror', (e) => seen.errors.push(String(e)));
|
||||
page.on('requestfailed', (r) => { if (!/ERR_ABORTED|NS_BINDING_ABORTED|cancelled/i.test(r.failure()?.errorText ?? '')) seen.failed.push(`${r.url()} (${r.failure()?.errorText})`); });
|
||||
page.on('response', (r) => { if (r.status() >= 400) seen.failed.push(`${r.url()} (${r.status()})`); });
|
||||
const url = `${origin}/_wai/${host === 'hosted' ? 'hosted.html' : ''}`;
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.waiStudio && /Ready\./.test(document.querySelector('#ws-status')?.textContent ?? ''), null, { timeout: LONG });
|
||||
const tabs = await page.$$eval('[role=tab]', (ts) => ts.map((t) => t.id.replace('ws-tab-', '')));
|
||||
const want = host === 'local' ? ['inspect', 'verify', 'media', 'rooms', 'run', 'quantum'] : ['inspect', 'verify', 'media', 'rooms', 'quantum'];
|
||||
check(JSON.stringify(tabs) === JSON.stringify(want), `${tag}: the panels this host shows (${tabs.join(', ')})`);
|
||||
check((await page.$$('h1')).length === 1 && (await page.textContent('h1')) === 'WAI Studio', `${tag}: one h1, WAI Studio`);
|
||||
check((await page.getAttribute('#ws-host', 'data-host')) === host, `${tag}: the host is chosen at start (${await page.textContent('#ws-host')})`);
|
||||
const tab = async (id) => { await page.click(`#ws-tab-${id}`); await page.waitForSelector(`#ws-panel-${id}:not([hidden])`); };
|
||||
|
||||
// ---- Inspect ----
|
||||
await tab('inspect');
|
||||
await axe(page, `${tag}: Inspect, empty`);
|
||||
await page.setInputFiles('#ws-inspect-file', [...objects(`${O5}.wai`, `${O5}.wai.receipts`), ...rcpt('objects/rcpt')]);
|
||||
await page.waitForSelector('#ws-verdict', { timeout: LONG });
|
||||
await page.waitForFunction(() => document.querySelector('#ws-inspect-receipts')?.childElementCount > 0, null, { timeout: LONG });
|
||||
const ins = await page.evaluate(() => window.waiStudio.ctx.last);
|
||||
check(ins.inspect?.data?.verdict?.code === 'decodable' && ins.inspect.data.cid === 'e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b',
|
||||
`${tag}: Inspect reads http-conformance O5: decodable here, its content hash its name (${ins.inspect?.data?.verdict?.code})`);
|
||||
check(ins.inspectReceipts?.data?.ok === true && ins.inspectReceipts.data.indexes[0].lines[0].code === 'delivered-verified', `${tag}: Inspect checks O5's receipts, delivered-verified`);
|
||||
if (host === 'local') {
|
||||
await page.waitForFunction(() => /wai inspect/.test(document.querySelector('#ws-inspect-local')?.textContent ?? ''), null, { timeout: LONG });
|
||||
const local = await page.evaluate(() => window.waiStudio.ctx.last.inspectLocal);
|
||||
check(local?.ok === true && /wai\.text\.zstd/.test(local.stdout), `${tag}: the local wai's report beside it (${local?.argv?.join(' ')})`);
|
||||
}
|
||||
await page.click('#ws-registry > summary');
|
||||
await page.waitForSelector('#ws-registry-out table', { timeout: LONG });
|
||||
await axe(page, `${tag}: Inspect, an envelope read`);
|
||||
await page.screenshot({ path: join(SHOTS, `${browserName}-${host}-inspect.png`), fullPage: true });
|
||||
|
||||
// ---- Verify ----
|
||||
await tab('verify');
|
||||
await page.setInputFiles('#ws-verify-files', [...objects(`${O9C}.wai`, `${O9C}.wai.receipts`), ...rcpt('objects/rcpt')]);
|
||||
await page.waitForSelector('#ws-verify-summary', { timeout: LONG });
|
||||
const good = (await page.evaluate(() => window.waiStudio.ctx.last.verify)).data;
|
||||
const walk = expect('receipts_walk_two');
|
||||
check(good.ok && good.indexes[0].lines[0].code === walk.delivery[0].evidence, `${tag}: Verify: O9c held whole is ${walk.delivery[0].evidence}, as receipts_walk_two expects`);
|
||||
// The corpus keeps this index under a name of its own; served beside O9a it is O9a's.
|
||||
const badIndex = { name: `${O9A}.wai.receipts`, mimeType: 'application/octet-stream', buffer: readFileSync(join(HTTP, 'cases/receipts/O9a-bad-signature.wai.receipts')) };
|
||||
await page.setInputFiles('#ws-verify-files', [...objects(`${O9A}.wai`), ...rcpt('objects/rcpt'), ...rcpt('cases/rcpt')].map((p) => ({ name: p.split('/').pop(), mimeType: 'application/octet-stream', buffer: readFileSync(p) })).concat([badIndex]));
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.verify?.data?.ok === false, null, { timeout: LONG });
|
||||
const bad = (await page.evaluate(() => window.waiStudio.ctx.last.verify)).data;
|
||||
const sig = expect('receipts_group_bad_signature');
|
||||
check(bad.indexes[0].lines[0].code === sig.delivery[0].error, `${tag}: Verify: a group whose signature was changed is ${sig.delivery[0].error}, as receipts_group_bad_signature expects`);
|
||||
check(await page.isVisible(`#ws-verify-out li[data-code="${sig.delivery[0].error}"]`), `${tag}: Verify shows the refusal with its reason code`);
|
||||
await axe(page, `${tag}: Verify, a refusal shown`);
|
||||
|
||||
// ---- Encode, decode, package ----
|
||||
await tab('media');
|
||||
await page.setInputFiles('#ws-decode-file', objects(`${O10}.wai`));
|
||||
await page.waitForSelector('#ws-decode-verdict', { timeout: LONG });
|
||||
const dec = await page.evaluate(() => window.waiStudio.ctx.last.decode);
|
||||
check(dec?.ok && dec.data.capability === 'wai.audio.flac' && dec.data.kind === 'audio', `${tag}: FLAC (http-conformance O10) decoded in the tab`);
|
||||
await page.selectOption('#ws-encode-codec', 'png');
|
||||
await page.setInputFiles('#ws-encode-file', join(WAI, 'pack-conformance/stills/plain.png'));
|
||||
await page.click('#ws-encode-run');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.encode, null, { timeout: LONG });
|
||||
const enc = await page.evaluate(() => window.waiStudio.ctx.last.encode);
|
||||
check(enc?.ok === true && enc.data.capability === 'wai.image.png', `${tag}: a picture encoded as PNG in the tab (${enc?.error?.message ?? enc?.data?.envelope_bytes + ' B'})`);
|
||||
await page.waitForSelector('#ws-wrap-cap option[value="wai.text.zstd"]', { state: 'attached', timeout: LONG });
|
||||
await page.selectOption('#ws-wrap-cap', 'wai.text.zstd');
|
||||
await page.setInputFiles('#ws-wrap-file', { name: 'note.txt', mimeType: 'text/plain', buffer: Buffer.from('carried byte for byte') });
|
||||
await page.click('#ws-wrap-run');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.wrap, null, { timeout: LONG });
|
||||
const wr = await page.evaluate(() => window.waiStudio.ctx.last.wrap);
|
||||
check(wr?.ok === true && wr.data.intent === 'replicate', `${tag}: a payload wrapped as wai.text.zstd`);
|
||||
await axe(page, `${tag}: Encode, decode, package`);
|
||||
await page.click('#ws-wrap-out button:has-text("Inspect it")');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.inspect?.data?.capability === 'wai.text.zstd', null, { timeout: LONG });
|
||||
check(true, `${tag}: the wrapped envelope opens in Inspect`);
|
||||
|
||||
// ---- Rooms ----
|
||||
await tab('rooms');
|
||||
await page.click('#ws-room-show');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.room, null, { timeout: LONG });
|
||||
check(/^[0-9a-f]{64}$/.test((await page.evaluate(() => window.waiStudio.ctx.last.room)).room), `${tag}: the room shown from its service (room.show)`);
|
||||
await page.click('#ws-room-join');
|
||||
const joined = await page.waitForFunction(() => window.waiStudio.ctx.last.roomState?.phase === 'watching', null, { timeout: LONG }).then(() => true, () => false);
|
||||
const rs = await page.evaluate(() => window.waiStudio.ctx.last.roomState);
|
||||
check(joined && rs.errors === 0, `${tag}: joined the wai dev room as a viewer (${JSON.stringify(rs)})`);
|
||||
if (joined) await axe(page, `${tag}: the room client, as a viewer`, page.frame({ url: /\/room\// }));
|
||||
await axe(page, `${tag}: Rooms, in the room`);
|
||||
await page.click('#ws-room-leave');
|
||||
check(await page.isHidden('#ws-room-frame'), `${tag}: left the room`);
|
||||
await page.click('#ws-relay-test');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.relayTest, null, { timeout: LONG });
|
||||
const rt = await page.evaluate(() => window.waiStudio.ctx.last.relayTest);
|
||||
check(rt.ok === true && rt.protocol === 'moqt-22', `${tag}: the pre-call test: the relay's binding opened and its SETUP answered (${JSON.stringify(rt)})`);
|
||||
|
||||
// ---- Run ----
|
||||
if (host === 'local') {
|
||||
await tab('run');
|
||||
await page.waitForSelector('#ws-run-stack table', { timeout: LONG });
|
||||
const st = await page.evaluate(() => window.waiStudio.ctx.state);
|
||||
check(st.services.length === 3 && st.services.every((s) => /^127\.0\.0\.1:/.test(s.listen)) && /checked/.test(await page.textContent('#ws-run-loopback')),
|
||||
`${tag}: Run: the stack, every service on loopback (${st.services.map((s) => s.listen).join(', ')})`);
|
||||
await page.click('#ws-run-check');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.health, null, { timeout: LONG });
|
||||
check((await page.evaluate(() => window.waiStudio.ctx.last.health)).relay === true, `${tag}: Run: the room service answers, its clock on the relay (service.check)`);
|
||||
await page.waitForFunction(() => (document.querySelector('#ws-run-log')?.textContent ?? '').length > 0, null, { timeout: LONG });
|
||||
check(true, `${tag}: Run: the relay's log, followed`);
|
||||
await page.click('#ws-run-sessions-show');
|
||||
await page.waitForFunction(() => window.waiStudio.ctx.last.sessions, null, { timeout: LONG });
|
||||
check(Array.isArray((await page.evaluate(() => window.waiStudio.ctx.last.sessions)).sessions), `${tag}: Run: the relay's session records (session.show)`);
|
||||
await axe(page, `${tag}: Run`);
|
||||
} else {
|
||||
check(!(await page.$('#ws-tab-run')), `${tag}: no Run panel on the hosted host`);
|
||||
}
|
||||
|
||||
// ---- Quantum ----
|
||||
await tab('quantum');
|
||||
const lint = await page.waitForFunction(() => /qubits/.test(document.querySelector('#qs-lint')?.textContent ?? ''), null, { timeout: LONG }).then(() => true, () => false);
|
||||
check(lint, `${tag}: Quantum Studio mounted, its first example linted by the engine (${await page.textContent('#qs-lint').catch(() => 'nothing')})`);
|
||||
await axe(page, `${tag}: Quantum`);
|
||||
|
||||
// ---- the command palette ----
|
||||
await page.click('#ws-palette-open');
|
||||
await page.waitForSelector('#ws-palette[open]', { timeout: 10_000 });
|
||||
const total = await page.$$eval('#ws-palette-list > li', (l) => l.length);
|
||||
const ids = await page.evaluate(() => window.waiStudio.actions.length);
|
||||
check(total === ids && ids > 0, `${tag}: the palette lists every operation of the catalogue (${total})`);
|
||||
await page.fill('#ws-palette-q', 'room.grant');
|
||||
const grant = await page.textContent('#ws-palette-list > li[data-id="room.grant"] .ws-op-where');
|
||||
check(/Runs elsewhere/.test(grant) && (host === 'local' ? /\/room-api\/grant/.test(grant) : /POST \/grant/.test(grant)), `${tag}: an operation this host does not run says where it runs (${grant})`);
|
||||
await page.fill('#ws-palette-q', 'dev.start');
|
||||
const devStart = await page.textContent('#ws-palette-list > li[data-id="dev.start"] .ws-op-where');
|
||||
check(host === 'local' ? /^Runs here/.test(devStart) : /Studio served by wai dev/.test(devStart), `${tag}: dev.start (${devStart})`);
|
||||
await axe(page, `${tag}: the command palette`);
|
||||
await page.keyboard.press('Escape');
|
||||
check(!(await page.$('#ws-palette[open]')), `${tag}: Escape closes the palette, from its search field`);
|
||||
await page.click('#ws-palette-open');
|
||||
await page.fill('#ws-palette-q', 'envelope.inspect');
|
||||
await page.click('#ws-palette-list > li[data-id="envelope.inspect"] button');
|
||||
check(await page.getAttribute('#ws-tab-inspect', 'aria-selected') === 'true' && await page.evaluate(() => document.activeElement?.id) === 'ws-inspect-file', `${tag}: envelope.inspect from the palette opens Inspect at its control`);
|
||||
|
||||
// ---- a phone's width ----
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
for (const id of tabs) {
|
||||
await tab(id);
|
||||
const wide = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
check(wide <= 1, `${tag}: ${id} at a phone's width, nothing scrolls sideways (${wide} px)`);
|
||||
}
|
||||
await page.screenshot({ path: join(SHOTS, `${browserName}-${host}-phone.png`), fullPage: true });
|
||||
|
||||
check(seen.errors.length === 0, `${tag}: no console error${seen.errors.length ? ': ' + seen.errors.slice(0, 5).join(' | ') : ''}`);
|
||||
check(seen.failed.length === 0, `${tag}: no failed request${seen.failed.length ? ': ' + seen.failed.slice(0, 5).join(' | ') : ''}`);
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
let host1 = null;
|
||||
try {
|
||||
host1 = await publisher();
|
||||
for (const b of BROWSERS) {
|
||||
for (const host of ['local', 'hosted']) {
|
||||
try {
|
||||
await run(b, host);
|
||||
} catch (e) {
|
||||
check(false, `${b} · ${host}: ${e.stack || e}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await host1?.close().catch(() => {});
|
||||
stop();
|
||||
}
|
||||
writeFileSync(join(OUT, 'result.txt'), `${fails === 0 ? 'every check passed' : `${fails} check(s) failed`}\n`);
|
||||
console.log(`\n${fails === 0 ? 'every check passed' : `${fails} check(s) failed`}`);
|
||||
process.exit(fails === 0 ? 0 : 1);
|
||||
5971
wai/wai-studio/ops.pending.json
Normal file
5971
wai/wai-studio/ops.pending.json
Normal file
File diff suppressed because it is too large
Load diff
559
wai/wai-studio/package-lock.json
generated
Normal file
559
wai/wai-studio/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
{
|
||||
"name": "wai-studio",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wai-studio",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"axe-core": "4.13.0",
|
||||
"esbuild": "0.27.4",
|
||||
"playwright": "1.60.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
|
||||
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
wai/wai-studio/package.json
Normal file
20
wai/wai-studio/package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "wai-studio",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "WAI Studio on the web: one front end with two hosts, the core in WebAssembly in the tab, or served by wai dev beside the local wai. Its actions are generated from the WAI operation catalogue.",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"actions": "node scripts/actions.mjs",
|
||||
"check": "node scripts/actions.mjs --check",
|
||||
"build": "node build.mjs",
|
||||
"test": "node --test test/",
|
||||
"e2e": "node e2e/studio.e2e.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"axe-core": "4.13.0",
|
||||
"esbuild": "0.27.4",
|
||||
"playwright": "1.60.0"
|
||||
}
|
||||
}
|
||||
213
wai/wai-studio/scripts/actions.mjs
Normal file
213
wai/wai-studio/scripts/actions.mjs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env node
|
||||
// Studio's actions, generated from the WAI operation catalogue, and the check that fails on
|
||||
// drift.
|
||||
//
|
||||
// node scripts/actions.mjs write src/actions.generated.js from the catalogue
|
||||
// node scripts/actions.mjs --check write nothing; exit 1 with one line a finding
|
||||
// [--ops FILE] the catalogue export to read (wai.ops/1)
|
||||
//
|
||||
// The catalogue is wai/catalogue/ops.json, which `wai ops export` writes from the table in
|
||||
// wai-rs (and CI holds to it). Until that table is on main, ops.pending.json beside this
|
||||
// script is a copy of the export from the pull request that brings it, and is read instead;
|
||||
// once wai/catalogue/ops.json exists, the copy must go, and the check says so.
|
||||
//
|
||||
// Every operation becomes an action: its id, its menu and item (the noun and the verb), its
|
||||
// palette entry (`noun verb`), its summary, where it is placed, its effect, and the names
|
||||
// the other doors give it (the command line, the MCP tool, the service's route), so the
|
||||
// palette can say where an operation runs that this Studio cannot run. src/bindings.js says,
|
||||
// by hand, which operations Studio runs and on which host. The check fails on:
|
||||
//
|
||||
// - an action with no operation: a binding, a core call or a server operation whose id the
|
||||
// catalogue does not hold;
|
||||
// - an operation with a browser placement (`client` or `both`: it runs on this side of a
|
||||
// service it names, as a page does) and no action: no binding;
|
||||
// - a mismatched name: src/actions.generated.js is not what the catalogue generates; a
|
||||
// binding run in the tab whose call is not the core's `noun_verb`; a core call or server
|
||||
// operation Studio binds to another host than the one that runs it; an `op: '…'`,
|
||||
// `.op('…'` or `data-op="…"` in the source that names no operation.
|
||||
//
|
||||
// Standard library only.
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
export const HERE = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const CATALOGUE = resolve(HERE, '../catalogue/ops.json');
|
||||
export const PENDING = join(HERE, 'ops.pending.json');
|
||||
export const GENERATED = join(HERE, 'src/actions.generated.js');
|
||||
export const CORE = join(HERE, 'core/src/lib.rs');
|
||||
export const SERVER = resolve(HERE, '../wai-rs/src/bin/wai_cli/dx/studio.rs');
|
||||
export const FORMAT = 'wai.ops/1';
|
||||
// The placements whose operations run on this side of a service, as a page does.
|
||||
export const BROWSER_PLACEMENTS = ['client', 'both'];
|
||||
|
||||
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
/** The catalogue to read, and why: `--ops`, else the export on main, else the pending copy. */
|
||||
export function catalogueFile(given) {
|
||||
if (given) return { file: resolve(given), why: 'given' };
|
||||
if (existsSync(CATALOGUE)) return { file: CATALOGUE, why: 'the catalogue' };
|
||||
return { file: PENDING, why: 'the pending copy (the catalogue is not on main yet)' };
|
||||
}
|
||||
|
||||
export function readCatalogue(file) {
|
||||
const bytes = readFileSync(file);
|
||||
const doc = JSON.parse(bytes.toString('utf8'));
|
||||
if (doc.format !== FORMAT) throw new Error(`${file}: not a catalogue export (format ${JSON.stringify(doc.format)})`);
|
||||
return { doc, sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||
}
|
||||
|
||||
/** One operation as Studio's action table holds it. */
|
||||
export function action(o) {
|
||||
const [noun, verb] = o.id.split('.');
|
||||
return {
|
||||
id: o.id,
|
||||
noun,
|
||||
verb,
|
||||
menu: cap(noun),
|
||||
item: cap(verb),
|
||||
palette: `${noun} ${verb}`,
|
||||
summary: o.summary,
|
||||
placement: o.placement,
|
||||
effect: o.effect,
|
||||
stability: o.stability,
|
||||
toolset: o.toolset,
|
||||
cli: o.doors?.cli ? o.cli : null,
|
||||
mcp: o.doors?.mcp ? o.names?.mcp ?? null : null,
|
||||
route: o.route ?? null,
|
||||
call: `${noun}_${verb}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The text of src/actions.generated.js for a catalogue. Internal operations are never published. */
|
||||
export function generate(doc, sha256) {
|
||||
const ops = doc.operations.filter((o) => o.stability !== 'internal').map(action);
|
||||
const lines = [
|
||||
'// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the',
|
||||
'// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run',
|
||||
'// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.',
|
||||
`export const CATALOGUE = ${JSON.stringify({ format: doc.format, toolchain: doc.toolchain, sha256 })};`,
|
||||
'export const ACTIONS = [',
|
||||
...ops.map((a) => ` ${JSON.stringify(a)},`),
|
||||
'];',
|
||||
'',
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** The ids the core binds for WebAssembly: each `#[wasm_bindgen] pub fn noun_verb`, and its OPERATIONS list. */
|
||||
export function coreCalls(src) {
|
||||
const calls = [...src.matchAll(/#\[wasm_bindgen\]\s*\npub fn ([a-z0-9]+_[a-z0-9]+)\s*\(/g)].map((m) => m[1]);
|
||||
const listed = [...(src.match(/pub const OPERATIONS: &\[&str\] = &\[([\s\S]*?)\];/)?.[1] ?? '').matchAll(/"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]);
|
||||
return { calls, listed };
|
||||
}
|
||||
|
||||
/** The operations the local core runs through `wai dev` (`/_wai/api/op/<id>`): its LOCAL_OPS table. */
|
||||
export function serverOps(src) {
|
||||
const table = src.match(/const LOCAL_OPS: &\[[^\]]*\] = &\[([\s\S]*?)\n\];/)?.[1] ?? '';
|
||||
return [...table.matchAll(/\(\s*"([a-z0-9]+\.[a-z0-9]+)"/g)].map((m) => m[1]);
|
||||
}
|
||||
|
||||
/** Every `op: '…'` and `data-op="…"` in Studio's source, with where it stands. */
|
||||
export function sourceOps(files) {
|
||||
const out = [];
|
||||
for (const [path, text] of files) {
|
||||
for (const m of text.matchAll(/(?:\bop:\s*|\.op\(\s*|data-op['"]?\s*[:=]\s*)(["'`])([a-z0-9]+\.[a-z0-9]+)\1/g)) out.push({ path, id: m[2] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every finding of the drift check, as lines. `bindings` is src/bindings.js's BINDINGS;
|
||||
* `generated` the committed text of src/actions.generated.js; `core` and `server` the
|
||||
* sources of the core and of `wai dev`'s Studio routes; `files` Studio's source as
|
||||
* [path, text].
|
||||
*/
|
||||
export function findings({ doc, sha256, generated, bindings, core, server, files }) {
|
||||
const out = [];
|
||||
const ids = new Set(doc.operations.map((o) => o.id));
|
||||
const byId = new Map(doc.operations.map((o) => [o.id, o]));
|
||||
if (generated !== generate(doc, sha256)) out.push('src/actions.generated.js is not what the catalogue generates: run `node scripts/actions.mjs`');
|
||||
for (const [id, b] of Object.entries(bindings)) {
|
||||
if (!ids.has(id)) out.push(`the action ${id} has no operation in the catalogue`);
|
||||
if (b.panel == null && b.elsewhere == null) out.push(`the action ${id} names neither a panel nor where it runs`);
|
||||
}
|
||||
for (const o of doc.operations) {
|
||||
if (BROWSER_PLACEMENTS.includes(o.placement) && o.stability !== 'internal' && !(o.id in bindings)) {
|
||||
out.push(`the operation ${o.id} is placed ${o.placement} (it runs on this side of a service, as a page does) and has no action in src/bindings.js`);
|
||||
}
|
||||
}
|
||||
const { calls, listed } = coreCalls(core);
|
||||
const tab = Object.entries(bindings).filter(([, b]) => b.hosted === 'tab' || b.local === 'tab' || b.local === 'tab+local').map(([id]) => id);
|
||||
for (const id of tab) {
|
||||
const want = id.replace('.', '_');
|
||||
if (!calls.includes(want)) out.push(`the action ${id} runs in the tab, and the core has no call ${want}`);
|
||||
if (!listed.includes(id)) out.push(`the action ${id} runs in the tab, and the core's OPERATIONS does not list it`);
|
||||
}
|
||||
for (const c of calls) {
|
||||
const [noun] = c.split('_');
|
||||
const id = c.replace('_', '.');
|
||||
if ([...ids].some((i) => i.startsWith(`${noun}.`)) && !ids.has(id)) out.push(`the core's call ${c} names ${id}, which the catalogue does not hold`);
|
||||
}
|
||||
for (const id of listed) {
|
||||
if (!ids.has(id)) out.push(`the core's OPERATIONS lists ${id}, which the catalogue does not hold`);
|
||||
else if (!tab.includes(id)) out.push(`the core runs ${id}, and no action runs it in the tab`);
|
||||
}
|
||||
const local = serverOps(server);
|
||||
for (const id of local) {
|
||||
if (!ids.has(id)) out.push(`wai dev's LOCAL_OPS runs ${id}, which the catalogue does not hold`);
|
||||
else if (!Object.entries(bindings).some(([b, x]) => b === id && (x.local === 'local' || x.local === 'tab+local'))) out.push(`wai dev's LOCAL_OPS runs ${id}, and no action runs it through the local core`);
|
||||
}
|
||||
for (const [id, b] of Object.entries(bindings)) {
|
||||
if ((b.local === 'local' || b.local === 'tab+local') && !local.includes(id)) out.push(`the action ${id} runs through the local core, and wai dev's LOCAL_OPS does not run it`);
|
||||
}
|
||||
for (const { path, id } of sourceOps(files)) {
|
||||
if (!ids.has(id)) out.push(`${path}: ${id} names no operation of the catalogue`);
|
||||
}
|
||||
for (const [id, b] of Object.entries(bindings)) {
|
||||
const o = byId.get(id);
|
||||
if (o && b.elsewhere === 'cli' && !o.doors?.cli) out.push(`the action ${id} says it runs from the command line, and the catalogue gives it no command line`);
|
||||
if (o && b.elsewhere === 'mcp' && !o.doors?.mcp) out.push(`the action ${id} says it runs through wai mcp, and the catalogue gives it no MCP tool`);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const n of readdirSync(dir)) {
|
||||
const p = join(dir, n);
|
||||
if (statSync(p).isDirectory()) out.push(...walk(p));
|
||||
else if (/\.(m?js|html)$/.test(n) && n !== 'actions.generated.js') out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const at = args.indexOf('--ops');
|
||||
const { file, why } = catalogueFile(at >= 0 ? args[at + 1] : null);
|
||||
const { doc, sha256 } = readCatalogue(file);
|
||||
if (!args.includes('--check')) {
|
||||
writeFileSync(GENERATED, generate(doc, sha256));
|
||||
console.log(`wrote ${relative(process.cwd(), GENERATED)} from ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations`);
|
||||
return 0;
|
||||
}
|
||||
const { BINDINGS } = await import(pathToFileURL(join(HERE, 'src/bindings.js')).href);
|
||||
const found = findings({
|
||||
doc,
|
||||
sha256,
|
||||
generated: existsSync(GENERATED) ? readFileSync(GENERATED, 'utf8') : '',
|
||||
bindings: BINDINGS,
|
||||
core: readFileSync(CORE, 'utf8'),
|
||||
server: existsSync(SERVER) ? readFileSync(SERVER, 'utf8') : '',
|
||||
files: walk(join(HERE, 'src')).map((p) => [relative(HERE, p), readFileSync(p, 'utf8')]),
|
||||
});
|
||||
if (at < 0 && file === CATALOGUE && existsSync(PENDING)) found.push('the catalogue is on main (wai/catalogue/ops.json): delete ops.pending.json, the copy read until it landed');
|
||||
console.log(`read ${relative(process.cwd(), file)} (${why}): ${doc.operations.length} operations, ${Object.keys(BINDINGS).length} actions bound`);
|
||||
for (const f of found) console.log(`FAIL ${f}`);
|
||||
console.log(found.length ? `${found.length} finding(s)` : 'Studio\'s actions hold to the catalogue');
|
||||
return found.length ? 1 : 0;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) process.exit(await main());
|
||||
98
wai/wai-studio/src/actions.generated.js
Normal file
98
wai/wai-studio/src/actions.generated.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// GENERATED by scripts/actions.mjs from the WAI operation catalogue. Do not edit: change the
|
||||
// catalogue (wai-rs/src/catalogue.rs, exported by `wai ops export`), then run
|
||||
// `node scripts/actions.mjs`. `node scripts/actions.mjs --check` fails when this file differs.
|
||||
export const CATALOGUE = {"format":"wai.ops/1","toolchain":"0.3.0","sha256":"4bfdd96bbb263f59d210b8fdb4b2704d250be30cdb35f930954d2f287ac84724"};
|
||||
export const ACTIONS = [
|
||||
{"id":"toolchain.test","noun":"toolchain","verb":"test","menu":"Toolchain","item":"Test","palette":"toolchain test","summary":"WAI's four steps (name, dispatch, rebuild, receipt) on a picture made here, its receipt checked offline","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai hello","mcp":null,"route":null,"call":"toolchain_test"},
|
||||
{"id":"project.create","noun":"project","verb":"create","menu":"Project","item":"Create","palette":"project create","summary":"A new project from one of the examples, written into a new or empty directory; --list names the templates","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai create","mcp":null,"route":null,"call":"project_create"},
|
||||
{"id":"dev.start","noun":"dev","verb":"start","menu":"Dev","item":"Start","palette":"dev start","summary":"A relay, a room service and the room page on one origin on this machine, for development; --check starts, checks every piece and stops","placement":"local","effect":"runs","stability":"stable","toolset":"dev","cli":"wai dev","mcp":"dev_start","route":null,"call":"dev_start"},
|
||||
{"id":"receipt.verify","noun":"receipt","verb":"verify","menu":"Receipt","item":"Verify","palette":"receipt verify","summary":"Every receipt index (*.wai.receipts) under each path, its group receipts and claims checked offline","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai verify","mcp":"receipt_verify","route":null,"call":"receipt_verify"},
|
||||
{"id":"envelope.inspect","noun":"envelope","verb":"inspect","menu":"Envelope","item":"Inspect","palette":"envelope inspect","summary":"What an envelope holds and whether this build decodes it: media, intent, pin, capability, fallback, the dispatch verdict and the conformance checks","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai inspect","mcp":"envelope_inspect","route":null,"call":"envelope_inspect"},
|
||||
{"id":"envelope.wrap","noun":"envelope","verb":"wrap","menu":"Envelope","item":"Wrap","palette":"envelope wrap","summary":"Carry a registered capability's bytes in an envelope, byte for byte, under SPEC's intent, role and candidate rules","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai wrap","mcp":"envelope_wrap","route":null,"call":"envelope_wrap"},
|
||||
{"id":"envelope.unwrap","noun":"envelope","verb":"unwrap","menu":"Envelope","item":"Unwrap","palette":"envelope unwrap","summary":"An envelope's capability and its checks, and with -o its payload or one component, exactly as wrapped","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai unwrap","mcp":null,"route":null,"call":"envelope_unwrap"},
|
||||
{"id":"media.encode","noun":"media","verb":"encode","menu":"Media","item":"Encode","palette":"media encode","summary":"Encode a file with a codec this build holds, in an envelope; AVC and HEVC through the device's own encoders, with a signed record","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai encode","mcp":null,"route":null,"call":"media_encode"},
|
||||
{"id":"media.decode","noun":"media","verb":"decode","menu":"Media","item":"Decode","palette":"media decode","summary":"Decode an envelope through SPEC §4's dispatch to raw samples, with a .meta file beside them","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai decode","mcp":null,"route":null,"call":"media_decode"},
|
||||
{"id":"media.generate","noun":"media","verb":"generate","menu":"Media","item":"Generate","palette":"media generate","summary":"Make a wai.generate.spectral picture natively and print its SHA-256, or write its instruction object","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai generate","mcp":null,"route":null,"call":"media_generate"},
|
||||
{"id":"score.build","noun":"score","verb":"build","menu":"Score","item":"Build","palette":"score build","summary":"A WSCR score from integer note fields","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai score build","mcp":null,"route":null,"call":"score_build"},
|
||||
{"id":"score.decode","noun":"score","verb":"decode","menu":"Score","item":"Decode","palette":"score decode","summary":"A score's reference mixdown and its digests (pcm_sha256, mixdown_blake3, score_hash)","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai score render","mcp":null,"route":null,"call":"score_decode"},
|
||||
{"id":"encoder.list","noun":"encoder","verb":"list","menu":"Encoder","item":"List","palette":"encoder list","summary":"What each encoder interface offers on this device, for AVC and HEVC","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai encoders","mcp":null,"route":null,"call":"encoder_list"},
|
||||
{"id":"encoder.verify","noun":"encoder","verb":"verify","menu":"Encoder","item":"Verify","palette":"encoder verify","summary":"A signed encode record checked, against the stream and its source where given","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai encode-record verify","mcp":null,"route":null,"call":"encoder_verify"},
|
||||
{"id":"registry.list","noun":"registry","verb":"list","menu":"Registry","item":"List","palette":"registry list","summary":"Every registered capability with its fidelity, licence class, replicate class and role, and whether this build decodes it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai capabilities","mcp":"registry_list","route":null,"call":"registry_list"},
|
||||
{"id":"registry.export","noun":"registry","verb":"export","menu":"Registry","item":"Export","palette":"registry export","summary":"Write, or with --check compare, the registry's machine-readable export and the generated table of REGISTRY.md","placement":"local","effect":"writes-project","stability":"stable","toolset":"maintainer","cli":"wai registry export","mcp":null,"route":null,"call":"registry_export"},
|
||||
{"id":"prior.list","noun":"prior","verb":"list","menu":"Prior","item":"List","palette":"prior list","summary":"The listing of a parameter set (wai.prior.set) on stdout, and its pin on stderr","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai prior-set","mcp":null,"route":null,"call":"prior_list"},
|
||||
{"id":"stage.encode","noun":"stage","verb":"encode","menu":"Stage","item":"Encode","palette":"stage encode","summary":"A refinement layer over a base and the layers below it, toward a target","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai stage refine","mcp":null,"route":null,"call":"stage_encode"},
|
||||
{"id":"stage.decode","noun":"stage","verb":"decode","menu":"Stage","item":"Decode","palette":"stage decode","summary":"Envelopes taken in order: each layer offered, each base decoded and refined, with each report and the units","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai stage apply","mcp":null,"route":null,"call":"stage_decode"},
|
||||
{"id":"stage.build","noun":"stage","verb":"build","menu":"Stage","item":"Build","palette":"stage build","summary":"A wai.video.int_motion base of N frames of RGB8 (WIV1, keyframe 0, zero motion)","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai stage base","mcp":null,"route":null,"call":"stage_build"},
|
||||
{"id":"stage.inspect","noun":"stage","verb":"inspect","menu":"Stage","item":"Inspect","palette":"stage inspect","summary":"A refinement layer's refines, its WIR1 header and its declared cost","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai stage inspect","mcp":null,"route":null,"call":"stage_inspect"},
|
||||
{"id":"stage.wrap","noun":"stage","verb":"wrap","menu":"Stage","item":"Wrap","palette":"stage wrap","summary":"WAI1 envelopes packed into one WAI2, their manifests verbatim","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai stage pack","mcp":null,"route":null,"call":"stage_wrap"},
|
||||
{"id":"stage.unwrap","noun":"stage","verb":"unwrap","menu":"Stage","item":"Unwrap","palette":"stage unwrap","summary":"One component of a WAI2 envelope as its WAI1 envelope","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai stage explode","mcp":null,"route":null,"call":"stage_unwrap"},
|
||||
{"id":"http.export","noun":"http","verb":"export","menu":"Http","item":"Export","palette":"http export","summary":"Envelopes named by their content hash, each with its head proof and entity tag, ready for an origin","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai http publish","mcp":null,"route":null,"call":"http_export"},
|
||||
{"id":"http.inspect","noun":"http","verb":"inspect","menu":"Http","item":"Inspect","palette":"http inspect","summary":"The codecs parameter a sequence of envelopes advertises: its union and its floor","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai http codecs","mcp":null,"route":null,"call":"http_inspect"},
|
||||
{"id":"http.start","noun":"http","verb":"start","menu":"Http","item":"Start","palette":"http start","summary":"An origin on loopback serving a published directory with http-delivery's headers, ranges and If-Range","placement":"local","effect":"runs","stability":"preview","toolset":"core","cli":"wai http origin","mcp":null,"route":null,"call":"http_start"},
|
||||
{"id":"hls.build","noun":"hls","verb":"build","menu":"Hls","item":"Build","palette":"hls build","summary":"An HLS rendition of published objects","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai http hls","mcp":null,"route":null,"call":"hls_build"},
|
||||
{"id":"dash.build","noun":"dash","verb":"build","menu":"Dash","item":"Build","palette":"dash build","summary":"A DASH manifest of published objects","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai http mpd","mcp":null,"route":null,"call":"dash_build"},
|
||||
{"id":"playlist.lock","noun":"playlist","verb":"lock","menu":"Playlist","item":"Lock","palette":"playlist lock","summary":"A media playlist sealed with a signed manifest receipt","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai http seal-playlist","mcp":null,"route":null,"call":"playlist_lock"},
|
||||
{"id":"playlist.verify","noun":"playlist","verb":"verify","menu":"Playlist","item":"Verify","palette":"playlist verify","summary":"A media playlist checked against its manifest receipt","placement":"local","effect":"reads","stability":"preview","toolset":"core","cli":"wai http check-playlist","mcp":null,"route":null,"call":"playlist_verify"},
|
||||
{"id":"corpus.generate","noun":"corpus","verb":"generate","menu":"Corpus","item":"Generate","palette":"corpus generate","summary":"The HTTP conformance corpus: generated, verified, or its differential and fuzz cases written","placement":"local","effect":"writes-project","stability":"preview","toolset":"maintainer","cli":"wai http vectors","mcp":null,"route":null,"call":"corpus_generate"},
|
||||
{"id":"http.load","noun":"http","verb":"load","menu":"Http","item":"Load","palette":"http load","summary":"An object fetched by the byte-range procedure, repeatedly, and the record of each run","placement":"client","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai http bench","mcp":null,"route":null,"call":"http_load"},
|
||||
{"id":"receipt.fetch","noun":"receipt","verb":"fetch","menu":"Receipt","item":"Fetch","palette":"receipt fetch","summary":"An object's receipt index fetched over HTTP and checked, its signers confirmed under the origin's key log where named","placement":"client","effect":"reads","stability":"preview","toolset":"core","cli":"wai http receipts","mcp":null,"route":null,"call":"receipt_fetch"},
|
||||
{"id":"compat.check","noun":"compat","verb":"check","menu":"Compat","item":"Check","palette":"compat check","summary":"What the working tree breaks against a git ref, by category (wire, record, api); a break passes once the CHANGELOG names it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai breaking","mcp":null,"route":null,"call":"compat_check"},
|
||||
{"id":"conformance.run","noun":"conformance","verb":"run","menu":"Conformance","item":"Run","palette":"conformance run","summary":"WAI's conformance corpus run against another implementation, through a command or an HTTP adapter, its result a signed record wai verify checks","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai conform","mcp":"conformance_run","route":null,"call":"conformance_run"},
|
||||
{"id":"conformance.list","noun":"conformance","verb":"list","menu":"Conformance","item":"List","palette":"conformance list","summary":"The kinds and suites wai conform runs, each suite's corpus and its cases","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai conformance list","mcp":"conformance_list","route":null,"call":"conformance_list"},
|
||||
{"id":"mcp.start","noun":"mcp","verb":"start","menu":"Mcp","item":"Start","palette":"mcp start","summary":"WAI's operations as tools for a coding agent: a Model Context Protocol server on standard input and output, in toolsets","placement":"local","effect":"runs","stability":"preview","toolset":"core","cli":"wai mcp","mcp":null,"route":null,"call":"mcp_start"},
|
||||
{"id":"agent.setup","noun":"agent","verb":"setup","menu":"Agent","item":"Setup","palette":"agent setup","summary":"A project made ready for coding agents: AGENTS.md, an import of it in a CLAUDE.md already there, and wai mcp registered in its MCP configuration","placement":"local","effect":"writes-project","stability":"preview","toolset":"core","cli":"wai setup-agent","mcp":null,"route":null,"call":"agent_setup"},
|
||||
{"id":"app.guide","noun":"app","verb":"guide","menu":"App","item":"Guide","palette":"app guide","summary":"The line language and every WAI widget, as a model writing a WAI app is told them","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app guide","mcp":"app_guide","route":null,"call":"app_guide"},
|
||||
{"id":"app.check","noun":"app","verb":"check","menu":"App","item":"Check","palette":"app check","summary":"An app program checked against the WAI registry and WAI's rules, every error with its line and reason code; in a project, its room and runtime too","placement":"local","effect":"reads","stability":"preview","toolset":"app","cli":"wai app check","mcp":"app_check","route":null,"call":"app_check"},
|
||||
{"id":"app.export","noun":"app","verb":"export","menu":"App","item":"Export","palette":"app export","summary":"An app made with Create written as a project: the program, wai.json naming its room, the page and its runtime, a README, AGENTS.md and the licence","placement":"local","effect":"writes-project","stability":"preview","toolset":"app","cli":"wai app export","mcp":"app_export","route":null,"call":"app_export"},
|
||||
{"id":"app.stream","noun":"app","verb":"stream","menu":"App","item":"Stream","palette":"app stream","summary":"A program streamed into the person's open Create page through the bridge wai mcp runs on loopback","placement":"local","effect":"runs","stability":"preview","toolset":"app","cli":null,"mcp":"app_stream","route":null,"call":"app_stream"},
|
||||
{"id":"registry.show","noun":"registry","verb":"show","menu":"Registry","item":"Show","palette":"registry show","summary":"One capability's registry entry, whether this build decodes it, its role, and the specification lines that name it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"registry_show","route":null,"call":"registry_show"},
|
||||
{"id":"docs.search","noun":"docs","verb":"search","menu":"Docs","item":"Search","palette":"docs search","summary":"Lines of the specification and its extensions holding every word of a query, each with its file, line and section","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":null,"mcp":"docs_search","route":null,"call":"docs_search"},
|
||||
{"id":"dev.stop","noun":"dev","verb":"stop","menu":"Dev","item":"Stop","palette":"dev stop","summary":"The development relay and room service a wai mcp server started, stopped","placement":"local","effect":"runs","stability":"stable","toolset":"dev","cli":null,"mcp":"dev_stop","route":null,"call":"dev_stop"},
|
||||
{"id":"room.join","noun":"room","verb":"join","menu":"Room","item":"Join","palette":"room join","summary":"A test principal enters the development room with its grant, publishes its tracks, and is seen joined by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_join","route":null,"call":"room_join"},
|
||||
{"id":"room.leave","noun":"room","verb":"leave","menu":"Room","item":"Leave","palette":"room leave","summary":"A test principal's session in the development room closed, and its leave seen by the room's clock","placement":"client","effect":"writes-target","stability":"stable","toolset":"dev","cli":null,"mcp":"room_leave","route":null,"call":"room_leave"},
|
||||
{"id":"session.show","noun":"session","verb":"show","menu":"Session","item":"Show","palette":"session show","summary":"The development room's session as a verifier is given it: descriptor, grants, key-log head and the clock's event chain","placement":"client","effect":"reads","stability":"stable","toolset":"dev","cli":null,"mcp":"session_show","route":null,"call":"session_show"},
|
||||
{"id":"service.show","noun":"service","verb":"show","menu":"Service","item":"Show","palette":"service show","summary":"The service's health","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/"},"call":"service_show"},
|
||||
{"id":"service.check","noun":"service","verb":"check","menu":"Service","item":"Check","palette":"service check","summary":"The service's health","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/health"},"call":"service_check"},
|
||||
{"id":"room.show","noun":"room","verb":"show","menu":"Room","item":"Show","palette":"room show","summary":"The current room","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/room"},"call":"room_show"},
|
||||
{"id":"room.grant","noun":"room","verb":"grant","menu":"Room","item":"Grant","palette":"room grant","summary":"A grant for a key","placement":"service","effect":"writes-target","stability":"preview","toolset":"dev","cli":null,"mcp":"room_grant","route":{"service":"wai-room","method":"POST","path":"/grant"},"call":"room_grant"},
|
||||
{"id":"admission.show","noun":"admission","verb":"show","menu":"Admission","item":"Show","palette":"admission show","summary":"The room's admission receipt so far","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/age/receipt"},"call":"admission_show"},
|
||||
{"id":"mls.tail","noun":"mls","verb":"tail","menu":"Mls","item":"Tail","palette":"mls tail","summary":"The room's log of MLS messages","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/mls"},"call":"mls_tail"},
|
||||
{"id":"mls.import","noun":"mls","verb":"import","menu":"Mls","item":"Import","palette":"mls import","summary":"A KeyPackage for a granted principal's Add","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/mls/key-package"},"call":"mls_import"},
|
||||
{"id":"mls.update","noun":"mls","verb":"update","menu":"Mls","item":"Update","palette":"mls update","summary":"A GroupInfo, or a commit and its Welcome","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/mls/post"},"call":"mls_update"},
|
||||
{"id":"performance.list","noun":"performance","verb":"list","menu":"Performance","item":"List","palette":"performance list","summary":"The clock's log of objects","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/co/objects"},"call":"performance_list"},
|
||||
{"id":"performance.show","noun":"performance","verb":"show","menu":"Performance","item":"Show","palette":"performance show","summary":"The room's co-performance as the clock holds it","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/co/state"},"call":"performance_show"},
|
||||
{"id":"schedule.create","noun":"schedule","verb":"create","menu":"Schedule","item":"Create","palette":"schedule create","summary":"A turn schedule the clock signs","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/co/schedule"},"call":"schedule_create"},
|
||||
{"id":"handover.create","noun":"handover","verb":"create","menu":"Handover","item":"Create","palette":"handover create","summary":"A handover the clock signs","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/co/handover"},"call":"handover_create"},
|
||||
{"id":"window.create","noun":"window","verb":"create","menu":"Window","item":"Create","palette":"window create","summary":"A selection window the clock signs","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/co/window"},"call":"window_create"},
|
||||
{"id":"fidelity.create","noun":"fidelity","verb":"create","menu":"Fidelity","item":"Create","palette":"fidelity create","summary":"A fidelity statement now","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/co/fidelity"},"call":"fidelity_create"},
|
||||
{"id":"time.show","noun":"time","verb":"show","menu":"Time","item":"Show","palette":"time show","summary":"The service's clock","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/watch/time"},"call":"time_show"},
|
||||
{"id":"source.fetch","noun":"source","verb":"fetch","menu":"Source","item":"Fetch","palette":"source fetch","summary":"The watch room's source, whole","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/watch/source"},"call":"source_fetch"},
|
||||
{"id":"watch.setup","noun":"watch","verb":"setup","menu":"Watch","item":"Setup","palette":"watch setup","summary":"A challenge for a join's proof","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/watch/challenge"},"call":"watch_setup"},
|
||||
{"id":"watch.tail","noun":"watch","verb":"tail","menu":"Watch","item":"Tail","palette":"watch tail","summary":"The watch room's log","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/watch"},"call":"watch_tail"},
|
||||
{"id":"watch.show","noun":"watch","verb":"show","menu":"Watch","item":"Show","palette":"watch show","summary":"The room as a co-watch verifier is given it","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/watch/room"},"call":"watch_show"},
|
||||
{"id":"watch.join","noun":"watch","verb":"join","menu":"Watch","item":"Join","palette":"watch join","summary":"A viewer enters by a proof of its key","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/watch/join"},"call":"watch_join"},
|
||||
{"id":"watch.update","noun":"watch","verb":"update","menu":"Watch","item":"Update","palette":"watch update","summary":"A viewer's events, checked and carried in order","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/watch/post"},"call":"watch_update"},
|
||||
{"id":"control.grant","noun":"control","verb":"grant","menu":"Control","item":"Grant","palette":"control grant","summary":"The host hands control over","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/remote/control"},"call":"control_grant"},
|
||||
{"id":"remote.show","noun":"remote","verb":"show","menu":"Remote","item":"Show","palette":"remote show","summary":"The session as the clock holds it","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/remote/room"},"call":"remote_show"},
|
||||
{"id":"remote.verify","noun":"remote","verb":"verify","menu":"Remote","item":"Verify","palette":"remote verify","summary":"That session verified","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/remote/state"},"call":"remote_verify"},
|
||||
{"id":"conversation.show","noun":"conversation","verb":"show","menu":"Conversation","item":"Show","palette":"conversation show","summary":"The conversation","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/msg"},"call":"conversation_show"},
|
||||
{"id":"conversation.setup","noun":"conversation","verb":"setup","menu":"Conversation","item":"Setup","palette":"conversation setup","summary":"A challenge for a proof","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/msg/challenge"},"call":"conversation_setup"},
|
||||
{"id":"conversation.join","noun":"conversation","verb":"join","menu":"Conversation","item":"Join","palette":"conversation join","summary":"A key that proves itself is admitted","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/join"},"call":"conversation_join"},
|
||||
{"id":"conversation.tail","noun":"conversation","verb":"tail","menu":"Conversation","item":"Tail","palette":"conversation tail","summary":"The log as it is owed to the caller","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/msg/log"},"call":"conversation_tail"},
|
||||
{"id":"conversation.import","noun":"conversation","verb":"import","menu":"Conversation","item":"Import","palette":"conversation import","summary":"A member's KeyPackage","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/key-package"},"call":"conversation_import"},
|
||||
{"id":"conversation.update","noun":"conversation","verb":"update","menu":"Conversation","item":"Update","palette":"conversation update","summary":"A GroupInfo, or a commit and its Welcome","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/mls"},"call":"conversation_update"},
|
||||
{"id":"message.create","noun":"message","verb":"create","menu":"Message","item":"Create","palette":"message create","summary":"A message","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/post"},"call":"message_create"},
|
||||
{"id":"acknowledgement.create","noun":"acknowledgement","verb":"create","menu":"Acknowledgement","item":"Create","palette":"acknowledgement create","summary":"An acknowledgement","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/ack"},"call":"acknowledgement_create"},
|
||||
{"id":"conversation.leave","noun":"conversation","verb":"leave","menu":"Conversation","item":"Leave","palette":"conversation leave","summary":"The caller leaves","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/leave"},"call":"conversation_leave"},
|
||||
{"id":"member.delete","noun":"member","verb":"delete","menu":"Member","item":"Delete","palette":"member delete","summary":"The owner removes a member","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/msg/remove"},"call":"member_delete"},
|
||||
{"id":"call.setup","noun":"call","verb":"setup","menu":"Call","item":"Setup","palette":"call setup","summary":"A challenge for a proof","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/call/challenge"},"call":"call_setup"},
|
||||
{"id":"call.create","noun":"call","verb":"create","menu":"Call","item":"Create","palette":"call create","summary":"Place a call","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/call/place"},"call":"call_create"},
|
||||
{"id":"call.invite","noun":"call","verb":"invite","menu":"Call","item":"Invite","palette":"call invite","summary":"Ring invitees","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/call/ring"},"call":"call_invite"},
|
||||
{"id":"inbox.show","noun":"inbox","verb":"show","menu":"Inbox","item":"Show","palette":"inbox show","summary":"The caller's inbox","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/inbox"},"call":"inbox_show"},
|
||||
{"id":"answer.create","noun":"answer","verb":"create","menu":"Answer","item":"Create","palette":"answer create","summary":"Accept, decline or cancel","placement":"service","effect":"writes-target","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"POST","path":"/call/answer"},"call":"answer_create"},
|
||||
{"id":"call.show","noun":"call","verb":"show","menu":"Call","item":"Show","palette":"call show","summary":"The current call","placement":"service","effect":"reads","stability":"preview","toolset":"core","cli":null,"mcp":null,"route":{"service":"wai-room","method":"GET","path":"/call"},"call":"call_show"},
|
||||
{"id":"ops.list","noun":"ops","verb":"list","menu":"Ops","item":"List","palette":"ops list","summary":"Every operation of this catalogue: its id, command line, effect and whether this build holds it","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops list","mcp":null,"route":null,"call":"ops_list"},
|
||||
{"id":"ops.search","noun":"ops","verb":"search","menu":"Ops","item":"Search","palette":"ops search","summary":"The operations that match a task, ranked, offline: up to five","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops search","mcp":null,"route":null,"call":"ops_search"},
|
||||
{"id":"ops.show","noun":"ops","verb":"show","menu":"Ops","item":"Show","palette":"ops show","summary":"One operation's entry: its input and output schemas, effect, placement and command line","placement":"local","effect":"reads","stability":"stable","toolset":"core","cli":"wai ops show","mcp":null,"route":null,"call":"ops_show"},
|
||||
{"id":"ops.export","noun":"ops","verb":"export","menu":"Ops","item":"Export","palette":"ops export","summary":"The catalogue as JSON (wai.ops/1), what other tools and CI read; --check compares a file with it","placement":"local","effect":"writes-project","stability":"stable","toolset":"core","cli":"wai ops export","mcp":null,"route":null,"call":"ops_export"},
|
||||
];
|
||||
53
wai/wai-studio/src/bindings.js
Normal file
53
wai/wai-studio/src/bindings.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// Which operations of the catalogue Studio runs, in which panel, and how on each host. The
|
||||
// operations and their names are the catalogue's (./actions.generated.js); this table only
|
||||
// binds them. `node scripts/actions.mjs --check` holds it to the catalogue: every key an
|
||||
// operation, every operation placed `client` or `both` bound here, every call named as the
|
||||
// catalogue names it.
|
||||
//
|
||||
// How an action runs on a host:
|
||||
// 'tab' in this tab: the core in WebAssembly (core/, its call `noun_verb`)
|
||||
// 'local' through the local `wai`, which `wai dev` runs for the page (/_wai/api/op/<id>)
|
||||
// 'tab+local' in this tab, and through the local `wai` for what the tab does not hold
|
||||
// 'page' by the page itself: the room client, or a service's route on the page's origin
|
||||
// null not on this host; the palette says where it runs
|
||||
// An operation placed `client` that Studio does not run names the door it runs through
|
||||
// instead (`elsewhere`), so the palette says so.
|
||||
|
||||
export const PANELS = [
|
||||
{ id: 'inspect', name: 'Inspect', hosts: ['hosted', 'local'] },
|
||||
{ id: 'verify', name: 'Verify and receipts', hosts: ['hosted', 'local'] },
|
||||
{ id: 'media', name: 'Encode, decode, package', hosts: ['hosted', 'local'] },
|
||||
{ id: 'rooms', name: 'Rooms', hosts: ['hosted', 'local'] },
|
||||
{ id: 'run', name: 'Run', hosts: ['local'] },
|
||||
{ id: 'quantum', name: 'Quantum', hosts: ['hosted', 'local'] },
|
||||
];
|
||||
|
||||
export const BINDINGS = {
|
||||
// Inspect: an envelope read in the tab; through `wai dev`, the local wai's verdict too.
|
||||
'envelope.inspect': { panel: 'inspect', hosted: 'tab', local: 'tab+local' },
|
||||
'envelope.unwrap': { panel: 'inspect', hosted: 'tab', local: 'tab' },
|
||||
'registry.list': { panel: 'inspect', hosted: 'tab', local: 'tab' },
|
||||
// Verify and receipts: receipt indexes checked in the tab, offline.
|
||||
'receipt.verify': { panel: 'verify', hosted: 'tab', local: 'tab' },
|
||||
// Encode, decode, package: the floor in the tab; every codec the local wai holds through it.
|
||||
'media.decode': { panel: 'media', hosted: 'tab', local: 'tab+local' },
|
||||
'media.encode': { panel: 'media', hosted: 'tab', local: 'tab+local' },
|
||||
'envelope.wrap': { panel: 'media', hosted: 'tab', local: 'tab' },
|
||||
// Rooms: the room's signed description from its service, a viewer's place by the room client.
|
||||
'room.show': { panel: 'rooms', hosted: 'page', local: 'page' },
|
||||
'room.join': { panel: 'rooms', hosted: 'page', local: 'page' },
|
||||
'room.leave': { panel: 'rooms', hosted: 'page', local: 'page' },
|
||||
// Run: the development stack `wai dev` started, which serves this Studio.
|
||||
'dev.start': { panel: 'run', hosted: null, local: 'page' },
|
||||
'service.check': { panel: 'run', hosted: null, local: 'page' },
|
||||
'session.show': { panel: 'run', hosted: null, local: 'page' },
|
||||
// Placed `client`, run from the command line.
|
||||
'http.load': { elsewhere: 'cli' },
|
||||
'receipt.fetch': { elsewhere: 'cli' },
|
||||
};
|
||||
|
||||
/** How `id` runs on `host`, or null. */
|
||||
export function runs(id, host) {
|
||||
const b = BINDINGS[id];
|
||||
return b && b[host] != null ? b[host] : null;
|
||||
}
|
||||
93
wai/wai-studio/src/cores.js
Normal file
93
wai/wai-studio/src/cores.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Studio's two cores, behind the calls the panels make.
|
||||
//
|
||||
// TabCore the core in WebAssembly (core/, `wai-studio-core`), in a worker: each call is
|
||||
// an operation of the catalogue, named `noun_verb`, and answers the JSON
|
||||
// envelope the command line prints with --json, and any bytes it made.
|
||||
// LocalCore the local `wai`, reached through the dev stack's own routes, which `wai dev`
|
||||
// serves on loopback beside the page (/_wai/api/): its state, its logs, its
|
||||
// session records, and the operations it runs for the page.
|
||||
|
||||
export class TabCore {
|
||||
constructor(pkgUrl, workerUrl) {
|
||||
this.pkgUrl = pkgUrl;
|
||||
this.workerUrl = workerUrl;
|
||||
this.worker = null;
|
||||
this.waiting = new Map();
|
||||
this.next = 1;
|
||||
}
|
||||
|
||||
/** `call(name, ...args)`: {env, bytes}, env being the parsed envelope. */
|
||||
call(name, ...args) {
|
||||
if (!this.worker) {
|
||||
this.worker = new Worker(this.workerUrl, { type: 'module' });
|
||||
this.worker.onmessage = (ev) => {
|
||||
const w = this.waiting.get(ev.data.id);
|
||||
if (!w) return;
|
||||
this.waiting.delete(ev.data.id);
|
||||
if (ev.data.ok) w.resolve({ env: JSON.parse(ev.data.json), bytes: ev.data.bytes ?? new Uint8Array() });
|
||||
else w.reject(new Error(ev.data.error));
|
||||
};
|
||||
this.worker.onerror = (ev) => {
|
||||
for (const w of this.waiting.values()) w.reject(new Error(ev.message || 'the core did not load in this browser'));
|
||||
this.waiting.clear();
|
||||
this.worker = null;
|
||||
};
|
||||
}
|
||||
const id = this.next++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.waiting.set(id, { resolve, reject });
|
||||
this.worker.postMessage({ id, pkg: this.pkgUrl, call: name, args });
|
||||
});
|
||||
}
|
||||
|
||||
describe() { return this.call('core_describe').then((r) => r.env); }
|
||||
inspect(bytes) { return this.call('envelope_inspect', bytes); }
|
||||
wrap(capability, payload, fallback, intent) { return this.call('envelope_wrap', capability, payload, fallback || undefined, intent || undefined); }
|
||||
unwrap(bytes, component = -1) { return this.call('envelope_unwrap', bytes, component); }
|
||||
decode(bytes) { return this.call('media_decode', bytes); }
|
||||
encode(codec, input, width, height, quality) { return this.call('media_encode', codec, input, width >>> 0, height >>> 0, quality & 255); }
|
||||
verify(paths, files) { return this.call('receipt_verify', paths, files); }
|
||||
registry() { return this.call('registry_list'); }
|
||||
}
|
||||
|
||||
const b64 = (s) => {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
};
|
||||
|
||||
export class LocalCore {
|
||||
constructor(api) {
|
||||
this.api = api.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async get(path) {
|
||||
const r = await fetch(`${this.api}${path}`, { headers: { 'x-wai-studio': '1' }, cache: 'no-store' });
|
||||
const body = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(body.error || `wai dev answered ${r.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
state() { return this.get('/state'); }
|
||||
log(name, from = 0) { return this.get(`/log?name=${encodeURIComponent(name)}&from=${from}`); }
|
||||
sessions() { return this.get('/sessions'); }
|
||||
|
||||
/**
|
||||
* Run operation `id` on the local wai: `body` the one input file's bytes, `query` its
|
||||
* arguments. Answers {op, ok, exit, argv, stdout, stderr, outputs: [{name, bytes}]}.
|
||||
*/
|
||||
async op(id, body, query = {}) {
|
||||
const q = new URLSearchParams(Object.entries(query).filter(([, v]) => v != null && v !== '')).toString();
|
||||
const r = await fetch(`${this.api}/op/${encodeURIComponent(id)}${q ? `?${q}` : ''}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream', 'x-wai-studio': '1' },
|
||||
body,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const v = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(v.error || `wai dev answered ${r.status}`);
|
||||
v.outputs = (v.outputs || []).map((o) => ({ name: o.name, bytes: b64(o.data) }));
|
||||
return v;
|
||||
}
|
||||
}
|
||||
47
wai/wai-studio/src/dom.js
Normal file
47
wai/wai-studio/src/dom.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// An element with attributes and children: `class`, `text`, `on…` handlers, and
|
||||
// boolean attributes as true. Built as nodes, never from HTML (Quantum Studio's helper).
|
||||
export const el = (tag, attrs = {}, ...kids) => {
|
||||
const e = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (k === 'class') e.className = v;
|
||||
else if (k === 'text') e.textContent = v;
|
||||
else if (k.startsWith('on')) e.addEventListener(k.slice(2), v);
|
||||
else if (v === true) e.setAttribute(k, '');
|
||||
else if (v !== false && v != null) e.setAttribute(k, v);
|
||||
}
|
||||
for (const k of kids.flat()) if (k != null && k !== false) e.append(k);
|
||||
return e;
|
||||
};
|
||||
|
||||
// A pane: a bordered surface with a small uppercase head, as Quantum Studio draws one.
|
||||
export function pane(id, title, ...body) {
|
||||
const h = el('h2', { id: `${id}-h`, text: title });
|
||||
return el('section', { class: 'ws-pane', id, 'aria-labelledby': `${id}-h` }, el('div', { class: 'ws-head' }, h), el('div', { class: 'ws-body' }, ...body));
|
||||
}
|
||||
|
||||
// A key/value table.
|
||||
export function facts(rows, caption) {
|
||||
return el('table', { class: 'ws-table ws-facts' },
|
||||
caption ? el('caption', { class: 'ws-k', text: caption }) : null,
|
||||
el('tbody', {}, rows.filter(Boolean).map(([k, v]) => el('tr', {}, el('th', { scope: 'row', text: k }), el('td', {}, v == null || v === '' ? el('span', { class: 'ws-note', text: '—' }) : v)))));
|
||||
}
|
||||
|
||||
// A table with a head row.
|
||||
export function table(head, rows, cls = '') {
|
||||
return el('div', { class: 'ws-scroll' }, el('table', { class: `ws-table ${cls}` },
|
||||
el('thead', {}, el('tr', {}, head.map((h) => el('th', { scope: 'col', text: h })))),
|
||||
el('tbody', {}, rows.map((r) => el('tr', {}, r.map((c) => el('td', {}, c ?? '—')))))));
|
||||
}
|
||||
|
||||
// A verdict: ✓ or ✗ in words.
|
||||
export const badge = (ok, text) => el('span', { class: `ws-badge ${ok === true ? 'ok' : ok === false ? 'bad' : ''}`, text });
|
||||
|
||||
export const mono = (text) => el('code', { class: 'ws-mono', text: String(text) });
|
||||
|
||||
export const hexOf = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
export function bytesText(n) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1 << 20) return `${(n / 1024).toFixed(1)} KiB`;
|
||||
return `${(n / (1 << 20)).toFixed(1)} MiB`;
|
||||
}
|
||||
100
wai/wai-studio/src/files.js
Normal file
100
wai/wai-studio/src/files.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// Files in and out of the tab: read, dropped, saved, and turned into the inputs the codecs
|
||||
// take (packed RGB8 pixels, mono 32-bit float samples). Nothing is uploaded.
|
||||
|
||||
export const readBytes = async (file) => new Uint8Array(await file.arrayBuffer());
|
||||
|
||||
/** Every file of a FileList or a drop, with the path the browser gives it. */
|
||||
export function listed(files) {
|
||||
return Array.from(files || []).map((f) => ({ file: f, path: f.webkitRelativePath || f.name }));
|
||||
}
|
||||
|
||||
/** Save `bytes` as `name`. */
|
||||
export function save(bytes, name, type = 'application/octet-stream') {
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type }));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
a.hidden = true;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/** Files dropped on `target` go to `take` (as from an input). */
|
||||
export function dropInto(target, take) {
|
||||
target.addEventListener('dragover', (e) => { e.preventDefault(); target.classList.add('ws-dropping'); });
|
||||
target.addEventListener('dragleave', () => target.classList.remove('ws-dropping'));
|
||||
target.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
target.classList.remove('ws-dropping');
|
||||
if (e.dataTransfer?.files?.length) take(e.dataTransfer.files);
|
||||
});
|
||||
}
|
||||
|
||||
/** A picture file decoded by the browser into packed RGB8. */
|
||||
export async function pictureToRgb(file) {
|
||||
const bmp = await createImageBitmap(file);
|
||||
const c = document.createElement('canvas');
|
||||
c.width = bmp.width;
|
||||
c.height = bmp.height;
|
||||
const g = c.getContext('2d', { willReadFrequently: true });
|
||||
g.drawImage(bmp, 0, 0);
|
||||
const rgba = g.getImageData(0, 0, c.width, c.height).data;
|
||||
const rgb = new Uint8Array(c.width * c.height * 3);
|
||||
for (let i = 0, j = 0; i < rgba.length; i += 4, j += 3) {
|
||||
rgb[j] = rgba[i];
|
||||
rgb[j + 1] = rgba[i + 1];
|
||||
rgb[j + 2] = rgba[i + 2];
|
||||
}
|
||||
bmp.close?.();
|
||||
return { rgb, width: c.width, height: c.height };
|
||||
}
|
||||
|
||||
/** Packed RGB8 drawn on a canvas. */
|
||||
export function rgbCanvas(rgb, width, height, label) {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = width;
|
||||
c.height = height;
|
||||
c.className = 'ws-picture';
|
||||
c.setAttribute('role', 'img');
|
||||
c.setAttribute('aria-label', label);
|
||||
const img = new ImageData(width, height);
|
||||
for (let i = 0, j = 0; j < rgb.length; i += 4, j += 3) {
|
||||
img.data[i] = rgb[j];
|
||||
img.data[i + 1] = rgb[j + 1];
|
||||
img.data[i + 2] = rgb[j + 2];
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
c.getContext('2d').putImageData(img, 0, 0);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** A sound file decoded by the browser, mixed to one channel of 32-bit floats. */
|
||||
export async function soundToMono(file) {
|
||||
const ctx = new OfflineAudioContext(1, 1, 48000);
|
||||
const buf = await ctx.decodeAudioData(await file.arrayBuffer());
|
||||
const out = new Float32Array(buf.length);
|
||||
for (let ch = 0; ch < buf.numberOfChannels; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
for (let i = 0; i < d.length; i++) out[i] += d[i] / buf.numberOfChannels;
|
||||
}
|
||||
return { samples: out, sampleRate: buf.sampleRate };
|
||||
}
|
||||
|
||||
/** Little-endian 32-bit float samples, interleaved, played once. */
|
||||
export async function play(f32bytes, sampleRate, channels = 1) {
|
||||
const n = Math.floor(f32bytes.length / 4 / channels);
|
||||
const view = new DataView(f32bytes.buffer, f32bytes.byteOffset, f32bytes.byteLength);
|
||||
const ctx = new AudioContext();
|
||||
const buf = ctx.createBuffer(channels, Math.max(1, n), sampleRate);
|
||||
for (let ch = 0; ch < channels; ch++) {
|
||||
const d = buf.getChannelData(ch);
|
||||
for (let i = 0; i < n; i++) d[i] = view.getFloat32((i * channels + ch) * 4, true);
|
||||
}
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buf;
|
||||
src.connect(ctx.destination);
|
||||
src.onended = () => ctx.close();
|
||||
src.start();
|
||||
}
|
||||
160
wai/wai-studio/src/main.js
Normal file
160
wai/wai-studio/src/main.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// WAI Studio on the web: one front end, two hosts, chosen at start by the mount element.
|
||||
//
|
||||
// hosted (data-host="hosted") the core in WebAssembly, in this tab: nothing uploaded, no
|
||||
// account. wai.transaction.science/studio/ serves it.
|
||||
// local (data-host="local") served by `wai dev` at /_wai/, on loopback beside the dev
|
||||
// stack: the core in WebAssembly, and the local `wai` for
|
||||
// what the tab does not hold, reached through the stack's
|
||||
// own routes (/_wai/api/).
|
||||
//
|
||||
// Its panels show what runs on its host (./bindings.js), and its command palette lists every
|
||||
// operation of the WAI catalogue by its id (./actions.generated.js, generated from the
|
||||
// catalogue), saying where one runs that this host does not.
|
||||
//
|
||||
// The mount element:
|
||||
// <div id="wai-studio" data-host="hosted|local"
|
||||
// data-core-pkg="…/wai_studio_core.js" data-worker="…/wai-studio-worker.js"
|
||||
// data-api="/_wai/api" (local)
|
||||
// data-service="/room-api" data-room-page="…" data-relay="ws(s)://…/moq"
|
||||
// data-moq-transport="…" data-moq-session="…" data-moq-pkg="…"
|
||||
// data-quantum-script="…" data-quantum-css="…" data-quantum-pkg="…"
|
||||
// data-quantum-worker="…" data-quantum-service="…"
|
||||
// data-embedded> (the host page has its own h1)
|
||||
import { ACTIONS, CATALOGUE } from './actions.generated.js';
|
||||
import { PANELS, BINDINGS, runs } from './bindings.js';
|
||||
import { TabCore, LocalCore } from './cores.js';
|
||||
import { el } from './dom.js';
|
||||
import { palette } from './palette.js';
|
||||
import { inspectPanel } from './panels/inspect.js';
|
||||
import { verifyPanel } from './panels/verify.js';
|
||||
import { mediaPanel } from './panels/media.js';
|
||||
import { roomsPanel } from './panels/rooms.js';
|
||||
import { runPanel } from './panels/run.js';
|
||||
import { quantumPanel } from './panels/quantum.js';
|
||||
|
||||
const BUILDERS = { inspect: inspectPanel, verify: verifyPanel, media: mediaPanel, rooms: roomsPanel, run: runPanel, quantum: quantumPanel };
|
||||
|
||||
function config(root) {
|
||||
const d = root.dataset;
|
||||
// Every URL the page names, made absolute against the page: a worker resolves its own
|
||||
// imports against its own URL.
|
||||
const abs = (u, dflt) => new URL(u || dflt, location.href).href;
|
||||
const wsOrigin = location.origin.replace(/^http/, 'ws');
|
||||
return {
|
||||
host: d.host === 'local' ? 'local' : 'hosted',
|
||||
embedded: 'embedded' in d,
|
||||
corePkg: abs(d.corePkg, 'pkg/wai_studio_core.js'),
|
||||
worker: abs(d.worker, 'wai-studio-worker.js'),
|
||||
api: d.api || '/_wai/api',
|
||||
service: d.service || '/room-api',
|
||||
roomPage: d.roomPage || '/room/',
|
||||
relay: d.relay || `${wsOrigin}/moq`,
|
||||
moq: { transport: abs(d.moqTransport, '/moq/transport.js'), session: abs(d.moqSession, '/moq/session.js'), pkg: abs(d.moqPkg, '/pkg/joule_moq_web.js') },
|
||||
quantum: d.quantumScript
|
||||
? { script: abs(d.quantumScript), css: abs(d.quantumCss), pkg: abs(d.quantumPkg), worker: abs(d.quantumWorker), service: d.quantumService || null }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mount(root) {
|
||||
const cfg = config(root);
|
||||
const host = cfg.host;
|
||||
const status = el('span', { class: 'ws-status', id: 'ws-status', role: 'status', 'aria-live': 'polite' });
|
||||
const ctx = {
|
||||
cfg,
|
||||
host,
|
||||
tab: new TabCore(cfg.corePkg, cfg.worker),
|
||||
local: host === 'local' ? new LocalCore(cfg.api) : null,
|
||||
state: null,
|
||||
last: {},
|
||||
can: (id, how) => { const r = runs(id, host); return r === how || r === 'tab+local' && (how === 'tab' || how === 'local'); },
|
||||
status(text, cls = '') { status.textContent = text; status.className = `ws-status ${cls}`; },
|
||||
open: null,
|
||||
};
|
||||
|
||||
// The panels this host shows: the study's host column, and Quantum where its files are.
|
||||
const shown = PANELS.filter((p) => p.hosts.includes(host) && (p.id !== 'quantum' || cfg.quantum));
|
||||
const tabs = el('div', { class: 'ws-tabs', role: 'tablist', 'aria-label': 'Panels' });
|
||||
const panels = new Map();
|
||||
const views = el('div', { class: 'ws-views' });
|
||||
for (const p of shown) {
|
||||
const view = BUILDERS[p.id](ctx);
|
||||
const tab = el('button', { class: 'ws-seg', role: 'tab', id: `ws-tab-${p.id}`, type: 'button', 'aria-selected': 'false', 'aria-controls': `ws-panel-${p.id}`, tabindex: '-1', text: p.name });
|
||||
const panel = el('div', { class: 'ws-panel', role: 'tabpanel', id: `ws-panel-${p.id}`, 'aria-labelledby': `ws-tab-${p.id}`, hidden: true }, view.element);
|
||||
tab.addEventListener('click', () => select(p.id));
|
||||
tabs.append(tab);
|
||||
views.append(panel);
|
||||
panels.set(p.id, { p, view, tab, panel });
|
||||
}
|
||||
let current = null;
|
||||
async function select(id, focus = false) {
|
||||
if (current === id || !panels.has(id)) return panels.get(id);
|
||||
const prev = current && panels.get(current);
|
||||
if (prev) {
|
||||
prev.tab.setAttribute('aria-selected', 'false');
|
||||
prev.tab.tabIndex = -1;
|
||||
prev.panel.hidden = true;
|
||||
prev.view.hidden?.();
|
||||
}
|
||||
const next = panels.get(id);
|
||||
next.tab.setAttribute('aria-selected', 'true');
|
||||
next.tab.tabIndex = 0;
|
||||
next.panel.hidden = false;
|
||||
current = id;
|
||||
if (focus) next.tab.focus();
|
||||
try { await next.view.shown?.(); } catch (e) { ctx.status(`${next.p.name}: ${e.message}`, 'bad'); }
|
||||
try { history.replaceState(null, '', `#${id}`); } catch { /* a sandboxed frame */ }
|
||||
return next;
|
||||
}
|
||||
tabs.addEventListener('keydown', (e) => {
|
||||
const ids = [...panels.keys()];
|
||||
const at = ids.indexOf(current);
|
||||
const to = e.key === 'ArrowRight' ? ids[(at + 1) % ids.length] : e.key === 'ArrowLeft' ? ids[(at - 1 + ids.length) % ids.length] : e.key === 'Home' ? ids[0] : e.key === 'End' ? ids[ids.length - 1] : null;
|
||||
if (to) { e.preventDefault(); select(to, true); }
|
||||
});
|
||||
|
||||
// An operation opened from the palette, or handed from one panel to another with its input.
|
||||
ctx.open = async (panelId, opId, input) => {
|
||||
const s = await select(panelId);
|
||||
if (!s) return;
|
||||
if (input && s.view.receive) await s.view.receive(opId, input);
|
||||
else if (input) await s.view.show?.(input.name, input.bytes);
|
||||
const target = s.panel.querySelector(`[data-op="${opId}"]`);
|
||||
if (target) {
|
||||
const box = target.closest('details');
|
||||
if (box) box.open = true;
|
||||
target.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const pal = palette(host, (panelId, opId) => ctx.open(panelId, opId));
|
||||
const palBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-palette-open', 'aria-haspopup': 'dialog', 'aria-keyshortcuts': 'Control+K Meta+K', text: 'Command palette', onclick: () => pal.show() });
|
||||
const where = host === 'local' ? 'Served by wai dev on this machine' : 'In this browser';
|
||||
const header = el('header', { class: 'ws-top' },
|
||||
el('div', { class: 'ws-brand' }, el('span', { class: 'ws-mark', 'aria-hidden': 'true', text: '⟨wai⟩' }), el(cfg.embedded ? 'span' : 'h1', { class: 'ws-title', text: 'WAI Studio' })),
|
||||
el('span', { class: 'ws-badge', id: 'ws-host', 'data-host': host, text: where }),
|
||||
palBtn);
|
||||
const foot = el('footer', { class: 'ws-foot' },
|
||||
el('span', { text: host === 'local' ? 'The local wai runs on this machine; nothing leaves it.' : 'What you open is read in this tab; nothing is uploaded.' }),
|
||||
el('span', { class: 'ws-note', id: 'ws-catalogue', text: `Operations: the WAI catalogue (${CATALOGUE.format}, toolchain ${CATALOGUE.toolchain})` }),
|
||||
status);
|
||||
root.classList.add('ws');
|
||||
root.replaceChildren(header, tabs, views, foot, pal.element);
|
||||
addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); pal.show(); }
|
||||
});
|
||||
|
||||
const start = async () => {
|
||||
if (ctx.local) {
|
||||
try { ctx.state = await ctx.local.state(); } catch (e) { ctx.status(`wai dev: ${e.message}`, 'bad'); }
|
||||
}
|
||||
const want = location.hash.slice(1);
|
||||
await select(panels.has(want) ? want : shown[0].id);
|
||||
ctx.tab.describe().then((d) => ctx.status(`Ready. This tab decodes ${d.decodes.join(', ')}.`, 'ok')).catch((e) => ctx.status(`The core did not load: ${e.message}`, 'bad'));
|
||||
};
|
||||
const ready = start();
|
||||
return { ctx, panels, palette: pal, actions: ACTIONS, bindings: BINDINGS, select, ready };
|
||||
}
|
||||
|
||||
const root = document.getElementById('wai-studio');
|
||||
if (root) window.waiStudio = mount(root);
|
||||
100
wai/wai-studio/src/palette.js
Normal file
100
wai/wai-studio/src/palette.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// The command palette: every operation of the catalogue, by its id. One this Studio runs on
|
||||
// its host opens the panel that runs it; one it does not says where it runs, from what the
|
||||
// catalogue gives it: its command line, its MCP tool, the service route it is served at, and
|
||||
// Studio served by `wai dev` for what runs only there.
|
||||
import { ACTIONS } from './actions.generated.js';
|
||||
import { BINDINGS, PANELS } from './bindings.js';
|
||||
import { el } from './dom.js';
|
||||
|
||||
const HOW = {
|
||||
tab: 'in this tab',
|
||||
local: 'through the local wai',
|
||||
'tab+local': 'in this tab, and through the local wai',
|
||||
page: 'in this page',
|
||||
};
|
||||
|
||||
/** Where `a` (an action of ./actions.generated.js) runs, seen from `host`. */
|
||||
export function where(a, host) {
|
||||
const b = BINDINGS[a.id] || {};
|
||||
const here = b[host] ?? null;
|
||||
const panel = here ? PANELS.find((p) => p.id === b.panel) : null;
|
||||
const doors = [];
|
||||
if (!here && host === 'hosted' && b.local) doors.push({ door: 'wai dev', text: 'Studio served by `wai dev`, at /_wai/' });
|
||||
if (a.cli) doors.push({ door: 'cli', text: `\`${a.cli}\`` });
|
||||
if (a.mcp) doors.push({ door: 'mcp', text: `\`wai mcp\`, tool ${a.mcp}` });
|
||||
if (a.route) {
|
||||
const at = a.route.service === 'wai-room' && host === 'local' ? `\`wai dev\` at ${a.route.method} /room-api${a.route.path}` : `${a.route.service}: ${a.route.method} ${a.route.path}`;
|
||||
doors.push({ door: 'service', text: at });
|
||||
}
|
||||
return { here, how: here ? HOW[here] : null, panel: panel ? panel.id : null, panelName: panel ? panel.name : null, doors };
|
||||
}
|
||||
|
||||
/** The palette's line for `a`: "Runs …" or "Runs elsewhere: …". */
|
||||
export function whereText(a, host) {
|
||||
const w = where(a, host);
|
||||
if (w.here) return `Runs here, ${w.how}: ${w.panelName}`;
|
||||
if (!w.doors.length) return 'Runs elsewhere';
|
||||
return `Runs elsewhere: ${w.doors.map((d) => d.text.replaceAll('`', '')).join(' · ')}`;
|
||||
}
|
||||
|
||||
/** The palette, a dialog; `open(panelId, opId)` takes the operations this host runs. */
|
||||
export function palette(host, open) {
|
||||
const listId = 'ws-palette-list';
|
||||
const search = el('input', { class: 'ws-input', id: 'ws-palette-q', type: 'search', placeholder: 'An operation, by its id or what it does', autocomplete: 'off', 'aria-controls': listId });
|
||||
const label = el('label', { for: 'ws-palette-q', class: 'ws-k', text: 'Operations of the WAI catalogue' });
|
||||
const list = el('ul', { class: 'ws-palette-list', id: listId, 'aria-label': 'Operations' });
|
||||
const count = el('p', { class: 'ws-note', id: 'ws-palette-count', role: 'status', 'aria-live': 'polite' });
|
||||
const close = el('button', { class: 'ws-btn ws-small', type: 'button', text: 'Close', onclick: () => dlg.close() });
|
||||
const dlg = el('dialog', { class: 'ws-palette', id: 'ws-palette', 'aria-labelledby': 'ws-palette-title' },
|
||||
el('div', { class: 'ws-head' }, el('h2', { id: 'ws-palette-title', text: 'Command palette' }), el('span', { class: 'ws-grow' }), close),
|
||||
el('div', { class: 'ws-body' }, label, search, count, list));
|
||||
|
||||
const items = ACTIONS.map((a) => {
|
||||
const w = where(a, host);
|
||||
const id = el('code', { class: 'ws-mono ws-op-id', text: a.id });
|
||||
const sum = el('span', { class: 'ws-op-sum', text: a.summary });
|
||||
const line = el('span', { class: `ws-op-where${w.here ? ' here' : ''}`, text: whereText(a, host) });
|
||||
const li = el('li', { class: 'ws-op', 'data-id': a.id });
|
||||
if (w.here) {
|
||||
li.append(el('button', { class: 'ws-op-run', type: 'button', 'data-run': a.id, onclick: () => { dlg.close(); open(w.panel, a.id); } }, id, sum, line));
|
||||
} else {
|
||||
li.append(el('div', { class: 'ws-op-elsewhere' }, id, sum, line));
|
||||
}
|
||||
return { a, li, text: `${a.id} ${a.palette} ${a.summary} ${a.cli || ''}`.toLowerCase() };
|
||||
});
|
||||
list.append(...items.map((i) => i.li));
|
||||
|
||||
const filter = () => {
|
||||
const q = search.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
let n = 0;
|
||||
for (const i of items) {
|
||||
const on = q.every((w) => i.text.includes(w));
|
||||
i.li.hidden = !on;
|
||||
n += on ? 1 : 0;
|
||||
}
|
||||
count.textContent = q.length ? `${n} of ${items.length} operations` : `${items.length} operations`;
|
||||
};
|
||||
search.addEventListener('input', filter);
|
||||
// Escape closes it, from the search field too (where a browser would only clear the text);
|
||||
// up and down move between the operations shown; Enter runs one this host runs.
|
||||
dlg.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); dlg.close(); return; }
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const shown = items.filter((i) => !i.li.hidden).map((i) => i.li.querySelector('button, .ws-op-elsewhere'));
|
||||
shown.forEach((x) => { if (x.tagName !== 'BUTTON') x.tabIndex = -1; });
|
||||
const at = shown.indexOf(document.activeElement);
|
||||
const next = e.key === 'ArrowDown' ? Math.min(shown.length - 1, at + 1) : at <= 0 ? -1 : at - 1;
|
||||
e.preventDefault();
|
||||
(next < 0 ? search : shown[next])?.focus();
|
||||
});
|
||||
filter();
|
||||
return {
|
||||
element: dlg,
|
||||
show() {
|
||||
if (!dlg.open) dlg.showModal();
|
||||
search.select();
|
||||
search.focus();
|
||||
},
|
||||
items,
|
||||
};
|
||||
}
|
||||
146
wai/wai-studio/src/panels/inspect.js
Normal file
146
wai/wai-studio/src/panels/inspect.js
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// Inspect: a .wai file opened or dropped, read in this tab: its envelope, its capabilities
|
||||
// (with the registry's row for each), its provenance (what it refines, what it was derived
|
||||
// from, its security), SPEC §4's verdict at this sink, and its receipts when its receipt
|
||||
// index is opened with it. Through `wai dev`, the local wai's own report, with every codec it
|
||||
// holds, sits beside it.
|
||||
import { el, pane, facts, table, badge, mono, bytesText } from '../dom.js';
|
||||
import { readBytes, listed, save, dropInto } from '../files.js';
|
||||
import { receiptsView } from './verify.js';
|
||||
|
||||
const VERDICT_OK = new Set(['decodable', 'decodable-fallback']);
|
||||
|
||||
function capabilityRows(rows) {
|
||||
return table(['Role', 'Capability', 'Media', 'Fidelity', 'Licence', 'Replicate', 'Decoded'],
|
||||
rows.map((r) => [r.role, mono(r.capability), r.registry.media, r.registry.fidelity, r.registry.license, r.registry.replicate, r.held ? badge(true, 'here') : badge(null, r.why)]), 'ws-caps');
|
||||
}
|
||||
|
||||
function checkLine(name, c) {
|
||||
if (!c) return null;
|
||||
return [name, c.ok === true ? badge(true, '✓ holds') : c.ok === false ? el('span', {}, badge(false, '✗'), ' ', c.finding) : el('span', { text: c.finding })];
|
||||
}
|
||||
|
||||
export function inspectPanel(ctx) {
|
||||
const input = el('input', { type: 'file', id: 'ws-inspect-file', multiple: true, accept: '.wai,.receipts,.json,application/octet-stream', 'data-op': 'envelope.inspect' });
|
||||
const intro = el('p', { class: 'ws-why', text: ctx.host === 'local'
|
||||
? 'Open a .wai file, or drop it here. It is read in this tab, and the local wai gives its verdict with every codec it holds. Open its receipt index (<cid>.wai.receipts) and its rcpt/ files with it to check its receipts too.'
|
||||
: 'Open a .wai file, or drop it here. It is read in this tab; nothing is uploaded. Open its receipt index (<cid>.wai.receipts) and its rcpt/ files with it to check its receipts too.' });
|
||||
const out = el('div', { class: 'ws-out', id: 'ws-inspect-out', 'aria-live': 'polite' });
|
||||
const registryOut = el('div', { class: 'ws-out', id: 'ws-registry-out' });
|
||||
const registryBox = el('details', { class: 'ws-details', id: 'ws-registry' }, el('summary', { 'data-op': 'registry.list', text: 'The capability registry' }), registryOut);
|
||||
registryBox.addEventListener('toggle', async () => {
|
||||
if (!registryBox.open || registryOut.childElementCount) return;
|
||||
const { env } = await ctx.tab.registry();
|
||||
if (!env.ok) { registryOut.append(el('p', { class: 'ws-bad', text: env.error.message })); return; }
|
||||
registryOut.append(table(['Capability', 'Media', 'Fidelity', 'Licence', 'Replicate', 'Role', 'Decoded'],
|
||||
env.data.capabilities.map((r) => [mono(r.capability), r.media, r.fidelity, r.license, r.replicate, r.role, r.decode === 'here' ? badge(true, 'in this tab') : 'sink-supplied'])));
|
||||
});
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
intro,
|
||||
el('div', { class: 'ws-row' }, el('label', { class: 'ws-field' }, 'A .wai file, and its receipts', input)),
|
||||
out, registryBox);
|
||||
const p = pane('ws-inspect', 'Inspect', body);
|
||||
dropInto(p, (files) => take(files));
|
||||
input.addEventListener('change', () => take(input.files));
|
||||
|
||||
async function take(fileList) {
|
||||
const files = listed(fileList);
|
||||
const envelopes = files.filter((f) => /\.wai$/i.test(f.path));
|
||||
const first = envelopes[0] || files.find((f) => !/\.(receipts|json)$/i.test(f.path));
|
||||
out.replaceChildren();
|
||||
if (!first) { ctx.status('Open a .wai file.', 'bad'); return; }
|
||||
ctx.status(`Reading ${first.path}…`);
|
||||
const bytes = await readBytes(first.file);
|
||||
await show(first.path, bytes, files);
|
||||
}
|
||||
|
||||
async function show(name, bytes, files = []) {
|
||||
const { env } = await ctx.tab.inspect(bytes);
|
||||
ctx.last.inspect = env;
|
||||
out.replaceChildren();
|
||||
if (!env.ok) {
|
||||
out.append(el('p', { class: 'ws-bad', id: 'ws-inspect-error' }, badge(false, env.error.code), ' ', env.error.message));
|
||||
ctx.status(`${name}: ${env.error.message}`, 'bad');
|
||||
return;
|
||||
}
|
||||
const d = env.data;
|
||||
const verdict = d.verdict
|
||||
? el('p', { class: 'ws-verdict', id: 'ws-verdict' }, badge(VERDICT_OK.has(d.verdict.code), d.verdict.code), ' ', d.verdict.text)
|
||||
: el('p', { class: 'ws-verdict', id: 'ws-verdict' }, d.pick?.index != null ? badge(true, `pick [${d.pick.index}] ${d.pick.capability}`) : badge(false, 'no rendition this sink decodes'));
|
||||
out.append(
|
||||
el('h3', { class: 'ws-sub', text: name }),
|
||||
verdict,
|
||||
facts([
|
||||
['Envelope', `${d.version}, ${bytesText(d.size)}`],
|
||||
['Content hash', mono(d.cid)],
|
||||
['Media', d.media],
|
||||
['Intent', d.intent],
|
||||
d.version === 'WAI1' ? ['Payload', bytesText(d.payload_size)] : ['Components', String(d.renditions.length)],
|
||||
], 'Envelope'),
|
||||
);
|
||||
if (d.version === 'WAI1') {
|
||||
out.append(el('h4', { class: 'ws-k', text: 'Capabilities' }), capabilityRows(d.resolution));
|
||||
out.append(facts([
|
||||
checkLine('Component roles', d.roles),
|
||||
checkLine('Candidates', d.candidates),
|
||||
d.pin ? ['Pin', el('span', {}, mono(d.pin.text), ' ', d.pin.check.ok ? badge(true, '✓ no static finding') : badge(false, d.pin.check.finding))] : ['Pin', 'none'],
|
||||
d.int_payload ? checkLine('Integer payload', d.int_payload) : null,
|
||||
], 'Checks'));
|
||||
out.append(facts([
|
||||
['Refines', d.refines ? (d.refines.ok ? `base ${d.refines.base.slice(0, 16)}…, layer ${d.refines.layer}` : el('span', {}, badge(false, '✗'), ' ', d.refines.finding)) : 'nothing (not a layer)'],
|
||||
['Derived from', d.derived_from ? el('pre', { class: 'ws-pre', tabindex: '0', text: JSON.stringify(d.derived_from, null, 2) }) : 'not declared'],
|
||||
['Security', d.security ? el('pre', { class: 'ws-pre', tabindex: '0', text: JSON.stringify(d.security, null, 2) }) : 'plaintext'],
|
||||
], 'Provenance'));
|
||||
out.append(el('div', { class: 'ws-actions' },
|
||||
el('button', { class: 'ws-btn', type: 'button', 'data-op': 'envelope.unwrap', text: 'Unwrap the payload', onclick: () => unwrapTo(bytes, -1, name) }),
|
||||
el('button', { class: 'ws-btn', type: 'button', 'data-op': 'media.decode', text: 'Decode', onclick: () => ctx.open('media', 'media.decode', { name, bytes }) })));
|
||||
} else {
|
||||
out.append(el('h4', { class: 'ws-k', text: 'Components' }),
|
||||
table(['#', 'Capability', 'Role', 'Bytes', 'Digest', 'Pin', 'Decoded', ''],
|
||||
d.renditions.map((r) => [String(r.index), mono(r.capability), r.role + (r.companion_of ? ` of ${r.companion_of}` : ''), bytesText(r.bytes),
|
||||
r.sha256 ? el('span', {}, mono(r.sha256.preview), ' ', badge(r.sha256.ok, r.sha256.ok ? '✓' : '✗ component_digest_mismatch')) : 'not stated',
|
||||
r.pin ?? '—', r.held ? badge(true, 'here') : 'sink-supplied',
|
||||
el('button', { class: 'ws-btn ws-small', type: 'button', 'data-op': 'envelope.unwrap', text: 'Unwrap', 'aria-label': `Unwrap component ${r.index}`, onclick: () => unwrapTo(bytes, r.index, name) })])));
|
||||
out.append(facts([
|
||||
['Pins', d.pin_check.ok ? badge(true, '✓ no static finding') : el('span', {}, badge(false, `✗ [${d.pin_check.index}]`), ' ', d.pin_check.finding)],
|
||||
checkLine('Component roles', d.roles),
|
||||
checkLine('Candidates', d.candidates),
|
||||
d.int_payload ? ['Integer payload', el('span', {}, badge(false, `✗ [${d.int_payload.index}]`), ' ', d.int_payload.finding)] : null,
|
||||
['Skipped', d.pick.skipped.length ? el('ul', { class: 'ws-list' }, d.pick.skipped.map((s) => el('li', { text: `[${s.index}] ${s.why}` }))) : 'none'],
|
||||
], 'Checks'));
|
||||
}
|
||||
out.append(el('details', { class: 'ws-details' }, el('summary', { text: 'The manifest' }), el('pre', { class: 'ws-pre', tabindex: '0', id: 'ws-manifest', text: JSON.stringify(d.manifest, null, 2) })));
|
||||
// Its receipts: the index named by its content hash, among the files opened with it.
|
||||
const index = files.find((f) => f.path.endsWith(`${d.cid}.wai.receipts`));
|
||||
const receipts = el('div', { class: 'ws-out', id: 'ws-inspect-receipts' });
|
||||
out.append(el('h4', { class: 'ws-k', text: 'Receipts' }), receipts);
|
||||
if (index) {
|
||||
const given = await Promise.all(files.map(async (f) => [f.path, await readBytes(f.file)]));
|
||||
const { env: v } = await ctx.tab.verify(given.map((g) => g[0]), given.map((g) => g[1]));
|
||||
ctx.last.inspectReceipts = v;
|
||||
receipts.append(v.ok ? receiptsView(v.data, true) : el('p', { class: 'ws-bad', text: v.error.message }));
|
||||
} else {
|
||||
receipts.append(el('p', { class: 'ws-note', text: `No receipt index opened with it (${d.cid.slice(0, 16)}….wai.receipts).` }));
|
||||
}
|
||||
if (ctx.local) {
|
||||
const local = el('div', { class: 'ws-out', id: 'ws-inspect-local' }, el('p', { class: 'ws-note', text: 'The local wai is reading it…' }));
|
||||
out.append(el('h4', { class: 'ws-k', text: 'The local wai' }), local);
|
||||
try {
|
||||
const r = await ctx.local.op('envelope.inspect', bytes);
|
||||
ctx.last.inspectLocal = r;
|
||||
local.replaceChildren(el('p', {}, badge(r.ok, r.ok ? `✓ ${r.argv.join(' ')}` : `exit ${r.exit}`)), el('pre', { class: 'ws-pre', tabindex: '0', text: (r.stdout + (r.stderr ? `\n${r.stderr}` : '')).trim() }));
|
||||
} catch (e) {
|
||||
local.replaceChildren(el('p', { class: 'ws-bad', text: e.message }));
|
||||
}
|
||||
}
|
||||
ctx.status(`${name}: ${d.verdict ? d.verdict.text : d.pick?.index != null ? `component ${d.pick.index} decodes here` : 'no component decodes here'}`, 'ok');
|
||||
}
|
||||
|
||||
async function unwrapTo(bytes, component, name) {
|
||||
const { env, bytes: payload } = await ctx.tab.unwrap(bytes, component);
|
||||
if (!env.ok) { ctx.status(env.error.message, 'bad'); return; }
|
||||
save(payload, `${name.replace(/\.wai$/i, '')}.${env.data.component}.payload`);
|
||||
ctx.status(`Unwrapped component ${env.data.component} (${env.data.capability}), ${bytesText(env.data.bytes)}, byte for byte.`, 'ok');
|
||||
}
|
||||
|
||||
return { id: 'inspect', element: p, show, take };
|
||||
}
|
||||
187
wai/wai-studio/src/panels/media.js
Normal file
187
wai/wai-studio/src/panels/media.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// Encode, decode, package. In this tab: the mandatory floor (decode PNG, FLAC and zstd;
|
||||
// encode PNG and zstd) and any registered capability's bytes wrapped in an envelope. Through
|
||||
// `wai dev`, the local wai decodes and encodes every codec it holds.
|
||||
import { el, pane, facts, badge, mono, bytesText, hexOf } from '../dom.js';
|
||||
import { readBytes, save, dropInto, pictureToRgb, rgbCanvas, soundToMono, play } from '../files.js';
|
||||
|
||||
const IMAGE = new Set(['png', 'jpeg', 'avif', 'jxl', 'jxl-lossless']);
|
||||
const AUDIO = new Set(['opus', 'flac']);
|
||||
const INTENTS = ['replicate', 'improve', 'create'];
|
||||
|
||||
// The CLI's output beside its .meta: an image's "H W", a sound's "SR", a video's "N H W fps_num fps_den".
|
||||
function readMeta(bytes) {
|
||||
return new TextDecoder().decode(bytes).trim().split(/\s+/).map(Number);
|
||||
}
|
||||
|
||||
function preview(kind, bytes, d) {
|
||||
if (kind === 'image') return rgbCanvas(bytes.subarray(0, d.width * d.height * 3), d.width, d.height, `The decoded picture, ${d.width} by ${d.height}`);
|
||||
if (kind === 'audio') {
|
||||
const ch = d.channels || 1;
|
||||
return el('p', {}, `${d.samples / ch} samples a channel at ${d.sample_rate} Hz, ${ch} channel${ch === 1 ? '' : 's'} `,
|
||||
el('button', { class: 'ws-btn ws-small', type: 'button', text: '▶ Play', onclick: () => play(bytes, d.sample_rate, ch) }));
|
||||
}
|
||||
const head = bytes.subarray(0, 2048);
|
||||
let text;
|
||||
try { text = new TextDecoder('utf-8', { fatal: true }).decode(head); } catch { text = null; }
|
||||
return text != null ? el('pre', { class: 'ws-pre', tabindex: '0', text: text + (bytes.length > head.length ? '\n…' : '') }) : el('pre', { class: 'ws-pre', tabindex: '0', text: hexOf(head.subarray(0, 256)) });
|
||||
}
|
||||
|
||||
export function mediaPanel(ctx) {
|
||||
// ---- decode ----
|
||||
const decIn = el('input', { type: 'file', id: 'ws-decode-file', accept: '.wai,application/octet-stream', 'data-op': 'media.decode' });
|
||||
const decOut = el('div', { class: 'ws-out', id: 'ws-decode-out', 'aria-live': 'polite' });
|
||||
decIn.addEventListener('change', async () => { if (decIn.files[0]) await decode(decIn.files[0].name, await readBytes(decIn.files[0])); });
|
||||
|
||||
async function decode(name, bytes) {
|
||||
decOut.replaceChildren();
|
||||
ctx.status(`Decoding ${name}…`);
|
||||
const { env, bytes: out } = await ctx.tab.decode(bytes);
|
||||
ctx.last.decode = env;
|
||||
if (env.ok) {
|
||||
const d = env.data;
|
||||
decOut.append(el('p', { id: 'ws-decode-verdict' }, badge(true, `✓ decoded in this tab`), ' ', mono(d.capability), d.warning ? el('span', { class: 'ws-warn', text: ` ${d.warning}` }) : null),
|
||||
preview(d.kind, out, d),
|
||||
el('div', { class: 'ws-actions' }, el('button', { class: 'ws-btn', type: 'button', text: 'Save what it decodes to', onclick: () => saveDecoded(name, out, d) })));
|
||||
ctx.status(`${name}: decoded in this tab (${d.capability}).`, 'ok');
|
||||
return;
|
||||
}
|
||||
if (env.error.code === 'decode.inert' && ctx.local && ctx.can('media.decode', 'local')) {
|
||||
const r = await ctx.local.op('media.decode', bytes);
|
||||
ctx.last.decodeLocal = r;
|
||||
if (!r.ok) {
|
||||
decOut.append(el('p', { class: 'ws-bad' }, badge(false, `exit ${r.exit}`), ' ', (r.stderr || r.stdout).trim()));
|
||||
ctx.status(`${name}: the local wai did not decode it.`, 'bad');
|
||||
return;
|
||||
}
|
||||
const raw = r.outputs.find((o) => o.name === 'out')?.bytes ?? new Uint8Array();
|
||||
const meta = readMeta(r.outputs.find((o) => o.name === 'out.meta')?.bytes ?? new Uint8Array());
|
||||
decOut.append(el('p', { id: 'ws-decode-verdict' }, badge(true, '✓ decoded by the local wai'), ' ', mono(r.argv.join(' '))), el('pre', { class: 'ws-pre', tabindex: '0', text: r.stdout.trim() }));
|
||||
if (meta.length === 2 && raw.length === meta[0] * meta[1] * 3) decOut.append(rgbCanvas(raw, meta[1], meta[0], `The decoded picture, ${meta[1]} by ${meta[0]}`));
|
||||
else if (meta.length === 5) decOut.append(rgbCanvas(raw.subarray(0, meta[1] * meta[2] * 3), meta[2], meta[1], `The first of ${meta[0]} decoded frames, ${meta[2]} by ${meta[1]}`));
|
||||
else if (meta.length === 1) decOut.append(el('p', { text: `${raw.length / 4} samples at ${meta[0]} Hz ` }, el('button', { class: 'ws-btn ws-small', type: 'button', text: '▶ Play', onclick: () => play(raw, meta[0]) })));
|
||||
decOut.append(el('div', { class: 'ws-actions' }, el('button', { class: 'ws-btn', type: 'button', text: 'Save what it decodes to', onclick: () => { save(raw, `${name}.out`); for (const o of r.outputs.filter((x) => x.name !== 'out')) save(o.bytes, `${name}.${o.name}`); } })));
|
||||
ctx.status(`${name}: decoded by the local wai.`, 'ok');
|
||||
return;
|
||||
}
|
||||
decOut.append(el('p', { class: 'ws-bad', id: 'ws-decode-error' }, badge(false, env.error.code), ' ', env.error.message));
|
||||
ctx.status(`${name}: ${env.error.message}`, 'bad');
|
||||
}
|
||||
|
||||
function saveDecoded(name, out, d) {
|
||||
const stem = name.replace(/\.wai$/i, '');
|
||||
if (d.kind === 'image') { save(out, `${stem}.rgb`); save(new TextEncoder().encode(`${d.height} ${d.width}\n`), `${stem}.rgb.meta`); }
|
||||
else if (d.kind === 'audio') { save(out, `${stem}.f32`); save(new TextEncoder().encode(`${d.sample_rate}\n`), `${stem}.f32.meta`); }
|
||||
else save(out, `${stem}.out`);
|
||||
}
|
||||
|
||||
// ---- encode ----
|
||||
const codec = el('select', { class: 'ws-select', id: 'ws-encode-codec', 'data-op': 'media.encode' });
|
||||
const encIn = el('input', { type: 'file', id: 'ws-encode-file' });
|
||||
const quality = el('input', { class: 'ws-input', id: 'ws-encode-q', type: 'number', min: '1', max: '100', value: '75' });
|
||||
const encBtn = el('button', { class: 'ws-btn ws-primary', type: 'button', id: 'ws-encode-run', text: 'Encode' });
|
||||
const encOut = el('div', { class: 'ws-out', id: 'ws-encode-out', 'aria-live': 'polite' });
|
||||
const fillCodecs = (local) => {
|
||||
codec.replaceChildren(...['png', 'zstd'].map((c) => el('option', { value: c, text: `${c} (in this tab)` })),
|
||||
...(local || []).filter((c) => !['png', 'zstd'].includes(c)).map((c) => el('option', { value: c, text: `${c} (the local wai)` })));
|
||||
};
|
||||
fillCodecs([]);
|
||||
encBtn.addEventListener('click', async () => {
|
||||
const f = encIn.files[0];
|
||||
if (!f) { ctx.status('Choose a file to encode.', 'bad'); return; }
|
||||
const c = codec.value;
|
||||
const q = Math.max(1, Math.min(100, Number(quality.value) || 75));
|
||||
encOut.replaceChildren();
|
||||
ctx.status(`Encoding ${f.name} as ${c}…`);
|
||||
let input;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let rate = null;
|
||||
try {
|
||||
if (IMAGE.has(c)) ({ rgb: input, width, height } = await pictureToRgb(f));
|
||||
else if (AUDIO.has(c)) { const s = await soundToMono(f); input = new Uint8Array(s.samples.buffer); rate = s.sampleRate; }
|
||||
else input = await readBytes(f);
|
||||
} catch (e) {
|
||||
ctx.status(`${f.name}: this browser could not read it as ${IMAGE.has(c) ? 'a picture' : AUDIO.has(c) ? 'a sound' : 'a file'} (${e.message})`, 'bad');
|
||||
return;
|
||||
}
|
||||
let env;
|
||||
let wai;
|
||||
if (c === 'png' || c === 'zstd') {
|
||||
({ env, bytes: wai } = await ctx.tab.encode(c, input, width, height, q));
|
||||
} else {
|
||||
const r = await ctx.local.op('media.encode', input, { codec: c, width, height, quality: q, sample_rate: rate });
|
||||
env = r.ok ? { ok: true, data: { codec: c, envelope_bytes: r.outputs[0]?.bytes.length, by: r.argv.join(' ') } } : { ok: false, error: { code: `exit ${r.exit}`, message: (r.stderr || r.stdout).trim() } };
|
||||
wai = r.outputs.find((o) => o.name === 'out.wai')?.bytes;
|
||||
}
|
||||
ctx.last.encode = env;
|
||||
if (!env.ok) { encOut.append(el('p', { class: 'ws-bad', id: 'ws-encode-error' }, badge(false, env.error.code), ' ', env.error.message)); ctx.status(env.error.message, 'bad'); return; }
|
||||
const name = `${f.name.replace(/\.[^.]+$/, '')}.${c}.wai`;
|
||||
encOut.append(facts([['Envelope', `${name}, ${bytesText(wai.length)}`], ['Input', IMAGE.has(c) ? `${width} × ${height} pixels` : AUDIO.has(c) ? `${input.length / 4} samples at ${rate} Hz` : bytesText(input.length)], env.data.by ? ['By', mono(env.data.by)] : null]),
|
||||
el('div', { class: 'ws-actions' },
|
||||
el('button', { class: 'ws-btn', type: 'button', text: 'Save', onclick: () => save(wai, name) }),
|
||||
el('button', { class: 'ws-btn', type: 'button', text: 'Inspect it', onclick: () => ctx.open('inspect', 'envelope.inspect', { name, bytes: wai }) })));
|
||||
ctx.status(`Encoded ${name}.`, 'ok');
|
||||
});
|
||||
|
||||
// ---- package ----
|
||||
const cap = el('select', { class: 'ws-select', id: 'ws-wrap-cap', 'data-op': 'envelope.wrap' });
|
||||
const fallback = el('select', { class: 'ws-select', id: 'ws-wrap-fallback' });
|
||||
const intent = el('select', { class: 'ws-select', id: 'ws-wrap-intent' }, INTENTS.map((i) => el('option', { value: i, text: i })));
|
||||
const payload = el('input', { type: 'file', id: 'ws-wrap-file' });
|
||||
const wrapBtn = el('button', { class: 'ws-btn ws-primary', type: 'button', id: 'ws-wrap-run', text: 'Wrap' });
|
||||
const wrapOut = el('div', { class: 'ws-out', id: 'ws-wrap-out', 'aria-live': 'polite' });
|
||||
let registryLoaded = false;
|
||||
async function loadRegistry() {
|
||||
if (registryLoaded) return;
|
||||
registryLoaded = true;
|
||||
const { env } = await ctx.tab.registry();
|
||||
if (!env.ok) return;
|
||||
const rows = env.data.capabilities.filter((r) => !r.candidate && r.role !== 'refinement');
|
||||
cap.replaceChildren(...rows.map((r) => el('option', { value: r.capability, text: r.capability })));
|
||||
fallback.replaceChildren(el('option', { value: '', text: 'none' }), ...rows.filter((r) => r.role === 'standalone').map((r) => el('option', { value: r.capability, text: r.capability })));
|
||||
cap.value = 'wai.text.zstd';
|
||||
}
|
||||
wrapBtn.addEventListener('click', async () => {
|
||||
const f = payload.files[0];
|
||||
if (!f) { ctx.status('Choose the payload to wrap.', 'bad'); return; }
|
||||
wrapOut.replaceChildren();
|
||||
const { env, bytes: wai } = await ctx.tab.wrap(cap.value, await readBytes(f), fallback.value, intent.value);
|
||||
ctx.last.wrap = env;
|
||||
if (!env.ok) { wrapOut.append(el('p', { class: 'ws-bad', id: 'ws-wrap-error' }, badge(false, env.error.code), ' ', env.error.message)); ctx.status(env.error.message, 'bad'); return; }
|
||||
const name = `${f.name}.wai`;
|
||||
wrapOut.append(facts([['Envelope', `${name}, ${bytesText(env.data.envelope_bytes)}`], ['Capability', mono(env.data.capability)], ['Intent', env.data.intent], ['Fallback', env.data.fallback]]),
|
||||
el('div', { class: 'ws-actions' },
|
||||
el('button', { class: 'ws-btn', type: 'button', text: 'Save', onclick: () => save(wai, name) }),
|
||||
el('button', { class: 'ws-btn', type: 'button', text: 'Inspect it', onclick: () => ctx.open('inspect', 'envelope.inspect', { name, bytes: wai }) })));
|
||||
ctx.status(`Wrapped ${f.name} as ${env.data.capability}.`, 'ok');
|
||||
});
|
||||
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
el('p', { class: 'ws-why', text: ctx.host === 'local'
|
||||
? 'This tab decodes the mandatory floor (PNG, FLAC and zstd), encodes PNG and zstd, and wraps any registered capability\'s bytes in an envelope; the local wai decodes and encodes every other codec it holds. Nothing leaves this machine.'
|
||||
: 'This tab decodes the mandatory floor (PNG, FLAC and zstd), encodes PNG and zstd, and wraps any registered capability\'s bytes in an envelope. Nothing is uploaded.' }),
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-decode-h' }, el('h3', { class: 'ws-sub', id: 'ws-decode-h', text: 'Decode' }),
|
||||
el('label', { class: 'ws-field' }, 'A .wai file', decIn), decOut),
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-encode-h' }, el('h3', { class: 'ws-sub', id: 'ws-encode-h', text: 'Encode' }),
|
||||
el('div', { class: 'ws-row' }, el('label', { class: 'ws-field' }, 'Codec', codec), el('label', { class: 'ws-field' }, 'Quality', quality)),
|
||||
el('label', { class: 'ws-field' }, 'A picture, a sound, or any file for zstd', encIn), el('div', { class: 'ws-actions' }, encBtn), encOut),
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-wrap-h' }, el('h3', { class: 'ws-sub', id: 'ws-wrap-h', text: 'Package' }),
|
||||
el('p', { class: 'ws-note', text: 'Any registered capability\'s bytes, carried byte for byte in a WAI1 envelope, held to the rules wai wrap holds an envelope to.' }),
|
||||
el('div', { class: 'ws-row' }, el('label', { class: 'ws-field' }, 'Capability', cap), el('label', { class: 'ws-field' }, 'Fallback', fallback), el('label', { class: 'ws-field' }, 'Intent', intent)),
|
||||
el('label', { class: 'ws-field' }, 'The payload', payload), el('div', { class: 'ws-actions' }, wrapBtn), wrapOut));
|
||||
const p = pane('ws-media', 'Encode, decode, package', body);
|
||||
dropInto(p, async (files) => { const f = files[0]; if (f) await decode(f.name, await readBytes(f)); });
|
||||
|
||||
return {
|
||||
id: 'media',
|
||||
element: p,
|
||||
async shown() {
|
||||
await loadRegistry();
|
||||
if (ctx.local && ctx.state?.encodes) fillCodecs(ctx.state.encodes);
|
||||
},
|
||||
async receive(opId, input) {
|
||||
if (opId === 'media.decode' && input) await decode(input.name, input.bytes);
|
||||
},
|
||||
decode,
|
||||
};
|
||||
}
|
||||
25
wai/wai-studio/src/panels/quantum.js
Normal file
25
wai/wai-studio/src/panels/quantum.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Quantum: Quantum Studio (wai/quantum-studio), mounted as a panel, unchanged: it runs its
|
||||
// programs in this browser through the quantum job service's engine compiled to WebAssembly,
|
||||
// and on the job service where the host names one. It mounts itself on `#studio` when its
|
||||
// module loads, so the panel makes that element first and loads the module once.
|
||||
import { el, pane } from '../dom.js';
|
||||
|
||||
export function quantumPanel(ctx) {
|
||||
const q = ctx.cfg.quantum;
|
||||
const mount = el('div', { id: 'studio', 'data-embedded': true, 'data-engine-pkg': q.pkg, 'data-worker': q.worker, 'data-service': q.service || null });
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
el('p', { class: 'ws-why', text: 'Write OpenQASM and run it in this browser' + (q.service ? ' or on the WAI quantum service' : '') + ': the engine picks the method, says why and how exact the answer is, and signs it, and the receipt is checked in the page.' }),
|
||||
mount);
|
||||
let loaded = null;
|
||||
return {
|
||||
id: 'quantum',
|
||||
element: pane('ws-quantum', 'Quantum', body),
|
||||
shown() {
|
||||
loaded ??= (async () => {
|
||||
if (q.css && !document.querySelector(`link[href="${q.css}"]`)) document.head.append(el('link', { rel: 'stylesheet', href: q.css }));
|
||||
await import(q.script);
|
||||
})();
|
||||
return loaded;
|
||||
},
|
||||
};
|
||||
}
|
||||
139
wai/wai-studio/src/panels/rooms.js
Normal file
139
wai/wai-studio/src/panels/rooms.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Rooms: a room's signed description from its service (`room.show`, the service's GET /room
|
||||
// on the page's own origin), a viewer's place in it (`room.join`, `room.leave`) by the room
|
||||
// client itself, run in a frame of its own as the room page runs it, and a pre-call test of a
|
||||
// relay: its WebSocket binding opened with the MOQT subprotocol, and the relay's SETUP
|
||||
// answered, each timed.
|
||||
import { el, pane, facts, badge, mono } from '../dom.js';
|
||||
|
||||
const KINDS = ['watch', 'remote', 'co', 'e2ee', 'age', 'world'];
|
||||
const NAMES = { watch: 'a watch room', remote: 'a remote desktop', co: 'a show (co-performance)', e2ee: 'end-to-end encrypted', age: 'admits by age', world: 'a shared world' };
|
||||
|
||||
export function roomsPanel(ctx) {
|
||||
const cfg = ctx.cfg;
|
||||
const service = el('input', { class: 'ws-input', id: 'ws-room-service', value: cfg.service || '/room-api', spellcheck: 'false' });
|
||||
const showBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-room-show', 'data-op': 'room.show', text: 'Show the room' });
|
||||
const joinBtn = el('button', { class: 'ws-btn ws-primary', type: 'button', id: 'ws-room-join', 'data-op': 'room.join', text: 'Join as a viewer' });
|
||||
const leaveBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-room-leave', 'data-op': 'room.leave', text: 'Leave', hidden: true });
|
||||
const roomOut = el('div', { class: 'ws-out', id: 'ws-room-out', 'aria-live': 'polite' });
|
||||
const state = el('p', { class: 'ws-note', id: 'ws-room-state', role: 'status', 'aria-live': 'polite' });
|
||||
const frameBox = el('div', { class: 'ws-frame-box', id: 'ws-room-frame-box' });
|
||||
let frame = null;
|
||||
let watching = null;
|
||||
|
||||
const base = () => service.value.trim().replace(/\/+$/, '');
|
||||
|
||||
showBtn.addEventListener('click', async () => {
|
||||
roomOut.replaceChildren();
|
||||
try {
|
||||
const r = await fetch(`${base()}/room`, { cache: 'no-store' });
|
||||
const v = await r.json();
|
||||
if (!r.ok) throw new Error(v.error || `the room service answered ${r.status}`);
|
||||
ctx.last.room = v;
|
||||
const kinds = KINDS.filter((k) => v[k]).map((k) => NAMES[k]);
|
||||
roomOut.append(facts([
|
||||
['Room', mono(v.room)],
|
||||
['Kind', kinds.length ? kinds.join(', ') : 'a room'],
|
||||
['Generation', String(v.generation ?? 0)],
|
||||
['Present', v.present != null ? String(v.present) : null],
|
||||
['Publishers', v.max_publishers != null ? `${v.publishers ?? 0} of ${v.max_publishers}` : null],
|
||||
['Descriptor', `${(v.descriptor || '').length / 2} bytes, signed (WRD1)`],
|
||||
['Grants', `${(v.grants || []).length} publisher grant${(v.grants || []).length === 1 ? '' : 's'}`],
|
||||
], 'The room'));
|
||||
ctx.status('The room service answered with the room.', 'ok');
|
||||
} catch (e) {
|
||||
roomOut.append(el('p', { class: 'ws-bad' }, badge(false, '✗'), ' ', e.message));
|
||||
ctx.status(`The room service: ${e.message}`, 'bad');
|
||||
}
|
||||
});
|
||||
|
||||
// The room client in a frame of its own, entered as a viewer by its own Watch button.
|
||||
joinBtn.addEventListener('click', () => {
|
||||
leave();
|
||||
const page = `${cfg.roomPage}${cfg.roomPage.includes('?') ? '&' : '?'}service=${encodeURIComponent(base())}`;
|
||||
frame = el('iframe', { class: 'ws-frame', id: 'ws-room-frame', title: 'The room client, as a viewer', src: page });
|
||||
frameBox.replaceChildren(frame);
|
||||
state.textContent = 'Loading the room client…';
|
||||
frame.addEventListener('load', async () => {
|
||||
const w = frame.contentWindow;
|
||||
// The client wires its buttons as its module finishes, then says it is idle.
|
||||
const end = Date.now() + 30_000;
|
||||
while (w.roomTest?.state?.phase !== 'idle' && Date.now() < end) await new Promise((r) => setTimeout(r, 100));
|
||||
const watch = w.document.getElementById('watch');
|
||||
if (!watch || w.roomTest?.state?.phase !== 'idle') { state.textContent = `The room client did not start (${w.roomTest?.state?.phase ?? 'not loaded'}).`; return; }
|
||||
watch.click();
|
||||
leaveBtn.hidden = false;
|
||||
joinBtn.disabled = true;
|
||||
watching = setInterval(() => {
|
||||
const s = w.roomTest?.state;
|
||||
if (!s) return;
|
||||
const error = s.errors.length ? String(s.errors[s.errors.length - 1]).split('\n')[0].slice(0, 240) : null;
|
||||
ctx.last.roomState = { phase: s.phase, mode: s.mode, sessions: s.sessions, errors: s.errors.length, error, transport: s.transport?.kind ?? null };
|
||||
state.textContent = `In the room as a viewer: ${s.phase}${s.transport ? `, over ${s.transport.kind}` : ''}${error ? `. The room client says: ${error}` : ''}`;
|
||||
}, 500);
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
function leave() {
|
||||
if (watching) clearInterval(watching);
|
||||
watching = null;
|
||||
try { frame?.contentWindow?.document.getElementById('leave')?.click(); } catch { /* gone */ }
|
||||
frame?.remove();
|
||||
frame = null;
|
||||
leaveBtn.hidden = true;
|
||||
joinBtn.disabled = false;
|
||||
}
|
||||
leaveBtn.addEventListener('click', () => { leave(); state.textContent = 'Left the room.'; ctx.status('Left the room.', 'ok'); });
|
||||
|
||||
// ---- the pre-call test ----
|
||||
const relay = el('input', { class: 'ws-input', id: 'ws-relay-url', value: cfg.relay, spellcheck: 'false' });
|
||||
const testBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-relay-test', text: 'Test my connection' });
|
||||
const testOut = el('div', { class: 'ws-out', id: 'ws-relay-out', 'aria-live': 'polite' });
|
||||
testBtn.addEventListener('click', async () => {
|
||||
testOut.replaceChildren();
|
||||
testBtn.disabled = true;
|
||||
const url = relay.value.trim();
|
||||
const rows = [];
|
||||
try {
|
||||
const [{ connect }, { MoqSession, withTimeout }, moqPkg] = await Promise.all([import(cfg.moq.transport), import(cfg.moq.session), import(cfg.moq.pkg)]);
|
||||
await moqPkg.default();
|
||||
const ws = url.replace(/^http/, 'ws');
|
||||
const t0 = performance.now();
|
||||
const t = await connect({ websocket: ws, force: 'websocket' });
|
||||
const opened = performance.now() - t0;
|
||||
rows.push(['Binding', `WebSocket, subprotocol ${t.protocol}`], ['Opened in', `${opened.toFixed(0)} ms`]);
|
||||
const t1 = performance.now();
|
||||
let setup = null;
|
||||
try {
|
||||
const s = await withTimeout(MoqSession.connect(t, moqPkg, { implementation: 'wai-studio' }), 10000, 'the relay\'s SETUP');
|
||||
setup = performance.now() - t1;
|
||||
rows.push(['SETUP', `answered in ${setup.toFixed(0)} ms`]);
|
||||
ctx.last.relayTest = { ok: true, opened, setup, protocol: t.protocol };
|
||||
void s;
|
||||
} catch (e) {
|
||||
rows.push(['SETUP', e.message]);
|
||||
ctx.last.relayTest = { ok: false, opened, error: e.message, protocol: t.protocol };
|
||||
}
|
||||
try { t.close(0, 'pre-call test done'); } catch { /* closed */ }
|
||||
testOut.append(el('p', {}, badge(setup != null, setup != null ? '✓ the relay answers' : '✗ the relay did not answer SETUP')), facts(rows, 'The relay'));
|
||||
ctx.status(setup != null ? 'The relay answers.' : 'The relay opened but did not answer SETUP.', setup != null ? 'ok' : 'bad');
|
||||
} catch (e) {
|
||||
ctx.last.relayTest = { ok: false, error: e.message };
|
||||
testOut.append(el('p', { class: 'ws-bad' }, badge(false, '✗'), ' ', e.message));
|
||||
ctx.status(`The relay: ${e.message}`, 'bad');
|
||||
} finally {
|
||||
testBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-room-h' }, el('h3', { class: 'ws-sub', id: 'ws-room-h', text: 'A room' }),
|
||||
el('p', { class: 'ws-why', text: 'Show a room\'s signed description from its service, and join it as a viewer: the room client makes its own key, the service grants it a viewer\'s place, and every object is checked against its receipt before it is shown.' }),
|
||||
el('label', { class: 'ws-field' }, 'The room service, on this page\'s origin', service),
|
||||
el('div', { class: 'ws-actions' }, showBtn, joinBtn, leaveBtn),
|
||||
roomOut, state, frameBox),
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-relay-h' }, el('h3', { class: 'ws-sub', id: 'ws-relay-h', text: 'Pre-call test' }),
|
||||
el('p', { class: 'ws-why', text: 'Open a relay\'s WebSocket binding with the MOQT subprotocol and wait for its SETUP, timing each.' }),
|
||||
el('label', { class: 'ws-field' }, 'The relay', relay),
|
||||
el('div', { class: 'ws-actions' }, testBtn), testOut));
|
||||
return { id: 'rooms', element: pane('ws-rooms', 'Rooms', body), leave };
|
||||
}
|
||||
85
wai/wai-studio/src/panels/run.js
Normal file
85
wai/wai-studio/src/panels/run.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Run (through `wai dev` only): the development stack that serves this Studio, as `wai dev`
|
||||
// reports it: the origin and its routes, each service with the loopback address it listens
|
||||
// on, the room service's health (`service.check`), the relay's session records
|
||||
// (`session.show`), and each service's log, followed as it grows.
|
||||
import { el, pane, facts, table, badge, mono } from '../dom.js';
|
||||
|
||||
export function runPanel(ctx) {
|
||||
const stack = el('div', { class: 'ws-out', id: 'ws-run-stack', 'data-op': 'dev.start' });
|
||||
const health = el('div', { class: 'ws-out', id: 'ws-run-health' });
|
||||
const sessions = el('div', { class: 'ws-out', id: 'ws-run-sessions' });
|
||||
const logName = el('select', { class: 'ws-select', id: 'ws-run-log-name' });
|
||||
const logOut = el('pre', { class: 'ws-pre ws-log', id: 'ws-run-log', tabindex: '0', 'aria-label': 'The log' });
|
||||
const checkBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-run-check', 'data-op': 'service.check', text: 'Check the room service' });
|
||||
const sessBtn = el('button', { class: 'ws-btn', type: 'button', id: 'ws-run-sessions-show', 'data-op': 'session.show', text: 'Show the session records' });
|
||||
let from = 0;
|
||||
let follow = null;
|
||||
|
||||
async function render() {
|
||||
const s = await ctx.local.state();
|
||||
ctx.state = s;
|
||||
stack.replaceChildren(
|
||||
facts([
|
||||
['Origin', el('a', { href: s.origin + '/', text: s.origin })],
|
||||
['Room service', mono(s.room_api)],
|
||||
['Relay', mono(s.relay)],
|
||||
['Mode', s.mode],
|
||||
['Keys, logs', mono(s.dir)],
|
||||
['Up for', `${Math.round(s.uptime_s)} s`],
|
||||
], 'The stack'),
|
||||
table(['Service', 'Listens on', 'Process', 'Log'], s.services.map((x) => [x.name, mono(x.listen), String(x.pid), x.log ?? '—'])),
|
||||
el('p', { class: 'ws-note', id: 'ws-run-loopback', text: `Every address above is on loopback (${s.services.every((x) => /^127\.0\.0\.1:|^\[::1\]:/.test(x.listen)) ? 'checked' : 'NOT all'}); nothing listens beyond this machine.` }),
|
||||
el('div', { class: 'ws-actions' }, s.pages.map((p) => el('a', { class: 'ws-btn', href: p.href, target: '_blank', rel: 'noopener' },
|
||||
p.name, el('span', { 'aria-hidden': 'true', text: ' ↗' }), el('span', { class: 'ws-visually-hidden', text: ' (opens in a new tab)' })))));
|
||||
logName.replaceChildren(...s.logs.map((n) => el('option', { value: n, text: n })));
|
||||
return s;
|
||||
}
|
||||
|
||||
async function tail() {
|
||||
if (!logName.value) return;
|
||||
const r = await ctx.local.log(logName.value, from);
|
||||
if (r.from === 0 && from === 0) logOut.textContent = '';
|
||||
logOut.textContent += r.text;
|
||||
if (logOut.textContent.length > 200_000) logOut.textContent = logOut.textContent.slice(-150_000);
|
||||
from = r.next;
|
||||
logOut.scrollTop = logOut.scrollHeight;
|
||||
}
|
||||
logName.addEventListener('change', () => { from = 0; logOut.textContent = ''; tail(); });
|
||||
|
||||
checkBtn.addEventListener('click', async () => {
|
||||
health.replaceChildren();
|
||||
try {
|
||||
const r = await fetch(`${ctx.state.room_api}/health`.replace(/^https?:\/\/[^/]+/, ''), { cache: 'no-store' });
|
||||
const v = await r.json();
|
||||
ctx.last.health = v;
|
||||
health.append(el('p', {}, badge(v.ok === true && v.relay === true, v.ok && v.relay ? '✓ the room service answers, and its clock is on the relay' : '✗ not ready'), ' ', mono(`room ${String(v.room).slice(0, 16)}…`)));
|
||||
} catch (e) {
|
||||
health.append(el('p', { class: 'ws-bad', text: e.message }));
|
||||
}
|
||||
});
|
||||
|
||||
sessBtn.addEventListener('click', async () => {
|
||||
const r = await ctx.local.sessions();
|
||||
ctx.last.sessions = r;
|
||||
sessions.replaceChildren(r.sessions.length
|
||||
? table(['Session', 'Record'], r.sessions.map((x, i) => [String(i + 1), el('pre', { class: 'ws-pre', tabindex: '0', text: JSON.stringify(x, null, 2) })]))
|
||||
: el('p', { class: 'ws-note', text: 'The relay has closed no session yet; each session it closes is recorded here.' }));
|
||||
});
|
||||
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
el('p', { class: 'ws-why', text: 'The development stack wai dev started on this machine, which serves this Studio: its routes, its services and their logs.' }),
|
||||
stack,
|
||||
el('div', { class: 'ws-actions' }, checkBtn, sessBtn), health, sessions,
|
||||
el('section', { class: 'ws-part', 'aria-labelledby': 'ws-run-log-h' }, el('h3', { class: 'ws-sub', id: 'ws-run-log-h', text: 'Logs' }),
|
||||
el('label', { class: 'ws-field' }, 'The log of', logName), logOut));
|
||||
return {
|
||||
id: 'run',
|
||||
element: pane('ws-run', 'Run', body),
|
||||
async shown() {
|
||||
await render();
|
||||
await tail();
|
||||
follow ??= setInterval(() => { if (!document.hidden) tail().catch(() => {}); }, 1500);
|
||||
},
|
||||
hidden() { clearInterval(follow); follow = null; },
|
||||
};
|
||||
}
|
||||
58
wai/wai-studio/src/panels/verify.js
Normal file
58
wai/wai-studio/src/panels/verify.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Verify and receipts: receipt indexes (`<cid>.wai.receipts`, http-delivery §8.2) with their
|
||||
// group receipts (`rcpt/<link hash>.json`) and, where it is opened too, the object they are
|
||||
// about (`<cid>.wai`), checked in this tab, offline, by the verifier `wai verify` runs: each
|
||||
// delivery item's group and its parents by their links, jwp-receipts §3 steps 1–5 against
|
||||
// the object (`delivered-verified`) or against nothing (`inclusion-verified`), and each claim
|
||||
// the index lists. Each line says what held, or the reason code of what did not.
|
||||
import { el, pane, badge, mono } from '../dom.js';
|
||||
import { readBytes, listed, dropInto } from '../files.js';
|
||||
|
||||
/** The checked indexes, as the command line lists them. */
|
||||
export function receiptsView(data, compact = false) {
|
||||
const box = el('div', { class: 'ws-receipts' });
|
||||
for (const ix of data.indexes) {
|
||||
const name = ix.index.split('/').pop();
|
||||
box.append(el('div', { class: 'ws-receipt', 'data-index': name },
|
||||
el('p', { class: 'ws-receipt-head' }, badge(ix.ok, ix.ok ? '✓ verified' : '✗ refused'), ' ', mono(name),
|
||||
ix.object_held ? el('span', { class: 'ws-note', text: ' the object is here' }) : el('span', { class: 'ws-note', text: ' the object is not here' })),
|
||||
el('ul', { class: 'ws-lines' }, ix.lines.map((l) => el('li', { class: l.ok ? 'ok' : 'bad', 'data-code': l.code ?? '' }, el('span', { class: 'ws-mark', 'aria-hidden': 'true', text: l.ok ? '✓' : '✗' }), el('span', { class: 'ws-visually-hidden', text: l.ok ? 'holds: ' : 'refused: ' }), l.text)))));
|
||||
}
|
||||
if (!compact) {
|
||||
const n = data.indexes.length;
|
||||
box.append(el('p', { class: 'ws-summary', id: 'ws-verify-summary' }, badge(data.ok, data.ok ? '✓' : '✗'), ` ${n} receipt index${n === 1 ? '' : 'es'} ${data.ok ? 'verified' : 'with a refusal (above)'}`));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
export function verifyPanel(ctx) {
|
||||
const files = el('input', { type: 'file', id: 'ws-verify-files', multiple: true, 'data-op': 'receipt.verify' });
|
||||
const folder = el('input', { type: 'file', id: 'ws-verify-folder', webkitdirectory: true, multiple: true });
|
||||
const out = el('div', { class: 'ws-out', id: 'ws-verify-out', 'aria-live': 'polite' });
|
||||
const body = el('div', { class: 'ws-col' },
|
||||
el('p', { class: 'ws-why', text: 'Open receipt indexes (<cid>.wai.receipts) with their group receipts (rcpt/<link hash>.json) and, where you have it, the object (<cid>.wai): open the folder that holds them, or the files, or drop them here. They are checked in this tab, offline.' }),
|
||||
el('div', { class: 'ws-row' },
|
||||
el('label', { class: 'ws-field' }, 'Files', files),
|
||||
el('label', { class: 'ws-field' }, 'A folder', folder)),
|
||||
out);
|
||||
const p = pane('ws-verify', 'Verify and receipts', body);
|
||||
dropInto(p, (f) => take(f));
|
||||
files.addEventListener('change', () => take(files.files));
|
||||
folder.addEventListener('change', () => take(folder.files));
|
||||
|
||||
async function take(fileList) {
|
||||
const given = await Promise.all(listed(fileList).map(async (f) => [f.path, await readBytes(f.file)]));
|
||||
out.replaceChildren();
|
||||
ctx.status(`Checking ${given.length} file${given.length === 1 ? '' : 's'}…`);
|
||||
const { env } = await ctx.tab.verify(given.map((g) => g[0]), given.map((g) => g[1]));
|
||||
ctx.last.verify = env;
|
||||
if (!env.ok) {
|
||||
out.append(el('p', { class: 'ws-bad', id: 'ws-verify-error' }, badge(false, env.error.code), ' ', env.error.message));
|
||||
ctx.status(env.error.message, 'bad');
|
||||
return;
|
||||
}
|
||||
out.append(receiptsView(env.data));
|
||||
ctx.status(env.data.ok ? 'Every receipt index verified.' : 'A receipt was refused; its reason is above.', env.data.ok ? 'ok' : 'bad');
|
||||
}
|
||||
|
||||
return { id: 'verify', element: p, take };
|
||||
}
|
||||
103
wai/wai-studio/src/studio.css
Normal file
103
wai/wai-studio/src/studio.css
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/* WAI Studio. Every colour and face comes from the host's design tokens (tokens.css standing
|
||||
alone; the site's own inside the site), and every part is drawn as Quantum Studio draws
|
||||
its own: the same top bar, segments, panes, buttons, badges and tables. */
|
||||
.ws {
|
||||
--ws-gap: 12px;
|
||||
--ws-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ws-gap);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-base);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ws *, .ws *::before, .ws *::after { box-sizing: border-box; }
|
||||
.ws h1, .ws h2, .ws h3, .ws h4 { margin: 0; text-wrap: balance; }
|
||||
.ws [hidden] { display: none !important; }
|
||||
.ws-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--ws-radius); background: var(--bg-surface); }
|
||||
.ws-brand { display: flex; align-items: baseline; gap: 8px; margin-right: auto; }
|
||||
.ws-brand .ws-title { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: 0.01em; }
|
||||
.ws-mark { font-family: var(--font-mono, ui-monospace, monospace); color: var(--accent-text); font-size: 14px; }
|
||||
.ws-tabs { display: flex; flex-wrap: wrap; border: 1px solid var(--border-subtle); border-radius: 6px; overflow: hidden; align-self: flex-start; max-width: 100%; }
|
||||
.ws-seg { min-height: 32px; padding: 5px 11px; border: 0; background: var(--bg-base); color: var(--text-secondary); font: 12px var(--font-mono, ui-monospace, monospace); cursor: pointer; }
|
||||
.ws-seg + .ws-seg { border-left: 1px solid var(--border-subtle); }
|
||||
.ws-seg[aria-selected="true"] { background: color-mix(in srgb, var(--accent) 18%, var(--bg-base)); color: var(--text-primary); }
|
||||
.ws :focus-visible { outline: 2px solid var(--focus-ring, var(--accent)); outline-offset: 2px; }
|
||||
.ws-views { min-width: 0; }
|
||||
.ws-pane { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--border); border-radius: var(--ws-radius); background: var(--bg-surface); overflow: hidden; }
|
||||
.ws-pane.ws-dropping { border-color: var(--accent); }
|
||||
.ws-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); }
|
||||
.ws-head h2 { font-size: 11px; font-family: var(--font-mono, ui-monospace, monospace); font-weight: 500; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-secondary); }
|
||||
.ws-body { padding: 10px 12px 14px; min-width: 0; }
|
||||
.ws-grow { flex: 1; }
|
||||
.ws-col { display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.ws-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-end; }
|
||||
.ws-part { display: flex; flex-direction: column; gap: 8px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||||
.ws-sub { font-size: 13px; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.ws-why { margin: 0; color: var(--text-secondary); font-size: 13px; line-height: 1.55; max-width: 72ch; }
|
||||
.ws-field { display: flex; flex-direction: column; gap: 3px; flex: 1 1 160px; min-width: 0; font-size: 10px; font-family: var(--font-mono, ui-monospace, monospace); letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
|
||||
.ws-field input[type="file"] { font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); letter-spacing: 0; text-transform: none; max-width: 100%; }
|
||||
.ws-input, .ws-select { width: 100%; min-height: 32px; padding: 5px 8px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-base); color: var(--text-primary); font: 12px var(--font-mono, ui-monospace, monospace); letter-spacing: 0; text-transform: none; }
|
||||
.ws-btn { min-height: 32px; padding: 5px 12px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-base); color: var(--text-primary); font: 12px var(--font-mono, ui-monospace, monospace); cursor: pointer; white-space: nowrap; text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.ws-btn:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.ws-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.ws-btn.ws-small { min-height: 26px; padding: 2px 9px; font-size: 11px; }
|
||||
.ws-primary { background: var(--accent-fill, var(--accent)); border-color: var(--accent-fill, var(--accent)); color: var(--on-accent, #0c0a09); font-weight: 650; }
|
||||
.ws-primary:hover:not(:disabled) { background: var(--accent-fill-hover, var(--accent)); }
|
||||
.ws-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
.ws-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border-subtle); font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }
|
||||
.ws-badge.ok { color: var(--ok-text); border-color: color-mix(in srgb, var(--ok-text) 40%, transparent); }
|
||||
.ws-badge.bad { color: var(--bad-text); border-color: color-mix(in srgb, var(--bad-text) 40%, transparent); }
|
||||
.ws-note { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono, ui-monospace, monospace); overflow-wrap: anywhere; margin: 0; }
|
||||
.ws-k { font: 10px var(--font-mono, ui-monospace, monospace); letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-muted); text-align: left; margin: 0; }
|
||||
.ws-bad { color: var(--bad-text); margin: 0; overflow-wrap: anywhere; }
|
||||
.ws-warn { color: var(--warn-text); }
|
||||
.ws-out { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.ws-out:empty { display: none; }
|
||||
.ws-verdict, .ws-summary { margin: 0; font-size: 13px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.ws-mono { font: 12px var(--font-mono, ui-monospace, monospace); overflow-wrap: anywhere; }
|
||||
.ws-scroll { overflow-x: auto; max-width: 100%; }
|
||||
.ws-table { width: 100%; border-collapse: collapse; font: 12px var(--font-mono, ui-monospace, monospace); font-variant-numeric: tabular-nums; }
|
||||
.ws-table caption { padding-bottom: 4px; }
|
||||
.ws-table th, .ws-table td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--border); vertical-align: top; overflow-wrap: anywhere; }
|
||||
.ws-table th { color: var(--text-secondary); font-weight: 500; }
|
||||
.ws-table thead th { color: var(--text-muted); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
.ws-facts th { width: 11em; }
|
||||
.ws-pre { margin: 0; padding: 8px 10px; border-radius: 6px; background: var(--bg-base); border: 1px solid var(--border); font: 12px/1.4 var(--font-mono, ui-monospace, monospace); overflow: auto; white-space: pre; max-height: 340px; max-width: 100%; }
|
||||
.ws-log { min-height: 160px; }
|
||||
.ws-details summary { cursor: pointer; font: 12px var(--font-mono, ui-monospace, monospace); color: var(--text-secondary); padding: 4px 0; }
|
||||
.ws-list { margin: 0; padding-left: 18px; }
|
||||
.ws-lines { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; font: 12px/1.45 var(--font-mono, ui-monospace, monospace); }
|
||||
.ws-lines li { display: flex; gap: 6px; overflow-wrap: anywhere; }
|
||||
.ws-lines li.ok .ws-mark { color: var(--ok-text); }
|
||||
.ws-lines li.bad { color: var(--bad-text); }
|
||||
.ws-receipts { display: flex; flex-direction: column; gap: 10px; }
|
||||
.ws-receipt { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-base); min-width: 0; }
|
||||
.ws-receipt-head { margin: 0; overflow-wrap: anywhere; }
|
||||
.ws-picture { max-width: 100%; height: auto; image-rendering: pixelated; border: 1px solid var(--border); border-radius: 6px; align-self: flex-start; background: var(--bg-base); }
|
||||
.ws-frame-box:empty { display: none; }
|
||||
.ws-frame { width: 100%; min-height: 560px; border: 1px solid var(--border); border-radius: var(--ws-radius); background: var(--bg-base); }
|
||||
.ws-foot { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; padding: 4px 2px; font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); }
|
||||
.ws-status { margin-left: auto; color: var(--text-secondary); overflow-wrap: anywhere; }
|
||||
.ws-status.ok { color: var(--ok-text); }
|
||||
.ws-status.bad { color: var(--bad-text); }
|
||||
.ws-visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
/* The command palette: a dialog over the page. */
|
||||
.ws-palette { width: min(760px, calc(100vw - 32px)); max-height: min(80vh, 760px); padding: 0; border: 1px solid var(--border-subtle); border-radius: var(--ws-radius); background: var(--bg-surface); color: var(--text-primary); }
|
||||
.ws-palette::backdrop { background: color-mix(in srgb, var(--bg-base) 70%, transparent); }
|
||||
.ws-palette .ws-body { display: flex; flex-direction: column; gap: 6px; }
|
||||
.ws-palette-list { list-style: none; margin: 0; padding: 0; overflow: auto; max-height: min(60vh, 560px); display: flex; flex-direction: column; gap: 2px; }
|
||||
.ws-op > button, .ws-op > div { display: flex; flex-direction: column; gap: 2px; width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: transparent; color: var(--text-primary); text-align: left; font: inherit; }
|
||||
.ws-op > button { cursor: pointer; }
|
||||
.ws-op > button:hover, .ws-op > button:focus-visible { background: color-mix(in srgb, var(--accent) 10%, transparent); }
|
||||
.ws-op-id { color: var(--accent-text); font-size: 12px; }
|
||||
.ws-op-sum { font-size: 12px; color: var(--text-secondary); line-height: 1.4; }
|
||||
.ws-op-where { font: 11px var(--font-mono, ui-monospace, monospace); color: var(--text-muted); overflow-wrap: anywhere; }
|
||||
.ws-op-where.here { color: var(--ok-text); }
|
||||
@media (max-width: 640px) {
|
||||
.ws-top { padding: 8px 10px; }
|
||||
.ws-facts th { width: auto; }
|
||||
.ws-frame { min-height: 420px; }
|
||||
}
|
||||
178
wai/wai-studio/src/tokens.css
Normal file
178
wai/wai-studio/src/tokens.css
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
/* The site's design tokens (wai-transaction-science-web src/styles/global.css), so the Studio
|
||||
looks the same standing alone (served by wai dev) as inside the site. Copied, not edited. */
|
||||
/* -- Design Tokens -- */
|
||||
:root {
|
||||
/* Neutrals — warm-tinted blacks (shared with transaction.science) */
|
||||
--stone-950: #0c0a09;
|
||||
--stone-900: #1c1917;
|
||||
--stone-800: #292524;
|
||||
--stone-700: #44403c;
|
||||
--stone-600: #57534e;
|
||||
--stone-500: #78716c;
|
||||
--stone-400: #a8a29e;
|
||||
--stone-300: #d6d3d1;
|
||||
--stone-200: #e7e5e4;
|
||||
--stone-100: #f5f5f4;
|
||||
--stone-50: #fafaf9;
|
||||
|
||||
/* Brand — WAI warm amber/tan.
|
||||
--accent is decorative: borders, glows, the focus ring and selection.
|
||||
Accent-coloured TEXT uses --accent-text; a button or tile uses --accent-fill with
|
||||
--on-accent on it. The hues are the brand's; each theme picks the step that keeps
|
||||
text at WCAG 2.2 AA (4.5:1) on the base and surface backgrounds. */
|
||||
--accent: #d4a574;
|
||||
--accent-light: #e0bd95;
|
||||
--accent-dark: #a87f4f;
|
||||
--accent-text: #d4a574;
|
||||
--accent-text-hover: #e0bd95;
|
||||
--accent-text-elevated: #d4a574;
|
||||
--accent-fill: #d4a574;
|
||||
--accent-fill-hover: #e0bd95;
|
||||
--on-accent: #0c0a09;
|
||||
/* The keyboard focus ring. It must reach 3:1 against every background it can sit on
|
||||
(base, surface and the elevated theme toggle): in the light theme --accent is 2.88:1
|
||||
on --bg-elevated, so the ring takes the darker step, which is 5.0:1 there. */
|
||||
--focus-ring: #d4a574;
|
||||
|
||||
/* Status text on the demonstration pages: a verdict that holds, one that fails, a
|
||||
warning, an informational value. Dark values are the pages' own; the light theme
|
||||
takes a darker step of the same hue so the text stays at 4.5:1 or more. */
|
||||
--ok-text: #00e5a0;
|
||||
--std-text: #10b981;
|
||||
--bad-text: #ff6b6b;
|
||||
--error-text: #ef4444;
|
||||
--warn-text: #f0a020;
|
||||
--info-text: #7aa0ff;
|
||||
|
||||
/* Sibling platform colors */
|
||||
--veritas-color: #0d9488;
|
||||
--terra-color: #3b82f6;
|
||||
--insights-color: #f59e0b;
|
||||
--tradingos-color: #06b6d4;
|
||||
--legalos-color: #8b5cf6;
|
||||
--compliance-color: #f43f5e;
|
||||
--settlement-color: #10b981;
|
||||
|
||||
/* Type — both faces are served from this site (above); a system face stands in only
|
||||
until one loads, or where it cannot */
|
||||
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
/* Semantic — dark by default */
|
||||
--text-primary: var(--stone-100);
|
||||
--text-secondary: var(--stone-400);
|
||||
--text-muted: #948d88;
|
||||
--bg-base: var(--stone-950);
|
||||
--bg-surface: var(--stone-900);
|
||||
--bg-elevated: var(--stone-800);
|
||||
--border: var(--stone-800);
|
||||
--border-subtle: var(--stone-700);
|
||||
--nav-bg: rgba(12, 10, 9, 0.8);
|
||||
--selection-color: white;
|
||||
/* Headline gradient: light to mid stone on the dark page, dark to mid on the light
|
||||
one, so both ends of a headline stay readable (7:1 or more at the faint end). */
|
||||
--gradient-text-from: var(--stone-100);
|
||||
--gradient-text-to: var(--stone-400);
|
||||
}
|
||||
|
||||
/* -- Dark theme (explicit) -- */
|
||||
[data-theme="dark"] {
|
||||
--text-primary: var(--stone-100);
|
||||
--text-secondary: var(--stone-400);
|
||||
--text-muted: #948d88;
|
||||
--bg-base: var(--stone-950);
|
||||
--bg-surface: var(--stone-900);
|
||||
--bg-elevated: var(--stone-800);
|
||||
--border: var(--stone-800);
|
||||
--border-subtle: var(--stone-700);
|
||||
--nav-bg: rgba(12, 10, 9, 0.8);
|
||||
--selection-color: white;
|
||||
--accent: #d4a574;
|
||||
--accent-light: #e0bd95;
|
||||
--accent-dark: #a87f4f;
|
||||
--accent-text: #d4a574;
|
||||
--accent-text-hover: #e0bd95;
|
||||
--accent-text-elevated: #d4a574;
|
||||
--accent-fill: #d4a574;
|
||||
--accent-fill-hover: #e0bd95;
|
||||
--on-accent: #0c0a09;
|
||||
--focus-ring: #d4a574;
|
||||
--ok-text: #00e5a0;
|
||||
--std-text: #10b981;
|
||||
--bad-text: #ff6b6b;
|
||||
--error-text: #ef4444;
|
||||
--warn-text: #f0a020;
|
||||
--info-text: #7aa0ff;
|
||||
}
|
||||
|
||||
/* -- Light theme -- */
|
||||
[data-theme="light"] {
|
||||
--text-primary: var(--stone-900);
|
||||
--text-secondary: var(--stone-600);
|
||||
--text-muted: #6b645f;
|
||||
--bg-base: var(--stone-50);
|
||||
--bg-surface: var(--stone-100);
|
||||
--bg-elevated: var(--stone-200);
|
||||
--border: var(--stone-200);
|
||||
--border-subtle: var(--stone-300);
|
||||
--accent: #a87f4f;
|
||||
/* In light mode the "light" step is the emphasis step used for code and hover
|
||||
text, so it is the darker one: #d4a574 on the light surface is 2:1. */
|
||||
--accent-light: #7a5a37;
|
||||
--accent-dark: #8a6740;
|
||||
--accent-text: #8a6740;
|
||||
--accent-text-hover: #7a5a37;
|
||||
/* accent text on --bg-elevated: #8a6740 is 4.08:1 there, one step darker is 5.2:1 */
|
||||
--accent-text-elevated: #7a5a37;
|
||||
--accent-fill: #8a6740;
|
||||
--accent-fill-hover: #7a5a37;
|
||||
--on-accent: #ffffff;
|
||||
--focus-ring: #7a5a37;
|
||||
--ok-text: #047857;
|
||||
--std-text: #047857;
|
||||
--bad-text: #b91c1c;
|
||||
--error-text: #b91c1c;
|
||||
--warn-text: #b45309;
|
||||
--info-text: #1d4ed8;
|
||||
--nav-bg: rgba(250, 250, 249, 0.8);
|
||||
--selection-color: var(--stone-900);
|
||||
--gradient-text-from: var(--stone-900);
|
||||
--gradient-text-to: var(--stone-600);
|
||||
}
|
||||
|
||||
/* -- Auto theme -- */
|
||||
[data-theme="auto"] {}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
[data-theme="auto"] {
|
||||
--text-primary: var(--stone-900);
|
||||
--text-secondary: var(--stone-600);
|
||||
--text-muted: #6b645f;
|
||||
--bg-base: var(--stone-50);
|
||||
--bg-surface: var(--stone-100);
|
||||
--bg-elevated: var(--stone-200);
|
||||
--border: var(--stone-200);
|
||||
--border-subtle: var(--stone-300);
|
||||
--accent: #a87f4f;
|
||||
--accent-light: #7a5a37;
|
||||
--accent-dark: #8a6740;
|
||||
--accent-text: #8a6740;
|
||||
--accent-text-hover: #7a5a37;
|
||||
--accent-text-elevated: #7a5a37;
|
||||
--accent-fill: #8a6740;
|
||||
--accent-fill-hover: #7a5a37;
|
||||
--on-accent: #ffffff;
|
||||
--focus-ring: #7a5a37;
|
||||
--ok-text: #047857;
|
||||
--std-text: #047857;
|
||||
--bad-text: #b91c1c;
|
||||
--error-text: #b91c1c;
|
||||
--warn-text: #b45309;
|
||||
--info-text: #1d4ed8;
|
||||
--nav-bg: rgba(250, 250, 249, 0.8);
|
||||
--selection-color: var(--stone-900);
|
||||
--gradient-text-from: var(--stone-900);
|
||||
--gradient-text-to: var(--stone-600);
|
||||
}
|
||||
}
|
||||
|
||||
30
wai/wai-studio/src/worker.js
Normal file
30
wai/wai-studio/src/worker.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// The Studio core (core/, built for the web) off the page's thread. The page posts
|
||||
// {id, pkg, call, args}; the first message loads the package from `pkg`. Each call is an
|
||||
// operation's binding, `noun_verb`, or `core_describe`; it answers {id, ok, json, bytes}, or
|
||||
// {id, ok: false, error} when the core could not be called at all.
|
||||
let loaded = null;
|
||||
let mod = null;
|
||||
|
||||
async function load(pkg) {
|
||||
loaded ??= import(pkg).then(async (m) => { await m.default(); mod = m; });
|
||||
await loaded;
|
||||
}
|
||||
|
||||
self.onmessage = async (ev) => {
|
||||
const { id, pkg, call, args } = ev.data;
|
||||
try {
|
||||
await load(pkg);
|
||||
if (call === 'core_describe') {
|
||||
self.postMessage({ id, ok: true, json: mod.core_describe() });
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9]+_[a-z0-9]+$/.test(call) || typeof mod[call] !== 'function') throw new Error(`the core has no call ${call}`);
|
||||
const out = mod[call](...(args || []));
|
||||
const json = out.json();
|
||||
const bytes = out.bytes();
|
||||
out.free();
|
||||
self.postMessage({ id, ok: true, json, bytes }, [bytes.buffer]);
|
||||
} catch (err) {
|
||||
self.postMessage({ id, ok: false, error: String(err && err.message ? err.message : err) });
|
||||
}
|
||||
};
|
||||
103
wai/wai-studio/test/actions.test.mjs
Normal file
103
wai/wai-studio/test/actions.test.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// Studio's actions from the catalogue (scripts/actions.mjs): what is generated, and every
|
||||
// rule of the drift check, each broken in a copy and caught; then the tree itself holds.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { HERE, CORE, SERVER, GENERATED, action, catalogueFile, coreCalls, findings, generate, readCatalogue, serverOps, sourceOps } from '../scripts/actions.mjs';
|
||||
import { BINDINGS } from '../src/bindings.js';
|
||||
|
||||
const op = (id, placement = 'local', more = {}) => ({
|
||||
id, summary: `the ${id}`, placement, effect: 'reads', toolset: 'core', stability: 'stable',
|
||||
cli: `wai ${id.split('.')[1]}`, doors: { cli: true, mcp: false, http: false }, names: { mcp: null }, route: null, ...more,
|
||||
});
|
||||
const DOC = { format: 'wai.ops/1', toolchain: '0.3.0', operations: [op('envelope.inspect'), op('room.join', 'client', { cli: null, doors: { cli: false, mcp: true, http: false }, names: { mcp: 'room_join' } }), op('room.grant', 'service', { route: { service: 'wai-room', method: 'POST', path: '/grant' } })] };
|
||||
const CORE_SRC = '#[wasm_bindgen]\npub fn envelope_inspect(bytes: &[u8]) -> Output {}\npub const OPERATIONS: &[&str] = &[\n "envelope.inspect",\n];\n';
|
||||
const SERVER_SRC = 'const LOCAL_OPS: &[(&str, &str)] = &[\n ("envelope.inspect", "inspect"),\n];\n';
|
||||
const OK_BINDINGS = { 'envelope.inspect': { panel: 'inspect', hosted: 'tab', local: 'tab+local' }, 'room.join': { panel: 'rooms', hosted: 'page', local: 'page' } };
|
||||
const clean = (over = {}) => ({ doc: DOC, sha256: 'x', generated: generate(DOC, 'x'), bindings: OK_BINDINGS, core: CORE_SRC, server: SERVER_SRC, files: [['src/x.js', "el('button', { 'data-op': 'envelope.inspect' })"]], ...over });
|
||||
|
||||
test('an action carries its operation\'s names in every door', () => {
|
||||
const a = action(op('room.grant', 'service', { cli: null, doors: { cli: false, mcp: true, http: true }, names: { mcp: 'room_grant' }, route: { service: 'wai-room', method: 'POST', path: '/grant' } }));
|
||||
assert.deepEqual([a.menu, a.item, a.palette, a.call, a.mcp, a.cli], ['Room', 'Grant', 'room grant', 'room_grant', 'room_grant', null]);
|
||||
assert.deepEqual(a.route, { service: 'wai-room', method: 'POST', path: '/grant' });
|
||||
});
|
||||
|
||||
test('the generated table is the catalogue\'s, in its order, and internal operations are left out', () => {
|
||||
const doc = { ...DOC, operations: [...DOC.operations, op('bench.run', 'local', { stability: 'internal' })] };
|
||||
const text = generate(doc, 'abc');
|
||||
assert.match(text, /^\/\/ GENERATED/);
|
||||
assert.ok(text.includes('"id":"envelope.inspect"') && !text.includes('bench.run'));
|
||||
assert.ok(text.indexOf('envelope.inspect') < text.indexOf('room.join'));
|
||||
assert.equal(generate(doc, 'abc'), text, 'the same catalogue gives the same bytes');
|
||||
});
|
||||
|
||||
test('a clean tree has no finding', () => {
|
||||
assert.deepEqual(findings(clean()), []);
|
||||
});
|
||||
|
||||
test('an action renamed so it names no operation is caught', () => {
|
||||
const b = { ...OK_BINDINGS, 'envelope.inspekt': OK_BINDINGS['envelope.inspect'] };
|
||||
delete b['envelope.inspect'];
|
||||
const f = findings(clean({ bindings: b }));
|
||||
assert.ok(f.some((x) => x.includes('the action envelope.inspekt has no operation')), f.join('\n'));
|
||||
});
|
||||
|
||||
test('an operation dropped from the catalogue is caught', () => {
|
||||
const doc = { ...DOC, operations: DOC.operations.filter((o) => o.id !== 'envelope.inspect') };
|
||||
const f = findings(clean({ doc }));
|
||||
assert.ok(f.some((x) => x.includes('is not what the catalogue generates')), f.join('\n'));
|
||||
assert.ok(f.some((x) => x.includes('the action envelope.inspect has no operation')), f.join('\n'));
|
||||
assert.ok(f.some((x) => x.includes("OPERATIONS lists envelope.inspect, which the catalogue does not hold")), f.join('\n'));
|
||||
});
|
||||
|
||||
test('an operation placed for the browser with no action is caught', () => {
|
||||
const b = { ...OK_BINDINGS };
|
||||
delete b['room.join'];
|
||||
const f = findings(clean({ bindings: b }));
|
||||
assert.ok(f.some((x) => x.includes('room.join is placed client') && x.includes('no action')), f.join('\n'));
|
||||
});
|
||||
|
||||
test('a mismatched name is caught: the core\'s call, the server\'s table, the source', () => {
|
||||
let f = findings(clean({ core: CORE_SRC.replace('envelope_inspect', 'envelope_read') }));
|
||||
assert.ok(f.some((x) => x.includes('the core has no call envelope_inspect')), f.join('\n'));
|
||||
assert.ok(f.some((x) => x.includes('envelope_read names envelope.read, which the catalogue does not hold')), f.join('\n'));
|
||||
f = findings(clean({ server: SERVER_SRC.replace('"envelope.inspect"', '"envelope.show"') }));
|
||||
assert.ok(f.some((x) => x.includes('LOCAL_OPS runs envelope.show')), f.join('\n'));
|
||||
assert.ok(f.some((x) => x.includes('the action envelope.inspect runs through the local core, and wai dev\'s LOCAL_OPS does not run it')), f.join('\n'));
|
||||
f = findings(clean({ files: [['src/y.js', "{ op: 'envelope.unwrapp' }"]] }));
|
||||
assert.ok(f.some((x) => x.includes('src/y.js: envelope.unwrapp names no operation')), f.join('\n'));
|
||||
f = findings(clean({ generated: generate(DOC, 'x').replace('"menu":"Envelope"', '"menu":"Envelopes"') }));
|
||||
assert.ok(f.some((x) => x.includes('not what the catalogue generates')), f.join('\n'));
|
||||
});
|
||||
|
||||
test('an action that says it runs from the command line needs a command line in the catalogue', () => {
|
||||
// room.join has no command line in DOC (an MCP tool alone).
|
||||
const f = findings(clean({ bindings: { ...OK_BINDINGS, 'room.join': { elsewhere: 'cli' } } }));
|
||||
assert.ok(f.some((x) => x.includes('room.join says it runs from the command line')), f.join('\n'));
|
||||
assert.deepEqual(findings(clean({ bindings: { ...OK_BINDINGS, 'room.join': { elsewhere: 'mcp' } } })), []);
|
||||
});
|
||||
|
||||
test('the readers find what the core, the server and the source name', () => {
|
||||
assert.deepEqual(coreCalls(CORE_SRC), { calls: ['envelope_inspect'], listed: ['envelope.inspect'] });
|
||||
assert.deepEqual(serverOps(SERVER_SRC), ['envelope.inspect']);
|
||||
assert.deepEqual(sourceOps([['a.js', "x = { op: 'a.b' }; el('i', { 'data-op': 'c.d' }); `data-op=\"e.f\"`; local.op('g.h', body)"]]).map((o) => o.id), ['a.b', 'c.d', 'e.f', 'g.h']);
|
||||
});
|
||||
|
||||
function walk(dir) {
|
||||
return readdirSync(dir).flatMap((n) => { const p = join(dir, n); return statSync(p).isDirectory() ? walk(p) : /\.(m?js|html)$/.test(n) && n !== 'actions.generated.js' ? [p] : []; });
|
||||
}
|
||||
|
||||
test('this tree holds to its catalogue', () => {
|
||||
const { file } = catalogueFile(null);
|
||||
const { doc, sha256 } = readCatalogue(file);
|
||||
const f = findings({
|
||||
doc, sha256,
|
||||
generated: readFileSync(GENERATED, 'utf8'),
|
||||
bindings: BINDINGS,
|
||||
core: readFileSync(CORE, 'utf8'),
|
||||
server: readFileSync(SERVER, 'utf8'),
|
||||
files: walk(join(HERE, 'src')).map((p) => [relative(HERE, p), readFileSync(p, 'utf8')]),
|
||||
});
|
||||
assert.deepEqual(f, []);
|
||||
});
|
||||
95
wai/wai-studio/test/core.test.mjs
Normal file
95
wai/wai-studio/test/core.test.mjs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Each panel's calls on the core built for the web (core/, wasm-bindgen --target web), made
|
||||
// as src/cores.js TabCore makes them, on vectors of the conformance corpora:
|
||||
// Inspect envelope_inspect, envelope_unwrap, registry_list
|
||||
// Verify receipt_verify
|
||||
// Media media_decode, media_encode, envelope_wrap
|
||||
// WAI_STUDIO_CORE_PKG names the package's directory (e2e/run.sh and CI build it).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url';
|
||||
|
||||
const PKG = process.env.WAI_STUDIO_CORE_PKG;
|
||||
const skip = PKG ? false : 'WAI_STUDIO_CORE_PKG names no core package';
|
||||
const WAI = resolve(fileURLToPath(import.meta.url), '../../..');
|
||||
const HTTP = join(WAI, 'http-conformance');
|
||||
const O5 = 'e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b';
|
||||
const O9A = '31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3';
|
||||
const O9C = 'a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133';
|
||||
const O10 = '1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8';
|
||||
const O2 = '25130382f5d9c73ae59a04d1fbae1463693f3769c090744ebd5f1e80fa3b0a34';
|
||||
|
||||
let core = null;
|
||||
async function load() {
|
||||
if (core) return core;
|
||||
const m = await import(pathToFileURL(join(PKG, 'wai_studio_core.js')).href);
|
||||
m.initSync({ module: readFileSync(join(PKG, 'wai_studio_core_bg.wasm')) });
|
||||
core = m;
|
||||
return m;
|
||||
}
|
||||
const call = (out) => { const env = JSON.parse(out.json()); const bytes = out.bytes(); out.free(); return { env, bytes }; };
|
||||
const object = (cid) => new Uint8Array(readFileSync(join(HTTP, 'objects', `${cid}.wai`)));
|
||||
const rcpt = () => readdirSync(join(HTTP, 'objects/rcpt')).map((n) => [`objects/rcpt/${n}`, new Uint8Array(readFileSync(join(HTTP, 'objects/rcpt', n)))]);
|
||||
|
||||
test('the core names its operations as the catalogue does', { skip }, async () => {
|
||||
const c = await load();
|
||||
const d = JSON.parse(c.core_describe());
|
||||
for (const id of d.operations) assert.equal(typeof c[id.replace('.', '_')], 'function', id);
|
||||
assert.deepEqual(d.decodes, ['wai.image.png', 'wai.audio.flac', 'wai.text.zstd']);
|
||||
});
|
||||
|
||||
test('Inspect: an envelope, its verdict here, its components, and the registry', { skip }, async () => {
|
||||
const c = await load();
|
||||
const { env } = call(c.envelope_inspect(object(O5)));
|
||||
assert.equal(env.wai, 'envelope/1');
|
||||
assert.equal(env.op, 'envelope.inspect');
|
||||
assert.equal(env.data.cid, O5);
|
||||
assert.equal(env.data.verdict.code, 'decodable');
|
||||
const two = call(c.envelope_inspect(object(O2))).env.data;
|
||||
assert.equal(two.version, 'WAI2');
|
||||
assert.equal(two.pick.capability, 'wai.image.png');
|
||||
// With no component named, the one SPEC §7.1 selects at a sink that advertises everything,
|
||||
// in SPEC §4's energy order: AVIF before the learned rendition (http-conformance's O2).
|
||||
const u = call(c.envelope_unwrap(object(O2), -1));
|
||||
assert.equal(u.env.data.capability, 'wai.image.avif');
|
||||
assert.ok(u.bytes.length > 0);
|
||||
const bad = call(c.envelope_inspect(new Uint8Array([1, 2, 3])));
|
||||
assert.equal(bad.env.ok, false);
|
||||
assert.equal(bad.env.error.code, 'envelope.unreadable');
|
||||
const r = call(c.registry_list()).env.data;
|
||||
assert.ok(r.capabilities.some((x) => x.capability === 'wai.text.zstd' && x.decode === 'here'));
|
||||
});
|
||||
|
||||
test('Verify: receipt indexes with their groups, as http-conformance expects them', { skip }, async () => {
|
||||
const c = await load();
|
||||
const files = [...rcpt(), [`objects/${O9C}.wai`, object(O9C)], [`objects/${O9C}.wai.receipts`, new Uint8Array(readFileSync(join(HTTP, 'objects', `${O9C}.wai.receipts`)))]];
|
||||
const ok = call(c.receipt_verify(files.map((f) => f[0]), files.map((f) => f[1]))).env;
|
||||
assert.equal(ok.data.ok, true);
|
||||
// http-conformance cases.json, receipts_walk_two: O9c held whole is delivered-verified.
|
||||
assert.equal(ok.data.indexes[0].lines[0].code, 'delivered-verified');
|
||||
const cases = readdirSync(join(HTTP, 'cases/rcpt')).map((n) => [`cases/rcpt/${n}`, new Uint8Array(readFileSync(join(HTTP, 'cases/rcpt', n)))]);
|
||||
const bad = [...rcpt(), ...cases, [`objects/${O9A}.wai`, object(O9A)], [`objects/${O9A}.wai.receipts`, new Uint8Array(readFileSync(join(HTTP, 'cases/receipts/O9a-bad-signature.wai.receipts')))]];
|
||||
const no = call(c.receipt_verify(bad.map((f) => f[0]), bad.map((f) => f[1]))).env;
|
||||
// receipts_group_bad_signature: signature_verify_failed.
|
||||
assert.equal(no.data.ok, false);
|
||||
assert.equal(no.data.indexes[0].lines[0].code, 'signature_verify_failed');
|
||||
});
|
||||
|
||||
test('Encode, decode, package: the floor round trip, a wrap, and a codec outside the floor refused', { skip }, async () => {
|
||||
const c = await load();
|
||||
const flac = call(c.media_decode(object(O10)));
|
||||
assert.equal(flac.env.data.kind, 'audio');
|
||||
assert.equal(flac.bytes.length, 4 * flac.env.data.samples);
|
||||
const rgb = new Uint8Array(4 * 3 * 3).map((_, i) => (i * 29) & 255);
|
||||
const png = call(c.media_encode('png', rgb, 4, 3, 75));
|
||||
assert.equal(png.env.ok, true);
|
||||
const back = call(c.media_decode(png.bytes));
|
||||
assert.deepEqual(Array.from(back.bytes), Array.from(rgb));
|
||||
const no = call(c.media_encode('avif', rgb, 4, 3, 75)).env;
|
||||
assert.equal(no.error.code, 'encode.not-here');
|
||||
const w = call(c.envelope_wrap('wai.text.zstd', new Uint8Array([1, 2, 3]), undefined, undefined));
|
||||
assert.equal(w.env.data.intent, 'replicate');
|
||||
assert.deepEqual(Array.from(call(c.envelope_unwrap(w.bytes, -1)).bytes), [1, 2, 3]);
|
||||
assert.equal(call(c.envelope_wrap('wai.no.such', new Uint8Array([1]), undefined, undefined)).env.error.code, 'capability.unknown');
|
||||
});
|
||||
41
wai/wai-studio/test/local-core.test.mjs
Normal file
41
wai/wai-studio/test/local-core.test.mjs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// The local core's calls (src/cores.js LocalCore) as the page makes them, against a stand-in
|
||||
// for `wai dev`'s routes: the header every call carries, the operation's id in its path and
|
||||
// its arguments in its query, the input as the body, and the files it wrote decoded.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { LocalCore } from '../src/cores.js';
|
||||
|
||||
test('each call reaches /_wai/api/ with X-WAI-Studio, and an operation\'s outputs come back as bytes', async () => {
|
||||
const seen = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
seen.push({ method: req.method, url: req.url, header: req.headers['x-wai-studio'], type: req.headers['content-type'], body: Buffer.concat(chunks) });
|
||||
res.setHeader('content-type', 'application/json');
|
||||
if (req.url.startsWith('/_wai/api/state')) res.end(JSON.stringify({ ready: true }));
|
||||
else if (req.url.startsWith('/_wai/api/log')) res.end(JSON.stringify({ text: 'x', next: 1 }));
|
||||
else if (req.url.startsWith('/_wai/api/op/')) res.end(JSON.stringify({ op: 'media.encode', ok: true, exit: 0, argv: ['wai', 'encode'], stdout: '', stderr: '', outputs: [{ name: 'out.wai', data: Buffer.from([0, 1, 2, 255]).toString('base64') }] }));
|
||||
else { res.statusCode = 403; res.end(JSON.stringify({ error: 'no' })); }
|
||||
});
|
||||
});
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const core = new LocalCore(`http://127.0.0.1:${server.address().port}/_wai/api/`);
|
||||
try {
|
||||
assert.deepEqual(await core.state(), { ready: true });
|
||||
await core.log('relay', 7);
|
||||
const r = await core.op('media.encode', new Uint8Array([9, 9, 9]), { codec: 'avif', width: 1, height: 1, quality: 50, sample_rate: null });
|
||||
assert.deepEqual(Array.from(r.outputs[0].bytes), [0, 1, 2, 255]);
|
||||
await assert.rejects(core.get('/nothing'), /no/);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
assert.ok(seen.every((s) => s.header === '1'), 'every call carries X-WAI-Studio: 1');
|
||||
assert.equal(seen[1].url, '/_wai/api/log?name=relay&from=7');
|
||||
const op = seen[2];
|
||||
assert.equal(op.method, 'POST');
|
||||
assert.equal(op.url, '/_wai/api/op/media.encode?codec=avif&width=1&height=1&quality=50', 'an argument with no value is left out');
|
||||
assert.equal(op.type, 'application/octet-stream');
|
||||
assert.deepEqual(Array.from(op.body), [9, 9, 9]);
|
||||
});
|
||||
51
wai/wai-studio/test/palette.test.mjs
Normal file
51
wai/wai-studio/test/palette.test.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// The command palette's "where it runs", from the catalogue and Studio's bindings, on each host.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ACTIONS } from '../src/actions.generated.js';
|
||||
import { BINDINGS, PANELS, runs } from '../src/bindings.js';
|
||||
import { where, whereText } from '../src/palette.js';
|
||||
|
||||
const byId = (id) => ACTIONS.find((a) => a.id === id);
|
||||
|
||||
test('every operation of the catalogue is in the palette, by its id', () => {
|
||||
const ids = ACTIONS.map((a) => a.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
for (const a of ACTIONS) assert.match(a.id, /^[a-z0-9]+\.[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
test('an operation this host runs opens its panel, and one panel the host does not show is never opened', () => {
|
||||
for (const host of ['hosted', 'local']) {
|
||||
for (const [id, b] of Object.entries(BINDINGS)) {
|
||||
const a = byId(id);
|
||||
if (!a) continue;
|
||||
const w = where(a, host);
|
||||
if (b[host]) {
|
||||
const p = PANELS.find((x) => x.id === b.panel);
|
||||
assert.ok(p.hosts.includes(host), `${id} runs in ${p.id}, which ${host} shows`);
|
||||
assert.equal(w.panel, b.panel);
|
||||
assert.match(whereText(a, host), /^Runs here/);
|
||||
} else {
|
||||
assert.equal(w.here, null, `${id} on ${host}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('an operation that runs elsewhere says where, from the catalogue', () => {
|
||||
// A command line and an MCP tool.
|
||||
assert.match(whereText(byId('toolchain.test'), 'hosted'), /Runs elsewhere: wai hello/);
|
||||
assert.match(whereText(byId('dev.stop'), 'local'), /wai mcp, tool dev_stop/);
|
||||
// A route of the room service: through wai dev, on the local host.
|
||||
assert.match(whereText(byId('room.grant'), 'local'), /wai dev at POST \/room-api\/grant/);
|
||||
assert.match(whereText(byId('room.grant'), 'hosted'), /wai-room: POST \/grant/);
|
||||
// What runs only through wai dev, seen from the hosted Studio.
|
||||
assert.match(whereText(byId('dev.start'), 'hosted'), /Studio served by wai dev, at \/_wai\//);
|
||||
// Placed for the browser, run from the command line.
|
||||
assert.match(whereText(byId('receipt.fetch'), 'hosted'), /wai http receipts/);
|
||||
assert.equal(runs('receipt.fetch', 'hosted'), null);
|
||||
});
|
||||
|
||||
test('the Run panel is the local host\'s alone', () => {
|
||||
assert.deepEqual(PANELS.find((p) => p.id === 'run').hosts, ['local']);
|
||||
assert.equal(where(byId('dev.start'), 'local').panel, 'run');
|
||||
});
|
||||
Loading…
Reference in a new issue