test(napplet): add on-device test harness (napplet + publish script)

A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.

publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
This commit is contained in:
Claude
2026-06-22 15:04:28 +00:00
parent df15414424
commit b0332c9ff3
3 changed files with 325 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# Napplet test harness
A self-contained napplet for **on-device verification** of Amethyst's NIP-5D host — it calls every
`window.napplet.*` API and shows each result on screen, so you can confirm the whole
shim → shell → broker → consent round-trip (and the newer `identity.getList/getZaps/getBadges`,
`identity.onChanged`, `keys.onAction`, and `resource.bytes` `nostr:` paths) works on a real device.
## Files
- `index.html` — the napplet. Read-only checks run on load; publish/upload/pay are behind buttons;
live pushes (`identity.changed`, `keys.action`) land in the top banner.
- `publish.sh` — uploads `index.html` to a Blossom server and publishes the napplet event.
## Prerequisites
- [`nak`](https://github.com/fiatjaf/nak) (signs + publishes the events), `curl`, and `sha256sum`
(or `shasum`/`openssl`).
- A Blossom server that accepts BUD-02 uploads (e.g. `https://blossom.primal.net`,
`https://cdn.satellite.earth`, or your own).
- Your **nsec** — use the **same key you're logged in as in Amethyst**, so the napplet appears under
your account and the identity reads (`getProfile`, `getFollows`, …) have data.
## Publish
```bash
cd tools/napplet-test
./publish.sh --sec nsec1yourkey... --server https://blossom.primal.net \
--relay wss://relay.damus.io --relay wss://nos.lol
```
Then verify it resolves (optional):
```bash
amy napplet fetch <your-pubkey-hex> --d napplet-test
```
## Open it in Amethyst
Build & install the debug app and watch the logs:
```bash
./gradlew :amethyst:installPlayDebug
adb logcat -s NappletHostActivity NappletBrokerService NappletContentServer
```
Logged in as the publishing key, find **"Napplet Test Harness"** in your Apps / Napplets list (or its
feed card) and tap **Open**. It launches in the sandboxed `:napplet` process.
## What to verify
- **On load:** each read row turns green. `shell.supports(identity)` = true, `(bogus)` = false. Every
capability prompts for consent the first time.
- **identity.getList/getZaps/getBadges:** open your own profile first so the cache has your lists /
zaps / badges, then relaunch — the rows show your data (empty arrays are valid if you have none).
- **identity.onChanged:** with the napplet open, switch accounts (or log out) → the banner shows
`identity.changed → <pubkey>`. It must NOT fire on the initial load.
- **keys.onAction:** with a hardware keyboard (or `adb shell input keyevent 47` for "S" while holding
Ctrl), press **Ctrl+S** → banner shows `keys.action → save fired`, and the keystroke is consumed.
- **resource.bytes `nostr:`:** paste a `nostr:nevent1…` / `note1…` / `naddr1…` / `npub1…` and run →
it returns the event JSON as a blob. Also try an `https://…` image URL.
- **Side effects (deliberate):** `relay.publish` signs+broadcasts a note as you; `upload.blob` uploads
a tiny blob; `value.payInvoice` pays a BOLT-11 invoice (needs a connected wallet). Each prompts.
- **Security:** the applet has no direct network (a plain `fetch()` inside it fails — CSP
`connect-src 'none'`); an undeclared capability is denied even if you'd allow it.
## Notes
- `publish.sh` uses `nak`'s `-t key=val1;val2` multi-element tag syntax (e.g.
`path=/index.html;<hash>`). If your `nak` version differs, adjust accordingly.
- Re-running `publish.sh` replaces the same addressable event (`d=napplet-test`).
+159
View File
@@ -0,0 +1,159 @@
<!doctype html>
<!--
Napplet test harness — a single-file napplet that exercises every window.napplet.* API and
renders the result of each on screen, so the Android host can be verified end to end on a device.
Read-only checks run automatically on load (each triggers a one-time consent prompt per capability).
Side-effectful ops (publish / upload / pay) are behind buttons so you trigger them deliberately.
Live pushes (identity.changed, keys.action) appear in the banner at the top when they fire.
Publish it with publish.sh, then open it from Amethyst (your Apps / Napplets list, or its feed card).
-->
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>Napplet Test Harness</title>
<style>
body { font: 14px/1.4 system-ui, sans-serif; margin: 0; padding: 12px; background: #fff; color: #111; }
h1 { font-size: 16px; margin: 0 0 8px; }
#banner { min-height: 1.4em; padding: 8px; margin-bottom: 10px; border-radius: 8px; background: #eef; color: #224; font-weight: 600; }
.row { padding: 6px 8px; border-bottom: 1px solid #eee; display: flex; gap: 8px; align-items: baseline; }
.name { flex: 0 0 12em; font-weight: 600; }
.val { flex: 1; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, monospace; font-size: 12px; }
.ok { color: #0a0; } .err { color: #c00; } .pending { color: #888; }
section { margin-top: 14px; }
input, button { font: inherit; padding: 6px 8px; margin: 2px 0; }
input { width: 100%; box-sizing: border-box; }
button { cursor: pointer; }
</style>
</head>
<body>
<h1>Napplet Test Harness</h1>
<div id="banner">Waiting for live pushes (switch account → identity.changed; press Ctrl+S → keys.action)…</div>
<div id="results"></div>
<section>
<h1>Side-effectful (tap to run)</h1>
<input id="pub" placeholder="note content to publish (relay.publish, signs as you)" />
<button onclick="doPublish()">relay.publish</button>
<hr/>
<input id="res" placeholder="resource.bytes URL — https://… or nostr:nevent1…/note1…/npub1…" />
<button onclick="doResource()">resource.bytes</button>
<hr/>
<button onclick="doUpload()">upload.blob (uploads a tiny text blob)</button>
<hr/>
<input id="inv" placeholder="BOLT-11 invoice for value.payInvoice" />
<button onclick="doPay()">value.payInvoice</button>
</section>
<script>
var results = document.getElementById('results');
var banner = document.getElementById('banner');
function row(name) {
var r = document.createElement('div'); r.className = 'row';
var n = document.createElement('div'); n.className = 'name'; n.textContent = name;
var v = document.createElement('div'); v.className = 'val pending'; v.textContent = '…';
r.appendChild(n); r.appendChild(v); results.appendChild(r);
return v;
}
function show(v, ok, value) {
v.className = 'val ' + (ok ? 'ok' : 'err');
v.textContent = (ok ? '' : 'ERROR: ') + (typeof value === 'string' ? value : JSON.stringify(value));
}
// Run an async napplet call and render pass/fail.
function check(name, fn) {
var v = row(name);
try {
Promise.resolve(fn()).then(function (out) { show(v, true, out === undefined ? 'ok' : out); },
function (e) { show(v, false, (e && e.message) || String(e)); });
} catch (e) { show(v, false, (e && e.message) || String(e)); }
}
function flash(msg) { banner.textContent = msg + ' (' + new Date().toLocaleTimeString() + ')'; }
function run() {
if (!window.napplet) { banner.textContent = 'window.napplet is MISSING — shim not injected!'; return; }
// ---- shell negotiation ----
check('shell.supports(identity)', function () { return napplet.shell.supports('identity'); });
check('shell.supports(bogus)', function () { return napplet.shell.supports('bogus'); });
// ---- identity (read-only) ----
check('identity.getPublicKey', function () { return napplet.identity.getPublicKey(); });
check('identity.getProfile', function () { return napplet.identity.getProfile(); });
check('identity.getRelays', function () { return napplet.identity.getRelays(); });
check('identity.getFollows', function () { return napplet.identity.getFollows().then(trunc); });
check('identity.getMutes', function () { return napplet.identity.getMutes().then(trunc); });
check('identity.getBlocked', function () { return napplet.identity.getBlocked().then(trunc); });
check('identity.getList(bookmarks)', function () { return napplet.identity.getList('bookmarks').then(trunc); });
check('identity.getZaps', function () { return napplet.identity.getZaps().then(trunc); });
check('identity.getBadges', function () { return napplet.identity.getBadges().then(trunc); });
// identity.onChanged → live push when you switch / log out
try {
napplet.identity.onChanged(function (pubkey) { flash('identity.changed → ' + (pubkey || '(logged out)')); });
row('identity.onChanged').textContent = 'registered — switch account to fire';
} catch (e) { show(row('identity.onChanged'), false, e.message); }
// ---- relay (read) ----
check('relay.query(kind1 limit3)', function () {
return napplet.relay.query({ kinds: [1], limit: 3 }).then(function (evs) { return (evs ? evs.length : 0) + ' events'; });
});
check('relay.subscribe(kind1)', function () {
return new Promise(function (resolve) {
var n = 0;
var sub = napplet.relay.subscribe({ kinds: [1], limit: 5 }, function () { n++; }, function () { sub.close(); resolve(n + ' events, EOSE ok'); });
setTimeout(function () { try { sub.close(); } catch (_) {} resolve(n + ' events (timeout)'); }, 8000);
});
});
// ---- storage round-trip ----
check('storage round-trip', function () {
var key = 'k' + Date.now();
return napplet.storage.setItem(key, 'v1')
.then(function () { return napplet.storage.getItem(key); })
.then(function (got) {
if (got !== 'v1') throw new Error('getItem returned ' + got);
return napplet.storage.keys();
})
.then(function (keys) { return napplet.storage.removeItem(key).then(function () { return 'set/get/keys(' + keys.length + ')/remove ok'; }); });
});
// ---- keys: register Ctrl+S, fire via onAction ----
try {
napplet.keys.registerAction({ id: 'save', label: 'Save', defaultKey: 'Ctrl+S' }).then(function (reg) {
show(row('keys.registerAction'), true, reg);
});
napplet.keys.onAction('save', function () { flash('keys.action → save fired'); });
} catch (e) { show(row('keys.registerAction'), false, e.message); }
}
function trunc(x) { var s = JSON.stringify(x); return s.length > 200 ? s.slice(0, 200) + '…' : s; }
// ---- side-effectful handlers ----
function doPublish() {
var c = document.getElementById('pub').value || ('napplet test ' + Date.now());
check('relay.publish', function () { return napplet.relay.publish({ kind: 1, content: c, tags: [] }).then(function (ev) { return 'published ' + ev.id; }); });
}
function doResource() {
var u = document.getElementById('res').value;
if (!u) { flash('enter a URL first'); return; }
check('resource.bytes(' + u.slice(0, 24) + '…)', function () {
return napplet.resource.bytes(u).then(function (blob) { return blob.size + ' bytes, type=' + blob.type; });
});
}
function doUpload() {
var bytes = new TextEncoder().encode('napplet upload test ' + Date.now());
check('upload.blob', function () { return napplet.upload.blob(bytes, 'text/plain').then(function (url) { return url; }); });
}
function doPay() {
var inv = document.getElementById('inv').value;
if (!inv) { flash('enter an invoice first'); return; }
check('value.payInvoice', function () { return napplet.value.payInvoice(inv).then(function (pre) { return 'preimage ' + pre; }); });
}
run();
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
#
# Publish the test napplet (index.html) so it can be opened in Amethyst on a device:
# 1. uploads index.html to a Blossom server (BUD-02 signed upload),
# 2. publishes a NIP-5D named-napplet event (kind 35129) whose manifest pins
# /index.html to the blob's sha256 and declares the capabilities it uses.
#
# Requirements: nak (https://github.com/fiatjaf/nak), curl, and sha256sum (or shasum/openssl).
#
# Usage:
# ./publish.sh --sec nsec1... --server https://blossom.example [--relay wss://... ]... [--id napplet-test]
#
# The secret key can also come from $NOSTR_SECRET_KEY or $NSEC. Relays default to a couple of
# public ones if none are given. Use the SAME key you are logged in as in Amethyst, so the napplet
# shows up under your own account and the identity reads have data.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
HTML="$HERE/index.html"
ID="napplet-test"
SERVER=""
SEC="${NOSTR_SECRET_KEY:-${NSEC:-}}"
RELAYS=()
while [ $# -gt 0 ]; do
case "$1" in
--sec) SEC="$2"; shift 2 ;;
--server) SERVER="${2%/}"; shift 2 ;;
--relay) RELAYS+=("$2"); shift 2 ;;
--id) ID="$2"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$SEC" ] || { echo "ERROR: no secret key (--sec / \$NOSTR_SECRET_KEY / \$NSEC)" >&2; exit 2; }
[ -n "$SERVER" ] || { echo "ERROR: no --server (a Blossom base URL, e.g. https://blossom.primal.net)" >&2; exit 2; }
command -v nak >/dev/null || { echo "ERROR: nak not found (https://github.com/fiatjaf/nak)" >&2; exit 2; }
command -v curl >/dev/null || { echo "ERROR: curl not found" >&2; exit 2; }
[ ${#RELAYS[@]} -gt 0 ] || RELAYS=(wss://relay.damus.io wss://nos.lol)
sha256() {
if command -v sha256sum >/dev/null; then sha256sum | cut -d' ' -f1
elif command -v shasum >/dev/null; then shasum -a 256 | cut -d' ' -f1
else openssl dgst -sha256 | sed 's/.* //'; fi
}
b64() { base64 | tr -d '\n'; }
HASH="$(sha256 < "$HTML")"
echo "index.html sha256 : $HASH"
# --- 1. Blossom upload (BUD-02: a kind-24242 'upload' auth event in the Authorization header) ---
EXP=$(( $(date +%s) + 3600 ))
AUTH_JSON="$(nak event -k 24242 --sec "$SEC" -t "t=upload" -t "x=$HASH" -t "expiration=$EXP" -c "Upload napplet test")"
AUTH_B64="$(printf '%s' "$AUTH_JSON" | b64)"
echo "Uploading to $SERVER/upload …"
UP="$(curl -sS -X PUT "$SERVER/upload" \
-H "Authorization: Nostr $AUTH_B64" \
-H "Content-Type: text/html" \
--data-binary @"$HTML")"
echo " server said: $UP"
echo "Verifying $SERVER/$HASH is retrievable …"
CODE="$(curl -sS -o /dev/null -w '%{http_code}' "$SERVER/$HASH")"
[ "$CODE" = "200" ] || { echo "ERROR: blob not retrievable (HTTP $CODE). Check the upload response above." >&2; exit 1; }
echo " OK (HTTP 200)"
# --- 2. NIP-5A aggregate hash over the one path: sha256("<filehash> /index.html\n") ---
AGG="$(printf '%s /index.html\n' "$HASH" | sha256)"
echo "aggregate hash : $AGG"
# --- 3. Publish the NIP-5D named napplet (kind 35129) ---
echo "Publishing napplet event (kind 35129, d=$ID) to: ${RELAYS[*]}"
nak event -k 35129 --sec "$SEC" \
-d "$ID" \
-t "path=/index.html;$HASH" \
-t "x=$AGG;aggregate" \
-t "server=$SERVER" \
-t "requires=identity" \
-t "requires=relay" \
-t "requires=storage" \
-t "requires=value" \
-t "requires=resource" \
-t "requires=upload" \
-t "requires=keys" \
-t "title=Napplet Test Harness" \
-t "description=Exercises every napplet.* API for on-device verification." \
"${RELAYS[@]}"
PUB="$(nak key public "$SEC")"
echo
echo "Done. Author pubkey: $PUB"
echo "Verify resolution with amy:"
echo " amy napplet fetch $PUB --d $ID"
echo "Then in Amethyst (logged in as this key): open your Apps / Napplets list and tap \"Napplet Test Harness\"."