Skip to content

feat(skills): Add wasm-device-test skill for on-device WASM testing - #23931

Open
MartinZikmund wants to merge 2 commits into
masterfrom
dev/mazi/wasm-lan-skill
Open

feat(skills): Add wasm-device-test skill for on-device WASM testing#23931
MartinZikmund wants to merge 2 commits into
masterfrom
dev/mazi/wasm-lan-skill

Conversation

@MartinZikmund

Copy link
Copy Markdown
Member

GitHub Issue: closes #23930

PR Type:

🤖 Project automation

What changed? 🚀

Adds a /wasm-device-test skill 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 run serves on localhost only, so a device cannot reach it.
  • python -m http.server (used by build/test-scripts/wasm-run-skia-runtime-tests.sh) binds loopback only, and does not register the .wasm MIME type, so the browser cannot use WebAssembly.instantiateStreaming.

The skill covers the whole loop — publish, serve, verify, report:

  • Publishes Release by default. A Debug WebAssembly build is dominated by interpreter overhead, so any conclusion about responsiveness or frame rate drawn from it is meaningless. Debug is opt-in.
  • Serves on all interfaces with the .NET WebAssembly MIME types registered, and Cache-Control: no-store so a republished app is picked up without clearing the service worker.
  • Probes the LAN address before reporting a URL. A blocking host firewall is the usual failure and is otherwise indistinguishable from the app not loading. The skill reports the address that actually answered rather than listing every adapter, and gives the New-NetFirewallRule command 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-tests skill's job.

Contributor tooling only; no product code is touched. AGENTS.md gains a row in the skills table.

Verification

The bundled scripts/serve-wasm.py was exercised against a published SamplesApp.Skia.WebAssembly.Browser output:

  • index.html → 200 on both localhost and the LAN address
  • _framework/*.wasmContent-Type: application/wasm
  • Cache-Control: no-store, must-revalidate present
  • the app was loaded and driven by hand from a phone browser on the same network

PR Checklist ✅

  • 🧪 Added Runtime tests, UI tests, or a manual test sample (for bug fixes / features, if applicable) — N/A, contributor tooling; the script was validated manually as described above
  • 📚 Docs have been added/updated following the documentation template (for bug fixes / features) — the skill is self-documenting and is registered in AGENTS.md
  • 🖼️ Validated PR Screenshots Compare Test Run results — N/A, no product code
  • ❗ Contains NO breaking changes
  • 👀 Reviewed 2 other open pull requests (optional but appreciated!)

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.
Copilot AI review requested due to automatic review settings July 31, 2026 11:39
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 3m 46s —— View job


PR Review

  • Gather context and read changed files
  • Review SKILL.md for correctness and completeness
  • Review serve-wasm.py for correctness, security, and robustness
  • Review AGENTS.md change
  • Post inline comments
  • Post summary

Summary

This 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 dotnet run and python -m http.server) is clear, the workflow phases are logical, and the Python server is minimal and correct in its core logic. Four findings to address before merging:

Severity Count
Bug (unintentional artifact) 1
Reliability 1
Code quality 2

Findings:

  1. AGENTS.md BOM — A UTF-8 BOM was accidentally prepended to AGENTS.md. Tooling that reads the first line for heading detection can misparse it. Should be stripped.

  2. _lan_addresses() returns empty on Ubuntu/Debiansocket.getaddrinfo(socket.gethostname(), …) on most Ubuntu machines resolves the hostname to 127.0.1.1 (a loopback alias), which is then filtered out. The script prints no "other devices" URL on the most common Linux dev machine, defeating the primary purpose of the skill. A SOCK_DGRAM fallback would fix this reliably across platforms.

  3. Ambiguous script path in SKILL.mdpython scripts/serve-wasm.py uses a relative path with no stated CWD context; running from the repo root (the agent's typical CWD) will fail. Should be .claude/skills/wasm-device-test/scripts/serve-wasm.py.

  4. Non-idiomatic end_headers override_base_end_headers = SimpleHTTPRequestHandler.end_headers followed by Handler._base_end_headers(self) is fragile. super().end_headers() is the Python 3 idiomatic form and is not susceptible to subclass MRO issues. Also, silencing all HTTP log output removes the one signal that shows whether a device ever reached the server — logging only 4xx/5xx would be much more useful for debugging connection failures.

No product code concerns (this is CI/tooling only): no async, no C#, no DependencyProperty patterns, no XAML.

Comment thread .claude/skills/wasm-device-test/scripts/serve-wasm.py Dismissed
Comment thread .claude/skills/wasm-device-test/scripts/serve-wasm.py Fixed
@MartinZikmund
MartinZikmund enabled auto-merge July 31, 2026 11:41
Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-test in AGENTS.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.

Comment thread AGENTS.md
@@ -1,4 +1,4 @@
# AGENTS.md
# AGENTS.md
Comment on lines +68 to +75
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

Comment on lines +21 to +25
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`.
Comment on lines +3 to +8
`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]
Copilot AI review requested due to automatic review settings July 31, 2026 11:41
Comment thread AGENTS.md
@@ -1,4 +1,4 @@
# AGENTS.md
# AGENTS.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
python scripts/serve-wasm.py <wwwroot> [port]
python .claude/skills/wasm-device-test/scripts/serve-wasm.py <wwwroot> [port]

Comment on lines +40 to +44
_base_end_headers = SimpleHTTPRequestHandler.end_headers

def end_headers(self):
self.send_header("Cache-Control", "no-store, must-revalidate")
Handler._base_end_headers(self)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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():

Suggested change
_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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
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.

Comment on lines +46 to +48
def log_message(self, fmt, *args):
# One line per asset is thousands of lines for a single page load.
pass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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)

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @MartinZikmund's task in 4m 27s —— View job


PR Review

  • Read changed files
  • Review AGENTS.md change
  • Review SKILL.md
  • Review serve-wasm.py
  • Post inline comments
  • Post summary

Summary

The latest commit (b47a6e39) addressed the except KeyboardInterrupt: pass finding (added an explanatory comment and explicit return 0). The remaining four issues from the prior review are still unresolved:

# Severity File Finding
1 Bug AGENTS.md:1 UTF-8 BOM prepended — strip it
2 Reliability serve-wasm.py:59–64 _lan_addresses() returns empty on Ubuntu/Debian (hostname resolves to 127.0.1.1); add a SOCK_DGRAM fallback
3 Bug SKILL.md:82 Script path scripts/serve-wasm.py is relative to an unstated CWD — will fail from repo root; use the full path
4 Code quality serve-wasm.py:40,44 Handler._base_end_headers class-variable trick is non-idiomatic; replace with super().end_headers()

Additionally, log_message silences all output (inline comment on line 48). When a device can't connect, there is currently no way to verify whether any request ever reached the server — surfacing 4xx/5xx would preserve the useful signal without the noise.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md header 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 .wasm MIME 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 raise ValueError for 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 LAN http://<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

Comment thread AGENTS.md
@@ -1,4 +1,4 @@
# AGENTS.md
# AGENTS.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# AGENTS.md
# AGENTS.md

### Phase 2: Serve

```bash
python scripts/serve-wasm.py <wwwroot> [port]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
pass
if args and str(args[1]).startswith(("4", "5")):
super().log_message(fmt, *args)

Comment on lines +59 to +64
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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):

Suggested change
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"

@unodevops

Copy link
Copy Markdown
Contributor

🤖 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

@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-23931/docs/index.html

@unodevops

Copy link
Copy Markdown
Contributor

⚠️⚠️ The build 225210 has failed on Uno.UI - CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a skill for testing WebAssembly apps on physical devices over the LAN

3 participants