feat(skills): Add wasm-device-test skill for on-device WASM testing - #23931
feat(skills): Add wasm-device-test skill for on-device WASM testing#23931MartinZikmund wants to merge 2 commits into
Conversation
Publishing a WebAssembly head and opening it on a phone is awkward today: dotnet run serves loopback only, and python -m http.server both binds loopback and does not know the .wasm MIME type, so the browser cannot stream-instantiate the module. The skill publishes Release by default, since a Debug WebAssembly build is dominated by interpreter overhead and any conclusion about responsiveness drawn from it is meaningless, then serves the published wwwroot on all interfaces with the .NET WebAssembly MIME types registered and Cache-Control: no-store so a republish is picked up without clearing the service worker. It also verifies the LAN address answers before handing over a URL, since a blocking host firewall is the usual failure and is otherwise indistinguishable from the app not working.
|
Claude finished @MartinZikmund's task in 3m 46s —— View job PR Review
SummaryThis is contributor-tooling only — no product code is touched — and the overall design is sound: the rationale for the skill (bridging the LAN/MIME-type gap between
Findings:
No product code concerns (this is CI/tooling only): no async, no C#, no DependencyProperty patterns, no XAML. |
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds a new contributor workflow skill (/wasm-device-test) to publish an Uno WebAssembly head and serve the published wwwroot over the LAN for manual on-device testing (phones/tablets), plus a small Python static server with WASM-related MIME types and Cache-Control: no-store.
Changes:
- Register
/wasm-device-testinAGENTS.md. - Add the new skill definition/instructions under
.claude/skills/wasm-device-test/. - Add a Python server script to serve published WASM output with correct MIME types and caching headers.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
| AGENTS.md | Adds the /wasm-device-test skill to the documented skills table (but introduces a UTF-8 BOM on the first line). |
| .claude/skills/wasm-device-test/SKILL.md | Introduces the skill workflow (publish → serve → verify → report), including command examples and limitations. |
| .claude/skills/wasm-device-test/scripts/serve-wasm.py | Adds a threaded static file server that binds to 0.0.0.0, pins WASM-related MIME types, and disables caching via response headers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -1,4 +1,4 @@ | |||
| # AGENTS.md | |||
| # AGENTS.md | |||
| def main(): | ||
| if len(sys.argv) < 2: | ||
| print(__doc__) | ||
| return 1 | ||
|
|
||
| root = sys.argv[1] | ||
| port = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT | ||
|
|
| This exists because the two obvious approaches both fail for device testing: | ||
|
|
||
| - `dotnet run` serves on `localhost` only — a phone cannot reach it. | ||
| - `python -m http.server` binds loopback only *and* does not know the `.wasm` MIME type, so the | ||
| browser cannot use `WebAssembly.instantiateStreaming`. |
| `python -m http.server` almost works, but two of its defaults break this use case: | ||
|
|
||
| * it binds loopback only, so a phone on the same network cannot reach it; | ||
| * it does not know the `.wasm` MIME type, so the browser refuses | ||
| `WebAssembly.instantiateStreaming` and falls back to a slower path (or fails outright, | ||
| depending on the browser). |
| ### Phase 2: Serve | ||
|
|
||
| ```bash | ||
| python scripts/serve-wasm.py <wwwroot> [port] |
| @@ -1,4 +1,4 @@ | |||
| # AGENTS.md | |||
| # AGENTS.md | |||
There was a problem hiding this comment.
A UTF-8 BOM (\xEF\xBB\xBF) was added to this file — the diff shows # AGENTS.md replacing # AGENTS.md. This is an unintentional editor artifact and should be reverted. A BOM in Markdown can confuse tools that parse the first line (e.g. heading extractors, CI scripts, some renderers). Please strip it before merging.
| ### Phase 2: Serve | ||
|
|
||
| ```bash | ||
| python scripts/serve-wasm.py <wwwroot> [port] |
There was a problem hiding this comment.
The path scripts/serve-wasm.py is relative to an unspecified working directory. When Claude executes this command from the repository root (the typical CWD), it will fail with "No such file or directory". Use the full repo-relative path so the command is unambiguous regardless of CWD:
| python scripts/serve-wasm.py <wwwroot> [port] | |
| python .claude/skills/wasm-device-test/scripts/serve-wasm.py <wwwroot> [port] |
| _base_end_headers = SimpleHTTPRequestHandler.end_headers | ||
|
|
||
| def end_headers(self): | ||
| self.send_header("Cache-Control", "no-store, must-revalidate") | ||
| Handler._base_end_headers(self) |
There was a problem hiding this comment.
The _base_end_headers class-variable trick is non-idiomatic Python 3 and fragile: if Handler were ever subclassed, Handler._base_end_headers would still bind to SimpleHTTPRequestHandler.end_headers, bypassing the intermediate class. The idiomatic form is super():
| _base_end_headers = SimpleHTTPRequestHandler.end_headers | |
| def end_headers(self): | |
| self.send_header("Cache-Control", "no-store, must-revalidate") | |
| Handler._base_end_headers(self) | |
| def end_headers(self): | |
| self.send_header("Cache-Control", "no-store, must-revalidate") | |
| super().end_headers() |
The class variable _base_end_headers on line 40 can then be removed entirely.
| """ | ||
| addresses = [] | ||
| try: | ||
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): |
There was a problem hiding this comment.
socket.getaddrinfo(socket.gethostname(), …) resolves via DNS//etc/hosts, not by enumerating network interfaces. On Ubuntu/Debian — the most common Linux dev machine — the hostname typically resolves to 127.0.1.1 (a loopback alias), which is then filtered out, leaving _lan_addresses() returning an empty list. The script would print only the localhost URL, never the "other devices" URL — exactly the failure mode this skill exists to avoid.
A reliable cross-platform fallback is to use a SOCK_DGRAM connect (no packets are sent):
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): | |
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): | |
| address = info[4][0] | |
| if not address.startswith(("127.", "169.254.")) and address not in addresses: | |
| addresses.append(address) | |
| # Fallback: hostname resolution often returns a loopback alias on Ubuntu/Debian. | |
| # A SOCK_DGRAM connect reveals the OS-preferred outbound address without sending packets. | |
| if not addresses: | |
| with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: | |
| s.connect(("8.8.8.8", 80)) | |
| addresses.append(s.getsockname()[0]) |
This gives at least the primary outbound address even when the hostname approach fails.
| def log_message(self, fmt, *args): | ||
| # One line per asset is thousands of lines for a single page load. | ||
| pass |
There was a problem hiding this comment.
Silencing all HTTP log output trades noise for debuggability. When a device can't connect, the first question is "did the request ever reach the server?" — and right now there's no way to answer that. Consider logging only errors (4xx/5xx) or connections from non-loopback addresses, which keeps output manageable while preserving the signal that matters:
| def log_message(self, fmt, *args): | |
| # One line per asset is thousands of lines for a single page load. | |
| pass | |
| def log_message(self, fmt, *args): | |
| # Suppress per-asset lines; only surface client errors so connection failures are visible. | |
| if args and str(args[1]).startswith(("4", "5")): | |
| super().log_message(fmt, *args) |
|
Claude finished @MartinZikmund's task in 4m 27s —— View job PR Review
SummaryThe latest commit (
Additionally, No product code is touched; all issues are in contributor tooling only. The design rationale and workflow are sound. Fix-first verdict on the four items above before merging. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (6)
AGENTS.md:1
- The first line now includes a UTF-8 BOM ("\ufeff"), which creates an encoding-only diff and can cause noisy churn in future edits. If the BOM wasn’t intentional, revert to a plain
# AGENTS.mdheader to keep the file encoding consistent.
# AGENTS.md
.claude/skills/wasm-device-test/SKILL.md:25
- This section says
python -m http.server"binds loopback only", but the repo’s own WASM runtime-test script invokes it without--bind(which binds all interfaces by default). This reads as misleading; the real blocker is the missing.wasmMIME type and caching behavior.
- `dotnet run` serves on `localhost` only — a phone cannot reach it.
- `python -m http.server` binds loopback only *and* does not know the `.wasm` MIME type, so the
browser cannot use `WebAssembly.instantiateStreaming`.
.claude/skills/wasm-device-test/SKILL.md:82
- The serve command uses
python scripts/serve-wasm.py, but the script is stored under.claude/skills/wasm-device-test/scripts/serve-wasm.py. Unless the working directory is changed earlier, this command won’t resolve from repo root.
python scripts/serve-wasm.py <wwwroot> [port]
.claude/skills/wasm-device-test/scripts/serve-wasm.py:75
port = int(sys.argv[2])will raiseValueErrorfor non-numeric input and print a traceback. This is a user-facing script; it should fail with a short message + usage instead of an exception.
root = sys.argv[1]
port = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_PORT
.claude/skills/wasm-device-test/SKILL.md:114
- The skill text claims the script prints “every non-loopback IPv4 address it can find” and that it “usually includes VPN, WSL, and Hyper-V adapters”. The implementation currently derives addresses via
socket.getaddrinfo(gethostname()), which may omit interface addresses on some machines and doesn’t necessarily enumerate adapters. Consider softening this wording to match the best-effort behavior.
The script prints every non-loopback IPv4 address it can find, which on a developer machine usually
includes VPN, WSL, and Hyper-V adapters that a phone cannot reach. Probe them and report the one
that answered, rather than listing all of them.
.claude/skills/wasm-device-test/SKILL.md:148
- Secure-context restrictions are broader than “service workers on some browsers”: service workers generally require HTTPS (or
localhost). Over a plain LANhttp://<ip>origin, service workers won’t register, which also changes the caching story. Suggest tightening this wording to avoid sending readers down a debugging rabbit hole.
- **HTTP only.** Browser APIs gated on a secure context (clipboard, geolocation, service workers on
some browsers, `SharedArrayBuffer`) will not work over a plain LAN address. If the user needs
those, they need HTTPS with a trusted certificate or a tunnel; say so rather than debugging the
| @@ -1,4 +1,4 @@ | |||
| # AGENTS.md | |||
| # AGENTS.md | |||
There was a problem hiding this comment.
A UTF-8 BOM (\xEF\xBB\xBF) was added to this file — the diff shows # AGENTS.md replacing # AGENTS.md. This is an editor artifact that can break heading-extraction tools, CI scripts, and some Markdown renderers that parse the first line. Please strip the BOM before merging.
| # AGENTS.md | |
| # AGENTS.md |
| ### Phase 2: Serve | ||
|
|
||
| ```bash | ||
| python scripts/serve-wasm.py <wwwroot> [port] |
There was a problem hiding this comment.
The path scripts/serve-wasm.py is relative to an unspecified CWD. When Claude executes this from the repository root (the normal CWD), it will fail with "No such file or directory". Use the full repo-relative path:
| python scripts/serve-wasm.py <wwwroot> [port] | |
| python .claude/skills/wasm-device-test/scripts/serve-wasm.py <wwwroot> [port] |
|
|
||
| def end_headers(self): | ||
| self.send_header("Cache-Control", "no-store, must-revalidate") | ||
| Handler._base_end_headers(self) |
There was a problem hiding this comment.
Non-idiomatic Python 3. The class-variable approach is fragile: if Handler is ever subclassed, Handler._base_end_headers still binds to SimpleHTTPRequestHandler.end_headers, bypassing the intermediate class. Use super() and remove the _base_end_headers class variable on line 40:
| Handler._base_end_headers(self) | |
| super().end_headers() |
|
|
||
| def log_message(self, fmt, *args): | ||
| # One line per asset is thousands of lines for a single page load. | ||
| pass |
There was a problem hiding this comment.
Silencing all HTTP log output removes the one observable signal for diagnosing connection failures. When a phone can't reach the server, the first question is "did any request arrive?" — and right now there's no way to answer that. Consider surfacing only client errors (4xx/5xx):
| pass | |
| if args and str(args[1]).startswith(("4", "5")): | |
| super().log_message(fmt, *args) |
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): | ||
| address = info[4][0] | ||
| if not address.startswith(("127.", "169.254.")) and address not in addresses: | ||
| addresses.append(address) | ||
| except socket.gaierror: | ||
| pass |
There was a problem hiding this comment.
socket.getaddrinfo(socket.gethostname(), …) resolves via DNS//etc/hosts, not by enumerating network interfaces. On Ubuntu/Debian — the most common Linux dev machine — the hostname typically resolves to 127.0.1.1 (a loopback alias), which is then filtered out, leaving _lan_addresses() returning an empty list. The "other devices" URL would never be printed, defeating the primary purpose of the skill.
A reliable cross-platform fallback is a SOCK_DGRAM connect (no packets are sent):
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): | |
| address = info[4][0] | |
| if not address.startswith(("127.", "169.254.")) and address not in addresses: | |
| addresses.append(address) | |
| except socket.gaierror: | |
| pass | |
| for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): | |
| address = info[4][0] | |
| if not address.startswith(("127.", "169.254.")) and address not in addresses: | |
| addresses.append(address) | |
| except socket.gaierror: | |
| pass # hostname resolution may fail; the SOCK_DGRAM fallback below covers this case | |
| # Fallback: hostname resolution returns a loopback alias on Ubuntu/Debian. | |
| # A SOCK_DGRAM connect reveals the OS-preferred outbound address without sending packets. | |
| if not addresses: | |
| try: | |
| with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: | |
| s.connect(("8.8.8.8", 80)) | |
| addresses.append(s.getsockname()[0]) | |
| except OSError: | |
| pass # no route to outside; caller prints nothing for "other devices" |
|
🤖 Your WebAssembly Skia Sample App stage site is ready! Visit it here: https://unowasmprstaging.z20.web.core.windows.net/pr-23931/wasm-skia-net9/index.html |
|
🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-23931/docs/index.html |
|
|
GitHub Issue: closes #23930
PR Type:
🤖 Project automation
What changed? 🚀
Adds a
/wasm-device-testskill that publishes an Uno Platform WebAssembly head and serves the published output on the local network, so it can be opened on a phone, tablet, or another machine.Neither existing option works for this:
dotnet runserves onlocalhostonly, so a device cannot reach it.python -m http.server(used bybuild/test-scripts/wasm-run-skia-runtime-tests.sh) binds loopback only, and does not register the.wasmMIME type, so the browser cannot useWebAssembly.instantiateStreaming.The skill covers the whole loop — publish, serve, verify, report:
Cache-Control: no-storeso a republished app is picked up without clearing the service worker.New-NetFirewallRulecommand rather than silently elevating.It documents the limitations that bite in practice: HTTP only (so secure-context APIs are unavailable), AP isolation on guest and corporate Wi-Fi, and that this is for driving an app by hand — automated WASM runtime tests remain the
/runtime-testsskill's job.Contributor tooling only; no product code is touched.
AGENTS.mdgains a row in the skills table.Verification
The bundled
scripts/serve-wasm.pywas exercised against a publishedSamplesApp.Skia.WebAssembly.Browseroutput:index.html→ 200 on bothlocalhostand the LAN address_framework/*.wasm→Content-Type: application/wasmCache-Control: no-store, must-revalidatepresentPR Checklist ✅
AGENTS.mdScreenshots Compare Test Runresults — N/A, no product code