From f9b3d47a607dab6e89c35a5a159936a8a40b2348 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 2 Sep 2026 15:38:46 +0800 Subject: [PATCH 1/3] Add jscpd --- .mise/config.toml | 74 ++++ .mise/mise.lock | 35 ++ CLAUDE.md | 21 +- .../rules/doc-summary-ends-with-period.yaml | 10 + .../generated_trees/generated_trees.rego | 104 +++++ .../generated_trees/generated_trees_test.rego | 81 ++++ config/generated-trees.toml | 77 ++++ config/jscpd-baseline.json | 364 ++++++++++++++++++ config/jscpd.json | 6 + config/semgrep/no-todo.yaml | 3 + config/semgrep/prefer-yaml-toml.yaml | 7 + config/typos.toml | 7 +- libs/web/Cargo.toml | 1 + libs/web/src/lib.rs | 44 +++ services/ws-modules/audio1/src/lib.rs | 52 +-- services/ws-modules/bluetooth/src/lib.rs | 52 +-- services/ws-modules/comm1/Cargo.toml | 4 +- services/ws-modules/comm1/src/lib.rs | 45 +-- services/ws-modules/data1/src/lib.rs | 41 +- services/ws-modules/except1/src/lib.rs | 40 +- services/ws-modules/face-detection/src/lib.rs | 54 +-- services/ws-modules/geolocation/src/lib.rs | 52 +-- services/ws-modules/graphics-info/src/lib.rs | 53 +-- services/ws-modules/har1/src/lib.rs | 56 +-- services/ws-modules/llm1/src/lib.rs | 49 +-- services/ws-modules/math1-sender/src/lib.rs | 41 +- services/ws-modules/math1/src/lib.rs | 41 +- services/ws-modules/nfc/src/lib.rs | 45 +-- services/ws-modules/pic-viewer/src/lib.rs | 47 +-- .../ws-modules/speech-recognition/src/lib.rs | 52 +-- services/ws-modules/video1/src/lib.rs | 53 +-- services/ws-wasm-agent/src/lib.rs | 16 + services/ws-wasm-agent/tests/web.rs | 105 ++++- 33 files changed, 980 insertions(+), 752 deletions(-) create mode 100644 config/conftest/policy/generated_trees/generated_trees.rego create mode 100644 config/conftest/policy/generated_trees/generated_trees_test.rego create mode 100644 config/generated-trees.toml create mode 100644 config/jscpd-baseline.json create mode 100644 config/jscpd.json diff --git a/.mise/config.toml b/.mise/config.toml index 964ca7d3..f8f3fcd4 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -110,6 +110,12 @@ ripgrep = "latest" "github:benhoyt/goawk" = "latest" "github:caldempsey/parfit" = "latest" "github:grok-rs/waitup" = "latest" +# jscpd: the cross-language copy/paste detector behind jscpd-check. +# Upstream ships prebuilt tarballs for every tier-1 and tier-2 triple, so the github (ubi) backend covers all five +# platforms with no os-scoping. Pinned rather than "latest" because clone fingerprints are tokenizer-derived: a +# jscpd bump can rewrite them and redden an unrelated PR against config/jscpd-baseline.json. Bump the pin and +# regenerate the baseline together. +"github:kucherenko/jscpd" = "5.1.1" # The macos/x64 half of vector (see the aqua:vectordotdev/vector note above). # A git `tag:` spec rather than the crates.io crate so this platform builds the exact source the other # platforms' 0.56.0 prebuilts are cut from -- bump it with the aqua entry. A source build is acceptable here @@ -680,6 +686,7 @@ depends = [ "action-validator-check", "actionlint-check", "ast-grep-check", + "branch-up-to-date-check", "cargo-check", "cargo-clippy-check", "cargo-doc-check", @@ -692,6 +699,7 @@ depends = [ "gen-help-check", "gen-specs-check", "hadolint-check", + "jscpd-check", "link-check", "ls-lint-check", "readme-mise-version-check", @@ -777,6 +785,28 @@ run = "semgrep scan --config config/semgrep --autofix --metrics=off ." description = "Lint the Dockerfiles with hadolint" run = "git ls-files '*Dockerfile' '*Dockerfile.*' | xargs hadolint --config config/hadolint.yaml" +# Duplication is gated as a fingerprint ratchet, not as a percentage. +# config/jscpd-baseline.json records the fingerprint of every clone that already existed, and only a clone absent +# from it fails, so nothing is permanently silenced and the ledger shrinks as duplication is removed -- +# `--update-baseline` prints the added/removed counts. A percentage gate was rejected because removing duplicated +# code also shrinks the denominator: this change deleted 653 duplicated lines and moved the total only 9.11% -> +# 9.00%. The baseline lives on the task rather than in config/jscpd.json so jscpd-report, which shares that +# config, still shows the whole picture instead of filtering to new clones. That config's `ignore` list covers +# only generator-owned output nobody hand-writes -- note the files under a module's pkg/ other than package.json +# are hand-written shims, so they stay in scope. +[tasks.jscpd-check] +description = "Detect copy/pasted code across every language (jscpd)" +run = "jscpd . --config config/jscpd.json --baseline config/jscpd-baseline.json --fail-on-new-clones" + +[tasks.jscpd-baseline-update] +description = "Rewrite config/jscpd-baseline.json from the current tree, printing added/removed clone counts" +run = "jscpd . --config config/jscpd.json --baseline config/jscpd-baseline.json --update-baseline" + +# No baseline here on purpose -- a human reading the report wants every clone, not just the new ones. +[tasks.jscpd-report] +description = "Write an HTML duplication report to target/jscpd for browsing" +run = "jscpd . --config config/jscpd.json --reporters html,console --output target/jscpd" + [tasks.cargo-check] run = "cargo check --workspace {{ vars.cargo_ws_excludes }}" @@ -863,6 +893,23 @@ git ls-files '*Cargo.toml' '.mise/config*.toml' '*pyproject.toml' Cargo.lock con """ shell = "bash -euo pipefail -c" +# Rego unit tests living beside the policies, for invariants the real policy runs cannot reach from one OS. +# Named into the conftest-check-* family so the `check` aggregate always picks it up. +[tasks.conftest-check-policy-tests] +description = "Run the Rego unit tests beside the conftest policies (conftest verify)" +run = "conftest verify -p config/conftest/policy" + +[tasks.conftest-check-generated-trees] +description = "Cross-check config/generated-trees.toml against the linter configs it governs (conftest)" +# --combine over a deliberately mixed file set, with conftest auto-detecting each parser (TOML, YAML, JSON). +# The declaration and every governed config must arrive in one evaluation so a rule can compare across them. +run = """ +files="config/generated-trees.toml config/jscpd.json config/pyrefly.toml config/ast-grep/rules config/semgrep" +# shellcheck disable=SC2086 # $files is a word-split path list by design; do not quote it. +conftest test --combine --namespace generated_trees -p config/conftest/policy $files +""" +shell = "bash -euo pipefail -c" + [tasks.conftest-check-yaml] description = "Run conftest OPA/Rego policies over the GitHub Actions workflow + action YAML" # Three passes over the workflow + action YAML. @@ -1240,6 +1287,33 @@ depends = ["gen:dockerignore"] description = "Fail if .dockerignore is stale vs .gitignore (run `mise run gen:dockerignore`)" run = "git diff --exit-code -- .dockerignore" +# Fail when HEAD does not already contain origin/main, because a stale branch makes every other check untrustworthy. +# A branch behind main runs its lints against code the rest of the repo has already moved past, so failures land in +# files the branch never touched and read as though the branch broke them. No linter can express this -- it is git +# topology rather than file content -- so it is a small git compare, in the same spirit as dockerignore-check above. +# Deliberately does not fetch: checks make no network calls, and a stale origin/main can only cause a missed warning, +# never a spurious failure. Skips when it cannot tell rather than guessing. CI checks out with `fetch-depth: 1`, so +# origin/main is simply absent there and the honest answer is "unknown" -- GitHub's own up-to-date-branch protection +# governs merging. This guard exists for developer machines, which is where the confusing failures happen. +[tasks.branch-up-to-date-check] +description = "Fail if the current branch does not already contain origin/main (rebase needed)" +run = """ +if ! git rev-parse --verify --quiet origin/main >/dev/null; then + echo "branch-up-to-date-check: origin/main not available (shallow clone?); skipping" + exit 0 +fi + +if git merge-base --is-ancestor origin/main HEAD; then + exit 0 +fi + +behind="$(git rev-list --count HEAD..origin/main)" +echo "branch-up-to-date-check: HEAD is behind origin/main by $behind commit(s)." +echo "Other checks may fail in files this branch never touched. Rebase first: git pull --rebase origin main" +exit 1 +""" +shell = "bash -euo pipefail -c" + [tasks.prefetch-ci] # `build-ws-wasm-agent` produces `services/ws-wasm-agent/pkg/`. # The static workspace references it via a `link:` path. Run it before `pnpm install` so the link target diff --git a/.mise/mise.lock b/.mise/mise.lock index 6ee1d75d..d8811bc2 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -758,6 +758,41 @@ checksum = "sha256:96a597048a5b8bde03769d3e55fdd54a4073eaab7d9d4e2accf601445e9a6 url = "https://github.com/grok-rs/waitup/releases/download/v2.0.0/waitup-x86_64-pc-windows-msvc.zip" url_api = "https://api.github.com/repos/grok-rs/waitup/releases/assets/348807986" +[[tools."github:kucherenko/jscpd"]] +version = "5.1.1" +backend = "github:kucherenko/jscpd" + +[tools."github:kucherenko/jscpd"."platforms.linux-arm64"] +checksum = "sha256:656f0b8f6c238d06ab2b13ac83dd238eac0105da3b6603dcfa387f278196dfad" +url = "https://github.com/kucherenko/jscpd/releases/download/v5.1.1/jscpd-linux-arm64-gnu.tar.gz" +url_api = "https://api.github.com/repos/kucherenko/jscpd/releases/assets/538185422" +provenance = "github-attestations" + +[tools."github:kucherenko/jscpd"."platforms.linux-x64"] +checksum = "sha256:c2a9aa5c63293d3523edf6682ede95cc9aebbdc12abb6bb0399636ea3e23f86a" +url = "https://github.com/kucherenko/jscpd/releases/download/v5.1.1/jscpd-linux-x64-gnu.tar.gz" +url_api = "https://api.github.com/repos/kucherenko/jscpd/releases/assets/538185420" +provenance = "github-attestations" + +[tools."github:kucherenko/jscpd"."platforms.macos-arm64"] +checksum = "sha256:0fc3f35fcac0eadde77803fd3b656d2b3699e7046ed4fb034a5a4bf4b9d71aad" +url = "https://github.com/kucherenko/jscpd/releases/download/v5.1.1/jscpd-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/kucherenko/jscpd/releases/assets/538185419" +provenance = "github-attestations" +provenance_verified = true + +[tools."github:kucherenko/jscpd"."platforms.macos-x64"] +checksum = "sha256:3948dbdd5cb165864b88b21456e583dc27b0af365d2585557789dea50dd9fe0e" +url = "https://github.com/kucherenko/jscpd/releases/download/v5.1.1/jscpd-darwin-x64.tar.gz" +url_api = "https://api.github.com/repos/kucherenko/jscpd/releases/assets/538185417" +provenance = "github-attestations" + +[tools."github:kucherenko/jscpd"."platforms.windows-x64"] +checksum = "sha256:d877aaa4a1571639b737d38793ef6bdd1f9db1a9c0c732be3341ea31766cd46d" +url = "https://github.com/kucherenko/jscpd/releases/download/v5.1.1/jscpd-windows-x64-msvc.tar.gz" +url_api = "https://api.github.com/repos/kucherenko/jscpd/releases/assets/538185436" +provenance = "github-attestations" + [[tools."github:microsoft/onnxruntime"]] version = "1.22.0" backend = "github:microsoft/onnxruntime" diff --git a/CLAUDE.md b/CLAUDE.md index 8f1545ee..ce827bfb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,21 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +## Check the branch is on top of `origin/main` before starting + +Run `mise run branch-up-to-date-check` as the first action of any task, and again before diagnosing any check or +test failure. A branch that is behind `origin/main` runs its lints and tests against code the rest of the repo has +already moved past, so failures surface in files the branch never touched -- and they read as though the branch +caused them. If the check reports the branch is behind, say so and get it rebased before doing anything else; +`git pull --rebase origin main` is not the agent's call to make unprompted, so ask. + +Do not theorise about why an unrelated crate is suddenly failing until this check has passed. A worked example +from this repo: a branch sitting one commit behind `origin/main` produced 10 clippy errors across +`libs/ws-runner-common`, `services/ws-web-runner` and `services/ws-wasi-runner` -- three crates the branch had not +touched and which did not depend on anything it changed. Every one of those lints had already been fixed by the +single missing commit. The wrong diagnosis reached for first was a toolchain change, which the dates flatly +contradicted; `git rev-list --left-right --count origin/main...HEAD` would have settled it immediately. + ## Scratch work stays inside this repo Any throwaway file the agent needs while working -- backup copies of files before destructive edits, generated @@ -300,7 +315,11 @@ run every loaded language's row; guest rows need their `MISE_ENV` loaded. | `*.java` | `check:java` | `dprint-fmt` / `dprint-check` cover `*.md`, `*.yaml`, `*.json`/`*.jsonc`, `*.ts`/`*.js`, `*.css`, `*.html`, -`*.java`, and `Dockerfile*`; `hadolint-check` also lints Dockerfiles, and `link-check` scans `*.md` + `*.rs`. Every +`*.java`, and `Dockerfile*`. `*.json`/`*.jsonc` is formatted by **two** tools, so a new config JSON must satisfy +both: `oxfmt-fmt` / `oxfmt-check` (in the `js` env, alongside `oxlint-check`) also claim it, and they break ties +differently -- oxfmt collapses a short array onto one line where dprint leaves it expanded, so a file dprint +accepts can still fail `oxfmt-check` in CI. `hadolint-check` also lints Dockerfiles, and `link-check` scans +`*.md` + `*.rs`. Every file is covered by `editorconfig-check` and `typos-check`, file and directory names by `ls-lint-check`, and `*.yml` is rejected by `semgrep-check` (use `*.yaml`). A multi-line task body inside `.mise/config*.toml` is shell, not TOML, so it carries two more of its own: `shfmt-mise-fmt` / `shfmt-mise-check` format it, and `shellcheck-mise-check` lints it. diff --git a/config/ast-grep/rules/doc-summary-ends-with-period.yaml b/config/ast-grep/rules/doc-summary-ends-with-period.yaml index 49c2aea9..cf84f8dd 100644 --- a/config/ast-grep/rules/doc-summary-ends-with-period.yaml +++ b/config/ast-grep/rules/doc-summary-ends-with-period.yaml @@ -32,6 +32,7 @@ files: - libs/edge-toolkit/tests/npm_mod.rs - libs/test-helpers/src/lib.rs - libs/test-otlp/src/lib.rs + - libs/web/src/lib.rs - libs/ws-runner-common/tests/config.rs - services/modules/tests/api_modules.rs - services/modules/tests/configure_missing_root.rs @@ -46,21 +47,29 @@ files: - services/storage/tests/tty_render.rs - services/websockify/src/lib.rs - services/websockify/tests/relay.rs + - services/ws-modules/audio1/src/lib.rs + - services/ws-modules/bluetooth/src/lib.rs - services/ws-modules/comm1/src/lib.rs + - services/ws-modules/data1/src/lib.rs - services/ws-modules/except1/src/lib.rs - services/ws-modules/face-detection/src/lib.rs - services/ws-modules/face-detection/tests/status_lines.rs + - services/ws-modules/geolocation/src/lib.rs + - services/ws-modules/graphics-info/src/lib.rs - services/ws-modules/har1/src/lib.rs - services/ws-modules/har1/tests/status_lines.rs - services/ws-modules/llm1/src/lib.rs - services/ws-modules/math1-sender/build.rs - services/ws-modules/math1-sender/src/lib.rs - services/ws-modules/math1/src/lib.rs + - services/ws-modules/nfc/src/lib.rs - services/ws-modules/pic-viewer/build.rs - services/ws-modules/pic-viewer/src/lib.rs - services/ws-modules/pic-viewer/tests/parse.rs - services/ws-modules/pic-viewer/tests/show_image.rs - services/ws-modules/sensor1/src/lib.rs + - services/ws-modules/speech-recognition/src/lib.rs + - services/ws-modules/video1/src/lib.rs - services/ws-modules/wasi-comm1/src/lib.rs - services/ws-modules/wasi-data1/src/lib.rs - services/ws-modules/wasi-math1/src/coverage.rs @@ -86,6 +95,7 @@ files: - services/ws-wasi-runner/tests/otel_propagation.rs - services/ws-wasi-runner/tests/vector_otlp_relay.rs - services/ws-wasm-agent/src/lib.rs + - services/ws-wasm-agent/tests/web.rs - services/ws-web-runner/build.rs - services/ws-web-runner/tests/modules.rs - services/ws/tests/config.rs diff --git a/config/conftest/policy/generated_trees/generated_trees.rego b/config/conftest/policy/generated_trees/generated_trees.rego new file mode 100644 index 00000000..8bcf8e5d --- /dev/null +++ b/config/conftest/policy/generated_trees/generated_trees.rego @@ -0,0 +1,104 @@ +# Bidirectional cross-reference between config/generated-trees.toml and the linter configs it governs. +# Run with `--namespace generated_trees`; the conftest-check-generated-trees task feeds the declaration plus every +# governed config to one `--combine` evaluation, letting a rule compare across files. +# +# Forward: a config listed in a tree's `required_in` must actually carry that tree's exclusion, or the linter walks +# generator output and reports findings nobody can fix by hand. Reverse: a config carrying a tree exclusion must be +# listed, or the declaration is no longer the whole picture and the next tree added will miss that config. +# +# Exclusions are matched as exact strings rather than by substring, because a wrong path base is the failure this +# policy exists to catch: semgrep silently matches nothing when a `paths.exclude` entry loses its leading slash, +# and the mistake is invisible until generator output shows up in a report. +package generated_trees + +source_path := "config/generated-trees.toml" + +# Path separators, normalised so a Windows lane compares equal to the declaration. +# conftest reports each path the way the OS handed it over, so on Windows a file found by walking a directory +# arrives as `config\semgrep\x.yaml` while one named directly on the command line keeps the `/` it was typed with. +# Both must match the forward-slash paths the declaration uses, or every forward check fails on that lane while +# the reverse checks stay silent -- exactly the shape the Windows job showed. +normalised(path) := replace(path, "\\", "/") + +is_source(file) if normalised(file.path) == source_path + +# Module-scope sprintf templates each live on their own line so the source stays readable. +missing_msg := "%s: missing the %q exclusion for the %q generated tree, which config/generated-trees.toml requires" + +undeclared_msg := "%s: carries the %q exclusion for the %q generated tree but is not in its `required_in` list" + +no_anchor_msg := "config/generated-trees.toml: [tree.%s] requires %q, which matches no [anchor] key" + +# The declared trees, keyed by name. +trees[name] := tree if { + some file in input + is_source(file) + some name, tree in file.contents.tree +} + +# The declared per-tool path templates, keyed by config path or path prefix. +anchors[prefix] := template if { + some file in input + is_source(file) + some prefix, template in file.contents.anchor +} + +# The exclusion string `config` must use for `tree`, resolved through the [anchor] table. +expected(config, tree) := pattern if { + some prefix, template in anchors + startswith(config, prefix) + pattern := replace(template, "{tree}", tree) +} + +# Every string value anywhere in a governed config's parsed contents, paired with the file it came from. +# A plain walk keeps the policy free of per-tool schema knowledge -- semgrep nests exclusions under +# `rules[].paths.exclude`, ast-grep under `files`, jscpd under `ignore`, pyrefly under `project-excludes`. +config_strings contains [normalised(file.path), value] if { + some file in input + not is_source(file) + walk(file.contents, [_, value]) + is_string(value) +} + +# The patterns `config` is expected to carry for `tree`: the narrower set when one is declared, else the whole tree. +allowed(config, name) := trees[name].narrower[config] + +allowed(config, name) := [expected(config, name)] if { + not trees[name].narrower[config] +} + +# The whole-tree pattern with its trailing `**` removed, used to spot any exclusion aimed into the tree. +tree_prefix(config, name) := trim_suffix(expected(config, name), "**") + +accounted(config, value, name) if { + config in trees[name].required_in + value in allowed(config, name) +} + +deny contains msg if { + some name, tree in trees + some config in tree.required_in + some pattern in allowed(config, name) + not [config, pattern] in config_strings + msg := sprintf(missing_msg, [config, pattern, name]) +} + +# Reverse direction, matched on the tree prefix rather than on the exact pattern. +# That way a narrowed exclusion cannot slip in undeclared either -- an unlisted `/verification/**/foo.yaml` is as +# much a drift as an unlisted whole-tree skip. +deny contains msg if { + some pair in config_strings + some name, _ in trees + startswith(pair[1], tree_prefix(pair[0], name)) + not accounted(pair[0], pair[1], name) + msg := sprintf(undeclared_msg, [pair[0], pair[1], name]) +} + +# A `required_in` entry that no [anchor] key covers would make the forward rule above vacuous. +# Fail loudly instead: there is no way to know what exclusion string that config should carry. +deny contains msg if { + some name, tree in trees + some config in tree.required_in + not expected(config, name) + msg := sprintf(no_anchor_msg, [name, config]) +} diff --git a/config/conftest/policy/generated_trees/generated_trees_test.rego b/config/conftest/policy/generated_trees/generated_trees_test.rego new file mode 100644 index 00000000..bc360ae4 --- /dev/null +++ b/config/conftest/policy/generated_trees/generated_trees_test.rego @@ -0,0 +1,81 @@ +# Unit tests for the generated_trees policy, run by `conftest verify`. +# +# These exist for one reason the other policies do not need: this policy compares conftest's reported file paths +# against paths written in a config file, and conftest reports them the way the host OS hands them over. A +# directory walked on Windows yields `config\semgrep\x.yaml` where macOS and Linux yield `config/semgrep/x.yaml`, +# so the Windows behaviour cannot be exercised by running the real task on a developer machine or on the Linux +# lane. Feeding synthetic input here covers that lane's shape from any OS. +package generated_trees_test + +import data.generated_trees + +# A declaration fixture covering both anchor styles the real file uses. +declaration := { + "path": "config/generated-trees.toml", + "contents": { + "anchor": { + "config/ast-grep/": "{tree}/**", + "config/semgrep/": "/{tree}/**", + }, + "tree": {"generated": {"required_in": [ + "config/ast-grep/rules/no-map-err.yaml", + "config/semgrep/no-todo.yaml", + ]}}, + }, +} + +# A governed config carrying the exclusion its anchor calls for, addressed with `sep` as the path separator. +governed(sep, path, exclusion) := { + "path": replace(path, "/", sep), + "contents": {"paths": {"exclude": [exclusion]}}, +} + +posix_input := [ + declaration, + governed("/", "config/ast-grep/rules/no-map-err.yaml", "generated/**"), + governed("/", "config/semgrep/no-todo.yaml", "/generated/**"), +] + +# The same tree as a Windows lane reports it. +# The declaration keeps the slashes it was typed with on the command line, while the walked configs arrive with +# backslashes -- the mismatch that turned every forward check red on that lane. +windows_input := [ + declaration, + governed("\\", "config/ast-grep/rules/no-map-err.yaml", "generated/**"), + governed("\\", "config/semgrep/no-todo.yaml", "/generated/**"), +] + +test_posix_paths_satisfy_the_declaration if { + count(generated_trees.deny) == 0 with input as posix_input +} + +test_windows_paths_satisfy_the_declaration if { + count(generated_trees.deny) == 0 with input as windows_input +} + +# The Windows shape must still be able to fail, or the normalisation would just be silencing the whole policy. +test_windows_paths_still_catch_a_missing_exclusion if { + broken := [ + declaration, + governed("\\", "config/ast-grep/rules/no-map-err.yaml", "generated/**"), + governed("\\", "config/semgrep/no-todo.yaml", "/somewhere-else/**"), + ] + + messages := generated_trees.deny with input as broken + count(messages) == 1 + contains(messages[_], "config/semgrep/no-todo.yaml") +} + +# An undeclared exclusion must still be caught through a backslash path. +test_windows_paths_still_catch_an_undeclared_exclusion if { + extra := [ + declaration, + governed("\\", "config/ast-grep/rules/no-map-err.yaml", "generated/**"), + governed("\\", "config/semgrep/no-todo.yaml", "/generated/**"), + governed("\\", "config/semgrep/no-non-ascii.yaml", "/generated/**"), + ] + + messages := generated_trees.deny with input as extra + count(messages) == 1 + contains(messages[_], "not in its `required_in` list") +} diff --git a/config/generated-trees.toml b/config/generated-trees.toml new file mode 100644 index 00000000..f0af1dac --- /dev/null +++ b/config/generated-trees.toml @@ -0,0 +1,77 @@ +# Single source of truth for the generator-owned trees and which linter configs must skip each one. +# +# These exclusion lists cannot be made identical across linters, and must not be: a rule scoped to one language +# has no reason to skip a tree holding none of it, which is why the ast-grep Rust rules skip generated/ but not +# verification/ (scenario YAML and TOML output, no Rust). no-non-ascii goes further and deliberately does NOT +# skip module pkg/, because a blanket pkg/ exclude once hid hand-written shim glyphs for months. +# +# What must stay in sync is narrower: every config that ought to skip a given tree does, written against the base +# its own tool resolves paths from. Declaring that here once -- instead of trusting two dozen configs to stay +# aligned by hand -- is what the `generated_trees` conftest policy enforces, in both directions, so a config +# cannot drift out of the declaration either. +# +# Adding a generator-owned tree: add a [tree.] block naming the configs that must skip it. The policy then +# fails until each one carries the exclusion, built from the [anchor] entry covering that config. + +# What each tool resolves an exclusion path against, keyed by the config path (or path prefix) it applies to. +# `{tree}` stands in for the tree name. These are three different bases, not three ways of writing one path: +# semgrep anchors `paths.exclude` at the repo root, so it needs a leading slash; ast-grep and jscpd take a path +# relative to the repo root; and pyrefly resolves `project-excludes` relative to its own config file, which sits +# one level down inside config/, so its entries climb out with `../`. +[anchor] +"config/ast-grep/" = "{tree}/**" +"config/jscpd.json" = "{tree}/**" +"config/pyrefly.toml" = "../{tree}/**" +"config/semgrep/" = "/{tree}/**" + +# AsyncAPI/OpenAPI/WIT specs plus the generated language clients. +# +# One deliberate absence: config/semgrep/no-trailing-backslash.yaml is not listed and must not be added. The +# backslash ban holds for generated Rust and Python too -- a generator emitting a line continuation is a generator +# bug to fix at the source, not output to forgive here -- so that rule keeps this tree in scope on purpose. +[tree.generated] +regenerated_by = "mise run gen-specs-all" +required_in = [ + "config/ast-grep/rules/command-requires-command-error.yaml", + "config/ast-grep/rules/no-allow-attributes.yaml", + "config/ast-grep/rules/no-concat-raw-strings.yaml", + "config/ast-grep/rules/no-consecutive-expect.yaml", + "config/ast-grep/rules/no-doctest.yaml", + "config/ast-grep/rules/no-extern-crate-self.yaml", + "config/ast-grep/rules/no-inline-mod.yaml", + "config/ast-grep/rules/no-map-err.yaml", + "config/ast-grep/rules/no-mixed-doc-line-comments.yaml", + "config/ast-grep/rules/no-non-ascii.yaml", + "config/ast-grep/rules/no-result-alias.yaml", + "config/ast-grep/rules/no-result-string-err.yaml", + "config/ast-grep/rules/no-shadow-result.yaml", + "config/ast-grep/rules/no-string-literal-line-continuation.yaml", + "config/ast-grep/rules/no-string-new.yaml", + "config/ast-grep/rules/prefer-self-mod-use.yaml", + "config/ast-grep/rules/use-mod-order.yaml", + "config/jscpd.json", + "config/pyrefly.toml", + "config/semgrep/anyhow-only-in-error-rs.yaml", + "config/semgrep/comment-summary-line.yaml", + "config/semgrep/no-non-ascii.yaml", + "config/semgrep/no-todo.yaml", + "config/semgrep/no-type-comments.yaml", +] + +# Scenario-generator outputs checked in beside their inputs; holds no Rust, so the Rust-only rules are absent. +[tree.verification] +regenerated_by = "mise run regen-verification" +required_in = [ + "config/jscpd.json", + "config/semgrep/comment-summary-line.yaml", + "config/semgrep/no-non-ascii.yaml", + "config/semgrep/no-todo.yaml", + "config/semgrep/no-trailing-backslash.yaml", +] + +# Configs that deliberately skip only part of a tree, with the exact globs they carry instead. +# Recorded rather than waved through, so narrowing stays a visible decision: the trailing-backslash ban only has +# generated output to forgive in the two deployment artifacts regen-verification writes, and keeping the rest of +# the tree in scope is the point. +[tree.verification.narrower] +"config/semgrep/no-trailing-backslash.yaml" = ["/verification/**/compose.yaml", "/verification/**/mise.toml"] diff --git a/config/jscpd-baseline.json b/config/jscpd-baseline.json new file mode 100644 index 00000000..ac66daeb --- /dev/null +++ b/config/jscpd-baseline.json @@ -0,0 +1,364 @@ +{ + "version": 1, + "fingerprints": { + "003c69c5099764c0": 1, + "006f44d440c27cda": 1, + "00895ccefe55ea1f": 1, + "01a4e90eede80cb9": 1, + "0225b79aa9a5cccf": 1, + "023513f38a87a1e6": 1, + "025e751ba88e4efb": 1, + "02c3bb68a9cb1430": 1, + "03e4e07acff327e2": 1, + "04199950b57990a0": 1, + "04daef50421ed235": 1, + "0624ccea1cfe8a52": 1, + "068ce48e0ecbf49b": 1, + "07378241fe562817": 1, + "077a1f0f3040c9ff": 1, + "081e81cbe4dcbe9b": 1, + "084d33784cf81a99": 1, + "08575dd6efc108cb": 1, + "0875c25e5ebb12cc": 1, + "0a544ce0eab1d8e7": 1, + "0c8f78b8c0d0f6b4": 1, + "0de69d245e3a7ff8": 1, + "100c4984075d3662": 1, + "100c5a39e529a691": 1, + "109b672c3d6939d9": 1, + "10b898032781bd2b": 1, + "136c57b4180f7238": 1, + "14c932221a47ee8b": 1, + "151943582f4d3a1c": 1, + "15f330210837288e": 1, + "161f873e1c1bf887": 1, + "162b7eccbfa52a74": 1, + "16ff0dbe3f3bf18c": 1, + "1722046a9fb68d8c": 1, + "1a07ddf368451c0a": 1, + "1a117c4e7a891482": 1, + "1a1867cca4e1d4e8": 1, + "1a1e1611e4db4656": 1, + "1a5d031b56ae4d6c": 1, + "1b2ff1d957de5f84": 1, + "1b88564119751a30": 1, + "1b9c78cc42fc26b6": 1, + "1bd6f5989d1961e5": 1, + "1c8cf13e10b67513": 1, + "1ce17b70419170cb": 1, + "1deb2208cf2ae53e": 1, + "1e540a5d293c0201": 1, + "1ef5e997a2b1f9ca": 1, + "1f8c3b477a9abb84": 1, + "20149cb81e6e030d": 1, + "204f3cdfa2071c3b": 1, + "220baca8675f61fd": 1, + "249241d8c264bc63": 1, + "24d94784c469f23a": 1, + "25bfced4b2a97152": 1, + "25f2fdb4e93d5683": 1, + "262c95c783c10c27": 1, + "264e155c8fdaf095": 1, + "266cd9babed7be0c": 1, + "295c804cb17ea8cf": 1, + "2c47cf795be8c0d9": 1, + "2c61d8307488c819": 1, + "2e16d6fbda66df34": 1, + "2e738d4de357d244": 1, + "2f97adcddfac1644": 1, + "300db9d0dacb1375": 1, + "3192274800f8c0d1": 1, + "31a1985df946271b": 1, + "31a98e49914dbdda": 1, + "32bc1f115b8d6721": 1, + "331841e078587935": 1, + "3369118fac56660d": 1, + "34b2a7c1a1a932c1": 1, + "34cd0de3c97e4264": 1, + "351718e466739794": 1, + "35ac92bba9a699ae": 1, + "35d7f304546de8a8": 1, + "389aea3e8662fb9b": 1, + "3945ad5647fc189d": 1, + "3a075ed2f78fa99e": 1, + "3aec07afda461f58": 1, + "3c0efbffa8bb6f9b": 1, + "3d7d16d66b899144": 1, + "3de59029c7b786b0": 1, + "3e0979bbb8c28ddc": 1, + "3e434b9269573a1c": 1, + "3e5aadc3e77078b6": 1, + "3e6e51f257b8453d": 1, + "414060bd0f2c3f74": 1, + "4268c1cf96611f46": 1, + "42d9fd6761886c9c": 1, + "43336c4ecfeca2a6": 1, + "448a09ade148d8c5": 1, + "4534795197b3c34c": 1, + "45486be737012557": 1, + "461eb8f3a4cf80d3": 1, + "468a45c2019ca4cd": 1, + "48306e1b09fbf195": 1, + "4a5b2cd87f30b9d3": 1, + "4c4c46e0ea6e7edc": 1, + "4eff6767e2aba30c": 1, + "506af004dd69d586": 1, + "50b373907a5a8f78": 1, + "50f5f12046dd3030": 1, + "51554123e83abb62": 1, + "51d9869c79c352cd": 1, + "521c8a862b4eacb3": 1, + "524a6a96d3fbfb6b": 1, + "52973adcc596dd52": 1, + "533bdecb47a87756": 1, + "53a6c52fb08ae691": 1, + "54b576854edda79b": 1, + "54f0cec8830d66e4": 1, + "55a3b3883b483d42": 1, + "564cd61a6a950a0d": 1, + "569e50cf9502975e": 1, + "56f6e2c56095b2ac": 1, + "57abbbbe99d0f40f": 1, + "582ab0d252910ed1": 1, + "583f38ee93d4fa73": 1, + "59aaafa19255814f": 1, + "5a38489b585cef78": 1, + "5a5b6a5ae3b87eea": 1, + "5a74620e0bca1632": 1, + "5b417d610f5386a7": 1, + "5cfb0f0873663a20": 1, + "5dd2d82aa4d412af": 1, + "5e1507230a488a93": 1, + "5ec166832f5e4653": 1, + "5ef75b0a5da08bd0": 1, + "5f2574a3a43dd64d": 1, + "608f556637a1f4db": 1, + "61012257ce19e116": 1, + "61b28209f41edecf": 1, + "6214037b4acff9b5": 1, + "62257586054b0689": 1, + "635fb32c899e4df6": 1, + "644491f2c34618c6": 1, + "6609674842b23010": 1, + "68018b468b05c01d": 1, + "683f90ab64c941ba": 1, + "688189c9d78d110e": 1, + "68d282c0d1bea30e": 1, + "68dcb15f583a29b9": 1, + "694a810b1108ef3e": 1, + "6a9090b7ce67374d": 1, + "6ab191050bb8943d": 1, + "6b0001890fa8f2d0": 1, + "6b4fea9b9c76d1b1": 1, + "6b7ef9701aa8a94b": 1, + "6bad88d8a503b406": 1, + "6f0fc3a98f5a3f4e": 1, + "6f5285df6f31747f": 1, + "6f84b76a00168521": 1, + "704ad54a5468c4b3": 1, + "71c439c66817fc57": 1, + "71d4c22b912832b9": 1, + "72295cb3aacc3d6c": 1, + "728c97d5724db2c3": 1, + "73ffadbd8a93c577": 1, + "7530634a13dc6308": 1, + "7558cd4dfb81e574": 1, + "7576fc3cdf945923": 1, + "76b4bda9089c9985": 1, + "773fa53d9003b640": 1, + "781cc393068d5440": 1, + "791347abafa6f64e": 1, + "7928760720f08c6d": 1, + "7ac63870d0087e3b": 1, + "7aee849465ea91ab": 1, + "7bd9f81ef3a29393": 1, + "7ca55da801def6b2": 1, + "7f700d1f7681c95e": 1, + "7fa442e9e2160278": 1, + "7fc2541d22f60998": 1, + "81b15523291b195e": 1, + "81c0a5c7a32d0775": 1, + "82db4dce7d23aee8": 1, + "8312c485c46fdf37": 1, + "83366229d53282e3": 1, + "8390cc689d64efcb": 1, + "84b3a552aa5c21c2": 1, + "84e106866c27d5cb": 1, + "87566acb3f2550bf": 1, + "875ab91a8c15e035": 1, + "879bfe322b3d676b": 1, + "87bf906017df4379": 1, + "87f1eade46af4d27": 1, + "898447a1d3603046": 1, + "8996a093b7bb3b54": 1, + "89bb6ca7b46aa5cd": 1, + "8acea4476f04e4a0": 2, + "8bcb05f9d0a15152": 1, + "8beb95ee363cd6e3": 1, + "8c912ef640675113": 1, + "8cc8242f1e37fc8e": 1, + "8cfbef0da485b326": 1, + "8fa398e0937479af": 1, + "9026cfda21e0396d": 1, + "928c39b341599636": 1, + "92ed119fbc8ed830": 1, + "92f12f72c8e5a704": 1, + "94a910b912778d76": 1, + "953f81c57e53df10": 1, + "96825238ca07ac74": 1, + "983245de70d60f9f": 1, + "985af6ffa52d3359": 1, + "9868e80d502c44d3": 1, + "98908da15a39ff81": 1, + "98dab08a54b05abf": 1, + "98e1331244999da4": 1, + "98f11864904716c0": 1, + "99d92cc4059cd526": 1, + "99e862abc592b065": 1, + "9a8dea5abd17445a": 1, + "9a8fed48470f9c3f": 1, + "9b6b5ce6c7b4646c": 1, + "9bee686b5043aedd": 1, + "9e018839353b1c84": 2, + "9e835c94361d6917": 1, + "a03ab32ade9684de": 1, + "a0438c241cc4a042": 1, + "a0a741f05c43798a": 1, + "a1ef5137775b605c": 1, + "a3111223b027f569": 1, + "a3a2b355d83e7c60": 1, + "a41e4aadea155813": 1, + "a4cb07095b808af8": 1, + "a518b5ae71e6e140": 1, + "a52b3254cc78b001": 1, + "a56b9fc117032c88": 1, + "a58c4944142e3bc2": 1, + "a58e3e798522b26c": 1, + "a5f04a02187cc3c8": 1, + "a68200ccaced4620": 1, + "a7b4d9edea8a9cb6": 1, + "a85fc20c24dcde44": 1, + "a896f65e46666bd0": 1, + "a9f793157ec6fced": 1, + "aa68ceead4a59e5f": 1, + "aa914b5b5ce55a0b": 1, + "aad9806c62fff62f": 1, + "aaeb9cdf748826bd": 1, + "ab4dbe992894e239": 2, + "ab8dfb1256c7ee74": 3, + "ac4638cd75018e36": 1, + "acdebc833a1835f4": 1, + "ad6457caf0d9b850": 1, + "ad6c8d0f5db047f5": 1, + "ae590b605758bec9": 1, + "af15707251b0b0e4": 1, + "b07cc7ec51bc1602": 1, + "b29077918336343b": 1, + "b296aae983dfdf34": 1, + "b32ec6344a29f094": 1, + "b4bba4c77bcedb60": 1, + "b5b271b02f563d47": 1, + "b5cf8f89d849de0b": 1, + "b8c4c2269d3fa831": 1, + "b9618e5424e42dff": 1, + "b9f2cf6d0d1816f7": 1, + "ba18dcc601d42b5e": 1, + "ba8145a95d4bf884": 1, + "baa142ee1fa82c18": 1, + "bab02c8fb19dca60": 1, + "bbce73eb21524ef5": 1, + "bc0cc376fa8d8327": 1, + "c01708e08c252d4a": 1, + "c06c37bfc8b0d9c9": 1, + "c09857b2be5aa4e1": 1, + "c10d9673e6b919c8": 1, + "c16ccb530445d1a2": 1, + "c1739adb5d43837b": 1, + "c1f303564ce2be53": 1, + "c304192584ec08b5": 1, + "c3b5adc9a5233cc5": 1, + "c4717a3b4b3e8c45": 1, + "c479ded037f23e52": 1, + "c48839dee3d0d4b9": 1, + "c581f2a55c9b0c56": 1, + "c66014a52ad17acd": 1, + "c6ad80475453274a": 1, + "c6e1e53b3f01fafb": 1, + "c713bd192586f25f": 1, + "c82b03377a54aab4": 1, + "c88e286ab3464478": 1, + "c8aa41bf2df4989a": 1, + "c8f96481b232f2ca": 1, + "c930bd531dbf93b9": 1, + "ca1ed8d0528c4816": 1, + "cb5488b69ff4c99f": 1, + "cbc385e98ca0d525": 1, + "cc04ffb35a35eff3": 1, + "cd160a47b593a2d6": 1, + "cd216052533fd84d": 1, + "ce09350cb036bbc9": 1, + "ce990318bfc4aa30": 1, + "cef2abfbb647c4b4": 1, + "cf7b459648a51f31": 1, + "cfe89c13ebd13c2e": 1, + "d02a1d7e66024e72": 1, + "d0824c4dc47397f2": 1, + "d2862790e1935f60": 1, + "d2c9c708a567ce7e": 1, + "d53180eb9c1c97e5": 1, + "d59e8d4b4d20963f": 1, + "d6fe23ac6652e0ca": 1, + "d72424c1bba450b5": 1, + "d7954652649a6f2c": 1, + "d7ad53a1ef1dc59c": 1, + "d8a0ad2f6e337e91": 1, + "d8f2a01e708c963b": 1, + "da8f06b38d34738e": 1, + "db6cec2e60d3687f": 1, + "dd56a7d95c286b3e": 1, + "e064fe600533f089": 1, + "e25456ad9cc7f9f9": 1, + "e4b9e7313fc22777": 1, + "e517097fd06d5cb5": 1, + "e5481f802d5a9f84": 1, + "e5c1ef02aa724f2b": 1, + "e67876c933d8a3b6": 1, + "e78731e14c433e07": 1, + "e797378988ced77b": 1, + "e8027ce1ea5e4717": 1, + "e8c1d6ed921ff4a5": 1, + "ebb8f005bcebfcee": 1, + "ec5c408a8f2c368d": 1, + "ec672ca4d244a9fa": 1, + "edef426075eceeb0": 1, + "edfb18c73a139873": 1, + "eeb29c82a26e0952": 1, + "ef69568ef92f1b79": 1, + "f0ecf845ae38d979": 1, + "f1f99624666ed51c": 1, + "f20e3a3f28708194": 1, + "f2167d54ff8b56ea": 1, + "f2a086bcedd018a8": 1, + "f2f40a2a067eb8ed": 1, + "f32c2b14cbf25a04": 1, + "f3eba887cab8ffc1": 1, + "f4cb8a57b3d1ebd1": 1, + "f4e5cc8c67e3dad7": 1, + "f5104a6be7ba929d": 1, + "f538dacb09de8e14": 1, + "f773b3122d6cb21a": 1, + "f8dacf11c2b785d4": 1, + "f9358cd2f43c9b65": 1, + "f964c720cdcd7ac0": 1, + "f99b1a47383f6735": 1, + "f9a76d82860ee95f": 1, + "faad1b4efc432727": 1, + "fae2a874410e4fbd": 1, + "fb0ad916b8f16dee": 1, + "fb5610b76ded1373": 1, + "fb78fa42d28ddc83": 1, + "fb8747afe01f9463": 1, + "fb8c4a24a3cbfb47": 1, + "fe6b5fea263c4cca": 1 + } +} diff --git a/config/jscpd.json b/config/jscpd.json new file mode 100644 index 00000000..a9a62f84 --- /dev/null +++ b/config/jscpd.json @@ -0,0 +1,6 @@ +{ + "minTokens": 40, + "minLines": 5, + "mode": "mild", + "ignore": ["generated/**", "verification/**", "**/pkg/package.json", "services/ws-wasi-runner/src/bindings.rs"] +} diff --git a/config/semgrep/no-todo.yaml b/config/semgrep/no-todo.yaml index 855654df..3cd9fc61 100644 --- a/config/semgrep/no-todo.yaml +++ b/config/semgrep/no-todo.yaml @@ -20,6 +20,9 @@ rules: # Vendored upstream WIT specs / generated bindings. # TODOs in there belong to wasi-nn / wasi-webgpu / etc. upstream. - "/generated/**" + # regen-verification output, whose TODOs are nobody's deferred work. + # They come from the scenario inputs, so resolve them in verification/*/input/ and regenerate. + - "/verification/**" # This rule file itself contains the word `TODO` in its own docstring and message. # Allowlist or the rule trips on itself. - "/config/semgrep/no-todo.yaml" diff --git a/config/semgrep/prefer-yaml-toml.yaml b/config/semgrep/prefer-yaml-toml.yaml index dcc57f14..a5129c23 100644 --- a/config/semgrep/prefer-yaml-toml.yaml +++ b/config/semgrep/prefer-yaml-toml.yaml @@ -19,6 +19,13 @@ rules: - "oxfmtrc.jsonc" # oxfmt: only JSON / JSONC supported - "tsconfig.json" # TypeScript/tsgolint: mandates the JSON `tsconfig.json` name (oxlint --type-aware) - "math1-input.json" # math1 wire payload: served to modules verbatim, parsed by their JSON parsers + # Both jscpd files have to be plain JSON, and .jsonc would misrepresent them. + # jscpd parses its config with plain serde_json and reads no YAML/TOML, so a `//` comment aborts it with + # `key must be a string at line 2 column 3` -- a .jsonc extension would advertise comments the parser + # rejects. jscpd-baseline.json is machine-written by `jscpd --update-baseline`; its shape is not ours to + # choose at all. + - "jscpd.json" # jscpd config + - "jscpd-baseline.json" # jscpd clone-fingerprint ratchet pattern-regex: \A message: >- Prefer YAML or TOML over JSON/JSONC/JSONL for config. diff --git a/config/typos.toml b/config/typos.toml index f4ddfd99..4d15dbf0 100644 --- a/config/typos.toml +++ b/config/typos.toml @@ -1,8 +1,13 @@ # data/ is out of scope for the typos check. # It holds vendored demo + model assets (not our own prose), so its spelling # is not ours to police. +# config/jscpd-baseline.json holds nothing but machine-written 16-char clone fingerprints, and hex digits spell +# enough near-words to trip the dictionary ("...b76ded1373" -> "dead", "...747afe01f" -> "safe"). Excluded as a +# whole file rather than via extend-ignore-re, which would have to match any 16-hex run and would blind the +# checker to real typos elsewhere. Nothing is lost: the file contains no prose. It cannot be gitignored -- the +# ratchet in jscpd-check needs it tracked. [files] -extend-exclude = ["data/"] +extend-exclude = ["config/jscpd-baseline.json", "data/"] [default] # "MIS Quarterly" is a journal title (Management Information Systems), not a typo of MISS/MIST. diff --git a/libs/web/Cargo.toml b/libs/web/Cargo.toml index dd2d3c43..c4e64014 100644 --- a/libs/web/Cargo.toml +++ b/libs/web/Cargo.toml @@ -23,6 +23,7 @@ web-sys = { workspace = true, features = [ "MediaStreamTrack", "MessageEvent", "Navigator", + "Window", ] } # Coverage instrumentation, off by default and enabled only by the coverage build of the browser wasm modules. diff --git a/libs/web/src/lib.rs b/libs/web/src/lib.rs index 3c898e45..cc78206b 100644 --- a/libs/web/src/lib.rs +++ b/libs/web/src/lib.rs @@ -69,3 +69,47 @@ pub async fn request_sensor_permission(target: JsValue) -> Result Result<(), JsValue> { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let callback = Closure::once_into_js(move || { + ignore(resolve.call0(&JsValue::NULL)); + }); + + if let Err(error) = + window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) + { + ignore(reject.call1(&JsValue::NULL, &error)); + } + }); + wasm_bindgen_futures::JsFuture::from(promise).await.map(ignore) +} + +/// Build this page's `/ws` endpoint URL from `window.location`, upgrading to `wss:` on an https page. +pub fn websocket_url() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; + let location = js_sys::Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; + let protocol = js_sys::Reflect::get(&location, &JsValue::from_str("protocol"))? + .as_string() + .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; + let host = js_sys::Reflect::get(&location, &JsValue::from_str("host"))? + .as_string() + .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; + let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; + Ok(format!("{ws_protocol}//{host}/ws")) +} + +/// Render a `JsValue` error as a displayable string, falling back to `JSON.stringify` then `Debug`. +#[must_use] +pub fn describe_js_error(error: &JsValue) -> String { + error + .as_string() + .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) + .unwrap_or_else(|| format!("{error:?}")) +} diff --git a/services/ws-modules/audio1/src/lib.rs b/services/ws-modules/audio1/src/lib.rs index 0ade96fe..d85888c9 100644 --- a/services/ws-modules/audio1/src/lib.rs +++ b/services/ws-modules/audio1/src/lib.rs @@ -6,9 +6,8 @@ use std::cell::RefCell; -use et_web::{JsCastExt as _, get_media_devices}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsCastExt as _, describe_js_error, get_media_devices, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -181,50 +180,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/bluetooth/src/lib.rs b/services/ws-modules/bluetooth/src/lib.rs index 75dc4e45..407f9aeb 100644 --- a/services/ws-modules/bluetooth/src/lib.rs +++ b/services/ws-modules/bluetooth/src/lib.rs @@ -4,9 +4,8 @@ reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" )] -use et_web::{JsFunctionExt as _, JsPromiseExt as _}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsFunctionExt as _, JsPromiseExt as _, describe_js_error, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -174,50 +173,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/comm1/Cargo.toml b/services/ws-modules/comm1/Cargo.toml index af38871d..22043c9d 100644 --- a/services/ws-modules/comm1/Cargo.toml +++ b/services/ws-modules/comm1/Cargo.toml @@ -14,9 +14,7 @@ test = false [dependencies] edge-toolkit.workspace = true -# Optional and coverage-only: unlike the other browser modules, comm1 has no et-web dependency of its own. -# Pull it under the coverage feature purely for its __et_capture_coverage export (wasm-bindgen collects it into glue). -et-web = { workspace = true, optional = true } +et-web.workspace = true et-ws-wasm-agent.workspace = true js-sys.workspace = true serde.workspace = true diff --git a/services/ws-modules/comm1/src/lib.rs b/services/ws-modules/comm1/src/lib.rs index 33f1797b..2030ce3c 100644 --- a/services/ws-modules/comm1/src/lib.rs +++ b/services/ws-modules/comm1/src/lib.rs @@ -8,12 +8,11 @@ use std::cell::RefCell; use std::rc::Rc; use edge_toolkit::ws::{AgentConnectionState, AgentSummary, ServerMessage}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; -use js_sys::{Promise, Reflect}; +use et_web::{sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; const LIST_AGENTS_POLL_MS: i32 = 1_000; const MESSAGE_PAUSE_MS: i32 = 3_000; @@ -165,17 +164,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { append_to_textarea("module-output", message) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_agent_id(client: &WsClient) -> Result { for _ in 0_u32..100 { let agent_id = client.get_agent_id(); @@ -187,32 +175,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - let _resolved = resolve.call0(&JsValue::NULL); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - let _rejected = reject.call1(&JsValue::NULL, &error); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/data1/src/lib.rs b/services/ws-modules/data1/src/lib.rs index c73e4f31..5ecefe4c 100644 --- a/services/ws-modules/data1/src/lib.rs +++ b/services/ws-modules/data1/src/lib.rs @@ -5,13 +5,11 @@ )] use edge_toolkit::ws::ServerMessage; -use et_web::JsResultExt as _; -use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; +use et_web::{JsResultExt as _, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use futures_util::StreamExt as _; -use js_sys::{Promise, Reflect}; use tracing::info; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; #[wasm_bindgen(start)] pub fn init() { @@ -109,16 +107,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { append_to_textarea("module-output", message) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_agent_id(client: &WsClient) -> Result { for _ in 0_u32..100 { let agent_id = client.get_agent_id(); @@ -129,28 +117,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { } Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, _reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - let _id: Result = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/except1/src/lib.rs b/services/ws-modules/except1/src/lib.rs index be2e3a47..66f8a385 100644 --- a/services/ws-modules/except1/src/lib.rs +++ b/services/ws-modules/except1/src/lib.rs @@ -15,11 +15,10 @@ use core::fmt; -use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; -use js_sys::{Promise, Reflect}; +use et_web::{sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use tracing::info; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; #[wasm_bindgen(start)] pub fn init() { @@ -110,16 +109,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { append_to_textarea("module-output", message) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_agent_id(client: &WsClient) -> Result { for _ in 0_u32..100 { let agent_id = client.get_agent_id(); @@ -130,28 +119,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { } Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, _reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - let _id: Result = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/face-detection/src/lib.rs b/services/ws-modules/face-detection/src/lib.rs index a17d5345..5b627553 100644 --- a/services/ws-modules/face-detection/src/lib.rs +++ b/services/ws-modules/face-detection/src/lib.rs @@ -23,8 +23,11 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _, get_media_devices}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; +use et_web::{ + JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _, describe_js_error, get_media_devices, sleep_ms, + websocket_url, +}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use js_sys::{Array, Float32Array, Function, Promise, Reflect}; use serde_json::json; use tracing::info; @@ -674,30 +677,6 @@ fn string_entries(target: &JsValue, field: &str) -> Result, JsValue> Ok(entries) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} - async fn create_face_session(model_path: &str) -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let ort = Reflect::get(window.as_ref(), &JsValue::from_str("ort"))?; @@ -774,29 +753,6 @@ fn create_tensor(values: &Float32Array) -> Result { Reflect::construct(&tensor_ctor, &args) } -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - let _ = resolve.call0(&JsValue::NULL); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - let _ = reject.call1(&JsValue::NULL, &error); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - fn log(message: &str) { let line = format!("[face-detection] {message}"); web_sys::console::log_1(&JsValue::from_str(&line)); diff --git a/services/ws-modules/geolocation/src/lib.rs b/services/ws-modules/geolocation/src/lib.rs index 6caa761b..67beb5ac 100644 --- a/services/ws-modules/geolocation/src/lib.rs +++ b/services/ws-modules/geolocation/src/lib.rs @@ -4,9 +4,8 @@ reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" )] -use et_web::JsFunctionExt as _; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsFunctionExt as _, describe_js_error, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -198,50 +197,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/graphics-info/src/lib.rs b/services/ws-modules/graphics-info/src/lib.rs index ec4e2487..ca41347d 100644 --- a/services/ws-modules/graphics-info/src/lib.rs +++ b/services/ws-modules/graphics-info/src/lib.rs @@ -5,9 +5,9 @@ reason = "browser WASM module: JsFuture is !Send; GPU benchmark uses float math; pipeline helpers are single-use" )] -use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsCastExt as _, JsFunctionExt as _, JsPromiseExt as _, describe_js_error, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; +use js_sys::Promise; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -828,50 +828,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/har1/src/lib.rs b/services/ws-modules/har1/src/lib.rs index f69f0d40..c5e6ceef 100644 --- a/services/ws-modules/har1/src/lib.rs +++ b/services/ws-modules/har1/src/lib.rs @@ -17,11 +17,14 @@ use std::cell::RefCell; use std::collections::VecDeque; use std::rc::Rc; -use et_web::{JsFunctionExt as _, JsPromiseExt as _, SENSOR_PERMISSION_GRANTED, request_sensor_permission}; +use et_web::{ + JsFunctionExt as _, JsPromiseExt as _, SENSOR_PERMISSION_GRANTED, describe_js_error, request_sensor_permission, + sleep_ms, websocket_url, +}; use et_ws_wasm_agent::{ - WsClient, WsClientConfig, js_bool_field, js_nested_object, js_number_field, set_textarea_value, + WsClient, WsClientConfig, js_bool_field, js_nested_object, js_number_field, set_textarea_value, wait_for_connected, }; -use js_sys::{Array, Float32Array, Function, Promise, Reflect}; +use js_sys::{Array, Float32Array, Function, Reflect}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -604,28 +607,10 @@ fn format_number(value: f64, digits: usize) -> String { } } -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - fn method(target: &JsValue, name: &str) -> Result { Reflect::get(target, &JsValue::from_str(name))?.into_function(name) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_motion_sample(sensors: &DeviceSensors) -> Result<(), JsValue> { for _ in 0_u32..100 { if sensors.has_motion() { @@ -637,19 +622,6 @@ async fn wait_for_motion_sample(sensors: &DeviceSensors) -> Result<(), JsValue> Err(JsValue::from_str("Timed out waiting for initial motion sample")) } -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} - async fn create_har_session(model_path: &str) -> Result { let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; let ort = Reflect::get(window.as_ref(), &JsValue::from_str("ort"))?; @@ -878,21 +850,5 @@ fn to_g(value: f64) -> f64 { value / STANDARD_GRAVITY } -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - #[cfg(test)] mod test_har1; diff --git a/services/ws-modules/llm1/src/lib.rs b/services/ws-modules/llm1/src/lib.rs index c12b4439..d9194f89 100644 --- a/services/ws-modules/llm1/src/lib.rs +++ b/services/ws-modules/llm1/src/lib.rs @@ -19,9 +19,9 @@ use std::cell::{Cell, RefCell}; use std::collections::VecDeque; use std::rc::Rc; -use et_web::{JsFunctionExt as _, JsPromiseExt as _, JsResultExt as _}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Array, Function, Object, Promise, Reflect}; +use et_web::{JsFunctionExt as _, JsPromiseExt as _, JsResultExt as _, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; +use js_sys::{Array, Function, Object, Reflect}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -611,18 +611,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } -/// Wait until the WebSocket client reports the connected state, or time out after ~10 seconds. -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _attempt in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - /// Wait until the server has acknowledged the connection with an agent id, or time out after ~10 seconds. async fn wait_for_agent_id(client: &WsClient) -> Result { for _attempt in 0_u32..100 { @@ -635,34 +623,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -/// Sleep for `duration_ms` via the window's timer, yielding to the browser event loop. -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - let _resolved = resolve.call0(&JsValue::NULL); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - let _rejected = reject.call1(&JsValue::NULL, &error); - } - }); - JsFuture::from(promise).await.map(et_web::ignore) -} - -/// Derive the WebSocket endpoint from the page's own origin (ws:// for http, wss:// for https). -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/math1-sender/src/lib.rs b/services/ws-modules/math1-sender/src/lib.rs index ccf04a4c..789de7f3 100644 --- a/services/ws-modules/math1-sender/src/lib.rs +++ b/services/ws-modules/math1-sender/src/lib.rs @@ -13,12 +13,10 @@ reason = "browser WASM module: JsFuture is !Send; module-local helpers are single-use by design" )] -use et_web::JsResultExt as _; -use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; -use js_sys::{Promise, Reflect}; +use et_web::{JsResultExt as _, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use tracing::info; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; /// The canonical input bytes, embedded from the committed file so there is one source of truth. const MATH1_INPUT_JSON: &str = include_str!(env!("ET_MATH1_INPUT_PATH")); @@ -96,16 +94,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { append_to_textarea("module-output", message) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_agent_id(client: &WsClient) -> Result { for _ in 0_u32..100 { let agent_id = client.get_agent_id(); @@ -116,28 +104,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { } Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, _reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - let _id: Result = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/math1/src/lib.rs b/services/ws-modules/math1/src/lib.rs index 13e7556c..2f3351e9 100644 --- a/services/ws-modules/math1/src/lib.rs +++ b/services/ws-modules/math1/src/lib.rs @@ -18,14 +18,12 @@ use std::cell::RefCell; use std::rc::Rc; -use et_web::JsResultExt as _; -use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea}; +use et_web::{JsResultExt as _, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, append_to_textarea, wait_for_connected}; use futures_util::StreamExt as _; -use js_sys::{Promise, Reflect}; use serde::Deserialize; use tracing::info; use wasm_bindgen::prelude::*; -use wasm_bindgen_futures::JsFuture; /// The canonical input: per-client (feature, target) samples plus the training hyperparameters. #[derive(Deserialize)] @@ -198,16 +196,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { append_to_textarea("module-output", message) } -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - async fn wait_for_agent_id(client: &WsClient) -> Result { for _ in 0_u32..100 { let agent_id = client.get_agent_id(); @@ -228,28 +216,3 @@ async fn wait_for_pointer(slot: &Rc>>) -> Result Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, _reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - let _id: Result = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms); - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/nfc/src/lib.rs b/services/ws-modules/nfc/src/lib.rs index d1d03e9f..844ef9e2 100644 --- a/services/ws-modules/nfc/src/lib.rs +++ b/services/ws-modules/nfc/src/lib.rs @@ -4,9 +4,8 @@ reason = "browser WASM module: JsFuture is !Send; module-local helpers like wait_for_* are single-use by design" )] -use et_web::{JsFunctionExt as _, JsPromiseExt as _}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsFunctionExt as _, JsPromiseExt as _, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -314,43 +313,3 @@ fn describe_js_error(error: &JsValue) -> String { .and_then(|json| json.as_string()) .unwrap_or_else(|| "Unknown error".to_string()) } - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/pic-viewer/src/lib.rs b/services/ws-modules/pic-viewer/src/lib.rs index f3a866ca..129543ce 100644 --- a/services/ws-modules/pic-viewer/src/lib.rs +++ b/services/ws-modules/pic-viewer/src/lib.rs @@ -20,8 +20,8 @@ use std::collections::VecDeque; use std::rc::Rc; use edge_toolkit::ws::ServerMessage; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -272,18 +272,6 @@ fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } -/// Wait until the WebSocket client reports the connected state, or time out after ~10 seconds. -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _attempt in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - /// Wait until the server has acknowledged the connection with an agent id, or time out after ~10 seconds. async fn wait_for_agent_id(client: &WsClient) -> Result { for _attempt in 0_u32..100 { @@ -296,34 +284,3 @@ async fn wait_for_agent_id(client: &WsClient) -> Result { Err(JsValue::from_str("Timed out waiting for assigned agent_id")) } - -/// Sleep for `duration_ms` via the window's timer, yielding to the browser event loop. -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - let _resolved = resolve.call0(&JsValue::NULL); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - let _rejected = reject.call1(&JsValue::NULL, &error); - } - }); - JsFuture::from(promise).await.map(et_web::ignore) -} - -/// Derive the WebSocket endpoint from the page's own origin (ws:// for http, wss:// for https). -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/speech-recognition/src/lib.rs b/services/ws-modules/speech-recognition/src/lib.rs index 5782ff92..2295d0cd 100644 --- a/services/ws-modules/speech-recognition/src/lib.rs +++ b/services/ws-modules/speech-recognition/src/lib.rs @@ -12,9 +12,8 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use et_web::JsFunctionExt as _; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsFunctionExt as _, describe_js_error, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -425,50 +424,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-modules/video1/src/lib.rs b/services/ws-modules/video1/src/lib.rs index 6ac6177e..2d6416e4 100644 --- a/services/ws-modules/video1/src/lib.rs +++ b/services/ws-modules/video1/src/lib.rs @@ -6,9 +6,9 @@ use std::cell::RefCell; -use et_web::{JsCastExt as _, get_media_devices}; -use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value}; -use js_sys::{Promise, Reflect}; +use et_web::{JsCastExt as _, describe_js_error, get_media_devices, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, set_textarea_value, wait_for_connected}; +use js_sys::Reflect; use serde_json::json; use tracing::info; use wasm_bindgen::prelude::*; @@ -205,50 +205,3 @@ fn log(message: &str) { fn set_module_status(message: &str) -> Result<(), JsValue> { set_textarea_value("module-output", message) } - -fn describe_js_error(error: &JsValue) -> String { - error - .as_string() - .or_else(|| js_sys::JSON::stringify(error).ok().map(String::from)) - .unwrap_or_else(|| format!("{error:?}")) -} - -async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { - for _ in 0_u32..100 { - if client.get_state() == "connected" { - return Ok(()); - } - sleep_ms(100).await?; - } - - Err(JsValue::from_str("Timed out waiting for websocket connection")) -} - -async fn sleep_ms(duration_ms: i32) -> Result<(), JsValue> { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let promise = Promise::new(&mut |resolve, reject| { - let callback = Closure::once_into_js(move || { - et_web::ignore(resolve.call0(&JsValue::NULL)); - }); - - if let Err(error) = - window.set_timeout_with_callback_and_timeout_and_arguments_0(callback.unchecked_ref(), duration_ms) - { - et_web::ignore(reject.call1(&JsValue::NULL, &error)); - } - }); - JsFuture::from(promise).await.map(|_| ()) -} - -fn websocket_url() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("No window available"))?; - let location = Reflect::get(window.as_ref(), &JsValue::from_str("location"))?; - let protocol = Reflect::get(&location, &JsValue::from_str("protocol"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.protocol is unavailable"))?; - let host = Reflect::get(&location, &JsValue::from_str("host"))? - .as_string() - .ok_or_else(|| JsValue::from_str("window.location.host is unavailable"))?; - let ws_protocol = if protocol == "https:" { "wss:" } else { "ws:" }; - Ok(format!("{ws_protocol}//{host}/ws")) -} diff --git a/services/ws-wasm-agent/src/lib.rs b/services/ws-wasm-agent/src/lib.rs index 8b16a802..0bbeae96 100644 --- a/services/ws-wasm-agent/src/lib.rs +++ b/services/ws-wasm-agent/src/lib.rs @@ -824,3 +824,19 @@ pub fn append_to_textarea(element_id: &str, message: &str) -> Result<(), JsValue Ok(()) } + +/// Poll `client` until it reports `connected`, for up to ten seconds. +#[expect( + clippy::future_not_send, + reason = "awaits et_web::sleep_ms, whose JsFuture is Rc-backed and never Send; single-threaded browser WASM" +)] +pub async fn wait_for_connected(client: &WsClient) -> Result<(), JsValue> { + for _ in 0_u32..100 { + if client.get_state() == "connected" { + return Ok(()); + } + et_web::sleep_ms(100).await?; + } + + Err(JsValue::from_str("Timed out waiting for websocket connection")) +} diff --git a/services/ws-wasm-agent/tests/web.rs b/services/ws-wasm-agent/tests/web.rs index f9335ba0..ac6346d8 100644 --- a/services/ws-wasm-agent/tests/web.rs +++ b/services/ws-wasm-agent/tests/web.rs @@ -1,9 +1,10 @@ #![cfg(test)] #![cfg(target_arch = "wasm32")] -use et_ws_wasm_agent::{WsClient, WsClientConfig}; -use js_sys::Promise; -use wasm_bindgen_futures::JsFuture; +use et_web::{describe_js_error, sleep_ms, websocket_url}; +use et_ws_wasm_agent::{WsClient, WsClientConfig, wait_for_connected}; +use js_sys::{Object, Reflect}; +use wasm_bindgen::JsValue; use wasm_bindgen_test::*; wasm_bindgen_test_configure!(run_in_browser); @@ -13,20 +14,92 @@ async fn test_websocket_connection() { let config = WsClientConfig::new("ws://127.0.0.1:8080/ws".to_string()); let mut client = WsClient::new(config); - // Connect to server let result = client.connect(); assert!(result.is_ok(), "Client should initiate connection without errors"); - // Give it a second to actually connect - let promise = Promise::new(&mut |resolve, _| { - let window = web_sys::window().unwrap(); - let _timeout_id = window - .set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, 1000) - .unwrap(); - }); - let _resolved = JsFuture::from(promise).await.unwrap(); - - // Assert connection state is successfully connected or at least it didn't fail - let state = client.get_state(); - assert_eq!(state, "connected", "Client should be connected to the server after 1s"); + wait_for_connected(&client) + .await + .expect("client should reach the connected state against the live cov-server"); + + assert_eq!( + client.get_state(), + "connected", + "Client should be connected to the server" + ); +} + +/// A dead endpoint must exhaust the poll loop rather than report success. +/// +/// Nothing listens on 45123, so the client never reaches the connected state and `wait_for_connected` spends its +/// full ~10s budget before giving up. That wait is the point: the timeout arm is the only path returning `Err`. +/// The port is deliberately high rather than something like 1 or 9, which browsers refuse outright as blocked +/// ports -- `new WebSocket()` would throw before a connection was ever attempted, testing the wrong thing. +#[wasm_bindgen_test] +async fn wait_for_connected_times_out_on_a_dead_endpoint() { + let config = WsClientConfig::new("ws://127.0.0.1:45123/ws".to_string()); + let mut client = WsClient::new(config); + let _connect = client.connect(); + + let outcome = wait_for_connected(&client).await; + assert!(outcome.is_err(), "a dead endpoint must never report connected"); +} + +/// `sleep_ms` resolves through `window.setTimeout` rather than hanging or rejecting. +#[wasm_bindgen_test] +async fn sleep_ms_resolves() { + sleep_ms(10).await.expect("window.setTimeout should resolve the sleep"); +} + +/// The websocket endpoint is derived from the page's own location. +#[wasm_bindgen_test] +fn websocket_url_derives_the_endpoint_from_the_page() { + let url = websocket_url().expect("a browser page always has window.location"); + + assert!( + url.starts_with("ws://") || url.starts_with("wss://"), + "expected a websocket scheme, got {url}" + ); + assert!(url.ends_with("/ws"), "expected the /ws endpoint path, got {url}"); +} + +/// A string error is described as itself, without going near `JSON.stringify`. +#[wasm_bindgen_test] +fn describe_js_error_uses_the_string_form() { + let error = JsValue::from_str("plain string error"); + + assert_eq!(describe_js_error(&error), "plain string error"); +} + +/// A non-string error falls back to `JSON.stringify`. +#[wasm_bindgen_test] +fn describe_js_error_falls_back_to_json() { + let error = Object::new(); + let _set = Reflect::set(error.as_ref(), &JsValue::from_str("code"), &JsValue::from_f64(7.0)) + .expect("setting a property on a fresh object cannot fail"); + + let described = describe_js_error(error.as_ref()); + + assert!( + described.contains("code"), + "expected the stringified key, got {described}" + ); + assert!( + described.contains('7'), + "expected the stringified value, got {described}" + ); +} + +/// A cyclic error makes `JSON.stringify` throw, leaving the `Debug` rendering as the last resort. +#[wasm_bindgen_test] +fn describe_js_error_falls_back_to_debug_when_json_throws() { + let error = Object::new(); + let _set = Reflect::set(error.as_ref(), &JsValue::from_str("self"), error.as_ref()) + .expect("setting a property on a fresh object cannot fail"); + + let described = describe_js_error(error.as_ref()); + + assert!( + !described.is_empty(), + "the Debug fallback must still describe the error" + ); } From 1d44e8ea44ac9766576e44f465fbae4e659960e6 Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 2 Sep 2026 19:14:26 +0800 Subject: [PATCH 2/3] try to get missing coverate --- .mise/config.coverage.toml | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index 6ec56603..d6f8f62f 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -256,20 +256,24 @@ description = "Coverage for the browser ws-wasm-agent: run its wasm-bindgen test # The .profraw -> lcov conversion mirrors wasm-cov (gut every .ll body to `unreachable`, keep the covmap, llc to a # fixed x86_64 ELF object, then llvm-cov), with one addition: the test binary links several workspace crates and # the agent's generic message helpers monomorphize into the test crate's object, so we compile every workspace -# .ll (not one) and keep only the agent lib's records. Gutting can leave an attribute group empty, which llc -# rejects, so emptied groups get a benign `nounwind`. +# .ll (not one) and keep only the records of the two crates these tests actually drive -- the agent lib and +# et-web. Gutting can leave an attribute group empty, which llc rejects, so emptied groups get a benign +# `nounwind`. run = """ covdir=target/agent-cov coreutils mkdir -p "$covdir" coreutils rm -f "$covdir"/*.profraw "$covdir"/*.o "$covdir"/*.g.ll "$covdir/server-ready" -# Force the agent crate (and its wasm test binary) to recompile so this run emits a fresh instrumented .ll. -# Its covmap must match the .profraw we are about to capture, but cargo only re-emits `--emit=llvm-ir` when a -# crate actually compiles, so on a warm cache a stale or missing .ll would otherwise make llvm-cov drop the -# agent's functions (a bare `cargo test` cache hit yields only the generic helpers' covmap, which monomorphize -# into the test crate's object). Only the agent's .ll is needed; other workspace crates' .ll are dropped by the -# lib-only keep filter below whether fresh, stale, or absent. +# Force the agent and et-web crates to recompile so this run emits fresh instrumented .ll for both. +# A covmap must match the .profraw we are about to capture, but cargo only re-emits `--emit=llvm-ir` when a crate +# actually compiles, so on a warm cache a stale or missing .ll would make llvm-cov drop that crate's functions (a +# bare `cargo test` cache hit yields only the generic helpers' covmap, which monomorphize into the test crate's +# object). et-web is cleaned alongside the agent because tests/web.rs exercises its browser helpers directly -- +# `websocket_url` and `describe_js_error` have no other route to coverage, since they run only in a browser and +# the ws-modules guests reach them through paths the runner tests never take. Every other workspace crate's .ll +# is still dropped by the keep filters below, fresh or stale. rustup target add wasm32-unknown-unknown cargo clean -p et-ws-wasm-agent --target wasm32-unknown-unknown +cargo clean -p et-web --target wasm32-unknown-unknown # Resolve the webdriver, honouring a CHROMEDRIVER already in the environment before the mise-pinned one. # CI points CHROMEDRIVER at the runner's chromedriver (matched to its preinstalled Chrome); locally this falls @@ -378,6 +382,10 @@ coreutils cat >"$keep" <<'AWK' AWK coreutils touch lcov.info goawk -v want="ws-wasm-agent/src/" -f "$keep" "$covdir/all.lcov" >>lcov.info +# Second pass for et-web, whose browser helpers tests/web.rs drives. +# One `want` substring per pass, matching the cov-server fold below, rather than a combined pattern the filter +# cannot express. +goawk -v want="libs/web/src/" -f "$keep" "$covdir/all.lcov" >>lcov.info # Fold cov-server's own native coverage in from the instrumented run above (its cov-server-*.profraw). # The export object links its whole dep tree, so keep only the cov-server.rs launcher records. From 0b8ba7ac8f2d9c05134292e85d632262c371d4bb Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Wed, 2 Sep 2026 20:47:31 +0800 Subject: [PATCH 3/3] try again --- .mise/config.coverage.toml | 6 +++--- .mise/config.toml | 8 ++++---- .mise/mise.lock | 9 +++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.mise/config.coverage.toml b/.mise/config.coverage.toml index d6f8f62f..17bddf10 100644 --- a/.mise/config.coverage.toml +++ b/.mise/config.coverage.toml @@ -243,8 +243,8 @@ UV_PYTHON = "{% if os() == 'windows' %}{{ vars.py3_win }}{% else %}{{ vars.py3_u depends = ["build-wasm-cov-wrapper"] description = "Coverage for the browser ws-wasm-agent: run its wasm-bindgen tests headless, emit lcov into lcov.info" # The agent is a browser wasm client with no native tests, so its lib is invisible to the cargo-llvm-cov run. -# This drives its wasm-bindgen tests (tests/client.rs) in a real headless Chrome and folds the result into the -# same lcov.info the other coverage tasks feed. Two pieces make it work: +# This drives its wasm-bindgen tests (tests/client.rs and tests/web.rs) in a real headless Chrome and folds the +# result into the same lcov.info the other coverage tasks feed. Two pieces make it work: # 1. A live backend. tests/client.rs covers the offline paths on its own, but the connected paths (onopen, # the connect-ack dispatch, the online send/flush/keepalive) need a server, so we start the in-process # et-ws-test-server (its cov-server bin) on the fixed port the tests dial, wait for its readiness marker, @@ -325,7 +325,7 @@ export RUSTFLAGS="--cfg wasm_bindgen_unstable_test_coverage" export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER="$runner" export CHROMEDRIVER="$driver" export LLVM_PROFILE_FILE="{{ config_root }}/$covdir/wasm-agent-%p.profraw" -cargo test -p et-ws-wasm-agent --features coverage --target wasm32-unknown-unknown --test client +cargo test -p et-ws-wasm-agent --features coverage --target wasm32-unknown-unknown --test client --test web # Remove the marker so cov-server returns from main and flushes its coverage, then reap it (a kill would drop it). coreutils rm -f "$covdir/server-ready" diff --git a/.mise/config.toml b/.mise/config.toml index f8f3fcd4..3e718a96 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -344,13 +344,13 @@ url = "https://github.com/rustfs/rustfs/releases/download/{{version}}/rustfs-win [tools."http:chromedriver"] bin = "chromedriver" os = ["linux/x64", "macos"] -version = "150.0.7871.124" +version = "152.0.7977.75" [tools."http:chromedriver".platforms.linux-x64] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/linux64/chromedriver-linux64.zip" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/linux64/chromedriver-linux64.zip" [tools."http:chromedriver".platforms.macos-arm64] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-arm64/chromedriver-mac-arm64.zip" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/mac-arm64/chromedriver-mac-arm64.zip" [tools."http:chromedriver".platforms.macos-x64] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-x64/chromedriver-mac-x64.zip" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/mac-x64/chromedriver-mac-x64.zip" [tools."http:augeas"] bin = "augtool" diff --git a/.mise/mise.lock b/.mise/mise.lock index d8811bc2..4849a86a 100644 --- a/.mise/mise.lock +++ b/.mise/mise.lock @@ -1133,17 +1133,18 @@ checksum = "sha256:6d97229c53cc0af09635bfd85b15169704ab9c07d5e23090252ac80746260 url = "https://github.com/edge-toolkit/core/releases/download/augeas-v1/1.14.1-x86_64-pc-windows-mingw.tar.gz" [[tools."http:chromedriver"]] -version = "150.0.7871.124" +version = "152.0.7977.75" backend = "http:chromedriver" [tools."http:chromedriver"."platforms.linux-x64"] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/linux64/chromedriver-linux64.zip" +checksum = "blake3:8b39e7bc80d0aa31a7f0527fdba86721ecfdf7198bf88b9d4a83e5ad005915b4" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/linux64/chromedriver-linux64.zip" [tools."http:chromedriver"."platforms.macos-arm64"] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-arm64/chromedriver-mac-arm64.zip" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/mac-arm64/chromedriver-mac-arm64.zip" [tools."http:chromedriver"."platforms.macos-x64"] -url = "https://storage.googleapis.com/chrome-for-testing-public/150.0.7871.124/mac-x64/chromedriver-mac-x64.zip" +url = "https://storage.googleapis.com/chrome-for-testing-public/152.0.7977.75/mac-x64/chromedriver-mac-x64.zip" [[tools."http:et-rp"]] version = "08a1d6f"