mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-30 15:48:36 +00:00
Load and publish single-artifact manifests with R/O declarations, verified icons, plain-text descriptions and kind-aware storage scopes. Update theme pushes, resource wire encoding and BUD-10 resolution, and harden sandbox injection and unknown-message handling. Validated 257 JVM/unit tests, four Chrome shell/shim tests, spotless, and both Android debug APK builds. Device QA and Compose visual inspection remain outstanding; no device or Xvfb is available. BREAKING CHANGE: legacy path-aggregate napplet manifests require republishing. Napplet CLI JSON replaces aggregate_sha256 with artifact_sha256 and removes aggregate_verified. Icon arguments now require a content hash and MIME type.
85 lines
6.0 KiB
JavaScript
85 lines
6.0 KiB
JavaScript
// Run: PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs node --test tools/napplet-test/protocol.test.mjs
|
|
// Uses an installed Chrome; no account, relay, or publication required.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
|
|
const root = new URL('../../', import.meta.url);
|
|
const shim = await readFile(new URL('commonsUI/src/commonMain/composeResources/files/napplet/shim.js', root), 'utf8');
|
|
const shell = await readFile(new URL('commonsUI/src/commonMain/composeResources/files/napplet/shell.html', root), 'utf8');
|
|
const contract = await readFile(new URL('commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletWebContract.kt', root), 'utf8');
|
|
const csp = contract.match(/const val APP_CSP: String =([\s\S]*?)\n\n/)[1].match(/"[^"]*"/g).map(s => JSON.parse(s)).join('');
|
|
const shellCsp = contract.match(/fun shellCsp[^=]*=([\s\S]*?)\n\n/)[1].match(/"[^"]*"/g).map(s => JSON.parse(s)).join('').replace('$frameSource', "'self'");
|
|
|
|
async function host(run, domains = ['theme', 'resource']) {
|
|
const browser = await chromium.launch({ executablePath: process.env.CHROMIUM || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' });
|
|
try {
|
|
const page = await browser.newPage();
|
|
page.setDefaultTimeout(5000);
|
|
const authored = `<script>window.bootDomains=Object.keys(window.napplet);window.themeChanges=[];window.handle=window.napplet.theme?.onChanged(t=>window.themeChanges.push(t));
|
|
window.security=(async()=>{
|
|
let storageDenied=false,evalDenied=false,networkDenied=false;
|
|
try {localStorage.getItem('x')} catch {storageDenied=true}
|
|
try {new Function('return 1')()} catch {evalDenied=true}
|
|
try {await fetch('https://example.com')} catch {networkDenied=true}
|
|
await WebAssembly.compile(new Uint8Array([0,97,115,109,1,0,0,0]));
|
|
return {domains:window.bootDomains,signer:typeof window.nostr,storageDenied,evalDenied,networkDenied};
|
|
})();<\/script>`;
|
|
const html = `<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="${csp}"><script>window.__nappletDomains=${JSON.stringify(domains)};${shim}<\/script>${authored}`;
|
|
// The native bridge is a test double; the production shell and injected shim run unchanged.
|
|
const bridge = `<script>window.requests=[];window.__nappletBridge={postMessage(raw){window.requests.push(JSON.parse(raw));}};<\/script>`;
|
|
const served = shell.replace('<head>', '<head>' + bridge).replace('__APP_SANDBOX__', 'allow-scripts').replace('__APP_BOOTSTRAP__', `iframe.srcdoc=new TextDecoder().decode(Uint8Array.from(atob('${Buffer.from(html).toString('base64')}'),c=>c.charCodeAt(0)));`);
|
|
await page.route('https://napplet.local/**', route => route.fulfill({ contentType: 'text/html', headers: {'Content-Security-Policy': shellCsp}, body: served }));
|
|
await page.goto('https://napplet.local/__shell__');
|
|
await page.waitForFunction(() => document.querySelector('iframe')?.contentWindow != null);
|
|
const frame = page.frames().find(f => f !== page.mainFrame());
|
|
await frame.waitForFunction(() => window.bootDomains);
|
|
const reply = msg => page.evaluate(msg => window.__nappletBridge.onmessage({data: JSON.stringify(msg)}), msg);
|
|
await run({page, frame, reply});
|
|
} finally { await browser.close(); }
|
|
}
|
|
|
|
test('srcdoc injection precedes code; opaque storage, no signer/network/eval, WebAssembly works', async () => {
|
|
await host(async ({frame}) => {
|
|
// Read the result of authored code: CDP evaluation itself bypasses unsafe-eval CSP checks.
|
|
const state = await frame.evaluate(() => window.security);
|
|
assert.deepEqual(state, {domains:['theme','resource'],signer:'undefined',storageDenied:true,evalDenied:true,networkDenied:true});
|
|
});
|
|
});
|
|
|
|
test('theme updates need no handshake/get; closing a listener stops local delivery', async () => {
|
|
await host(async ({page, frame, reply}) => {
|
|
assert.equal(await page.evaluate(() => window.requests.length), 0);
|
|
const theme = {colors:{background:'#000000',text:'#ffffff',primary:'#123456'}};
|
|
await reply({type:'theme.changed', theme});
|
|
await frame.waitForFunction(() => window.themeChanges.length === 1);
|
|
assert.deepEqual(await frame.evaluate(() => window.themeChanges[0]), theme);
|
|
await frame.evaluate(() => window.handle.close());
|
|
await reply({type:'theme.changed', theme});
|
|
// A same-frame synthetic event must not pass the parent source check.
|
|
await frame.evaluate(theme => window.dispatchEvent(new MessageEvent('message', {data:{type:'theme.changed',theme},source:window})), theme);
|
|
assert.equal(await frame.evaluate(() => window.themeChanges.length), 1);
|
|
});
|
|
});
|
|
|
|
test('resource binding decodes canonical base64 into Blob and rejects nonzero pad bits', async () => {
|
|
await host(async ({page, frame, reply}) => {
|
|
await frame.evaluate(() => { window.bytesResult = window.napplet.resource.bytes('data:application/octet-stream;base64,AAEC/w==').then(async blob => ({mime:blob.type,bytes:Array.from(new Uint8Array(await blob.arrayBuffer()))})); });
|
|
await page.waitForFunction(() => window.requests.length === 1);
|
|
const request = await page.evaluate(() => window.requests[0]);
|
|
await reply({type:'resource.bytes.result',id:request.id,blob:'AAEC/w==',mime:'application/octet-stream'});
|
|
assert.deepEqual(await frame.evaluate(() => window.bytesResult), {mime:'application/octet-stream',bytes:[0,1,2,255]});
|
|
await frame.evaluate(() => { window.invalid = window.napplet.resource.bytes('bad').then(()=>false,()=>true); });
|
|
await page.waitForFunction(() => window.requests.length === 2);
|
|
const bad = await page.evaluate(() => window.requests[1]);
|
|
await reply({type:'resource.bytes.result',id:bad.id,blob:'AB==',mime:'application/octet-stream'});
|
|
assert.equal(await frame.evaluate(() => window.invalid), true);
|
|
});
|
|
});
|
|
|
|
test('optional unavailable domains remain absent', async () => {
|
|
await host(async ({frame}) => {
|
|
assert.deepEqual(await frame.evaluate(() => Object.keys(window.napplet)), ['resource']);
|
|
}, ['resource']);
|
|
});
|