From ac633f89944a6ee9b071cabbfcea33e16e557394 Mon Sep 17 00:00:00 2001 From: Valentin Fernandez Date: Fri, 17 Jul 2026 14:20:12 -0300 Subject: [PATCH] fix: Answer truapi 0.4's MessagePort handshake --- .gitignore | 1 + CHANGELOG.md | 11 ++++ README.md | 10 +++- forum-post.md | 34 ++++++++++++ package.json | 3 +- pnpm-lock.yaml | 12 ++++ src/browser/host-runtime.ts | 7 ++- src/browser/truapi-port-handoff.ts | 88 ++++++++++++++++++++++++++++++ test/build-test-product.mjs | 30 ++++++---- test/integration.spec.ts | 51 +---------------- test/support.ts | 53 ++++++++++++++++++ test/test-product-truapi.html | 10 ++++ test/test-product-truapi.ts | 61 +++++++++++++++++++++ test/truapi-product.spec.ts | 63 +++++++++++++++++++++ 14 files changed, 370 insertions(+), 64 deletions(-) create mode 100644 src/browser/truapi-port-handoff.ts create mode 100644 test/support.ts create mode 100644 test/test-product-truapi.html create mode 100644 test/test-product-truapi.ts create mode 100644 test/truapi-product.spec.ts diff --git a/.gitignore b/.gitignore index d5bf2a1..2200b34 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist/ *.tgz .DS_Store test/test-product-bundle.js +test/test-product-truapi-bundle.js test/test-results/ test-results/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e138a0a..80410e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.11.0 + +### Added + +- **`@parity/truapi` 0.4 product support (MessagePort handoff).** Products built on `@parity/truapi` ≥ 0.4 boot through `@parity/truapi/sandbox`: the iframe posts `{ type: "truapi-ready" }` to the parent window and waits (20s) for a `{ type: "truapi-init" }` answer carrying a transferred `MessagePort`, then runs all protocol traffic over that port — it never listens on direct window postMessage. The host page now answers that handshake with a fresh port pair on every product page load and routes wire frames to whichever channel the product opened. Products on the 0.3 bootstrap (`@novasamatech/host-api-wrapper`) continue to use the direct window postMessage channel; both kinds connect to the same container, and `waitForConnection()` and all handlers work unchanged. Wire frames are identical on both channels, so no codec changes were needed. + +### Internal + +- `src/browser/truapi-port-handoff.ts`: `createDualChannelIframeProvider({ iframe, url })` builds on the container's `createIframeProvider`, answering `truapi-ready` and swapping the port pair per page load (device-permission and deep-link reloads each re-handshake). +- `test/truapi-product.spec.ts` + `test/test-product-truapi.ts`: integration coverage with a real `@parity/truapi@0.4` product bundle — asserts `getConnectionStatus()` turns `connected` and serves a localStorage roundtrip and product-account fetch over the port. `@parity/truapi` added as a devDependency. + ## 0.10.0 ### Breaking changes diff --git a/README.md b/README.md index 14a25e3..3ce16df 100644 --- a/README.md +++ b/README.md @@ -207,12 +207,18 @@ Playwright test → registers handlers: accounts, signing, chain RPC, localStorage Product (in iframe) - → host-api-wrapper detects iframe parent - → injects window.injectedWeb3.spektr + → truapi ≥ 0.4 (@parity/truapi/sandbox): posts truapi-ready, host answers + truapi-init with a transferred MessagePort — all frames flow over the port + → truapi 0.3 (host-api-wrapper): exchanges frames directly over window + postMessage; injects window.injectedWeb3.spektr → gets accounts (Alice/Bob with real sr25519 public keys) → signing requests → host auto-signs with dev keypair → returns signature ``` +Both channels carry the same wire frames and feed the same container, so every +handler, log, and control knob behaves identically for either product +generation. + The browser bundle (~780KB minified) includes `@novasamatech/host-container`, `@polkadot/keyring`, `@polkadot/types`, and WASM crypto. It's pre-built and inlined — consumers have zero build-time dependencies. ## API reference diff --git a/forum-post.md b/forum-post.md index fbe2cd7..314aa6d 100644 --- a/forum-post.md +++ b/forum-post.md @@ -668,3 +668,37 @@ The config type is renamed. The shape is identical (`id`, `name`, `genesisHash`, 4. Optionally, add the extra networks your product connects to so mid-session chain switches resolve. --- + +# host-api-test-sdk 0.11.0 + +## `@parity/truapi` 0.4 products connect out of the box + +Products that upgraded to `@parity/truapi` 0.4 (including everything built on +recent `@parity/product-sdk`) change how the iframe channel is opened: instead +of exchanging frames directly over window postMessage, the product posts +`{ type: "truapi-ready" }` and expects the host to answer with +`{ type: "truapi-init" }` carrying a transferred `MessagePort`. Against older +test-sdk releases, that handshake went unanswered — the product waited 20 +seconds for a port that never arrived and `waitForConnection()` timed out. + +The test host now answers the handshake and serves all traffic over the +transferred port. No test changes are needed: + +```ts +const bobFixture = createTestHostFixture({ + productUrl: "http://localhost:5260", + accounts: ["bob"], + networks: [PASEO_ASSET_HUB], +}); +// waitForConnection() now resolves for truapi-0.4 products too +``` + +Products on the 0.3 bootstrap (`@novasamatech/host-api-wrapper`) are +unaffected — the direct window postMessage channel is still served, and both +kinds of product talk to the same container with the same handlers, logs, and +permission model. + +## What you need to do + +1. Upgrade to `0.11.0`. +2. Nothing else — both product generations connect without configuration. diff --git a/package.json b/package.json index bf4943f..5deb8cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@parity/host-api-test-sdk", - "version": "0.10.0", + "version": "0.11.0", "description": "Lightweight test host for Spektr product E2E testing — embeds dapps with auto-signing dev accounts, no Docker needed", "license": "MIT", "repository": { @@ -47,6 +47,7 @@ "devDependencies": { "@novasamatech/host-api-wrapper": "^0.8.8", "@novasamatech/host-container": "^0.8.8", + "@parity/truapi": "^0.4.1", "@polkadot/keyring": "^14.0.0", "@polkadot/types": "^16.0.0", "@polkadot/util": "^14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d2d8d..cda34bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@novasamatech/host-container': specifier: ^0.8.8 version: 0.8.8(esbuild@0.25.12)(rxjs@7.8.2) + '@parity/truapi': + specifier: ^0.4.1 + version: 0.4.1 '@polkadot/keyring': specifier: ^14.0.0 version: 14.0.1(@polkadot/util-crypto@14.0.1(@polkadot/util@14.0.1))(@polkadot/util@14.0.1) @@ -244,6 +247,9 @@ packages: '@novasamatech/scale@0.8.8': resolution: {integrity: sha512-cWg4RkrUoysc+q7zQnr2vC+JmcuRLZFft3ZITkq1U6d7wDAYGJjzTYGKLDxIe2HZ+mRhU7otFCz1aaFtBEu98w==} + '@parity/truapi@0.4.1': + resolution: {integrity: sha512-q050QvYUOBEJsGBOvmJn81G0c7ht5+8zm7IyWPa/PtuQEMiCaf7f/CwqH3Dv0hESm/rBgQ1hpF7Pt4ZtSrs0aA==} + '@playwright/test@1.58.2': resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} engines: {node: '>=18'} @@ -1360,6 +1366,12 @@ snapshots: '@polkadot-api/utils': 0.4.0 scale-ts: 1.6.1 + '@parity/truapi@0.4.1': + dependencies: + '@noble/hashes': 2.2.0 + neverthrow: 8.2.0 + scale-ts: 1.6.1 + '@playwright/test@1.58.2': dependencies: playwright: 1.58.2 diff --git a/src/browser/host-runtime.ts b/src/browser/host-runtime.ts index 4ae4bb3..100e937 100644 --- a/src/browser/host-runtime.ts +++ b/src/browser/host-runtime.ts @@ -26,7 +26,6 @@ import { import type { Container } from "@novasamatech/host-container"; import { createContainer, - createIframeProvider, deriveProductEntropy, } from "@novasamatech/host-container"; import { Keyring } from "@polkadot/keyring"; @@ -41,6 +40,7 @@ import { import { ResultAsync } from "neverthrow"; import { getWsProvider } from "polkadot-api/ws"; +import { createDualChannelIframeProvider } from "./truapi-port-handoff.js"; import type { ChatBot, ChatMessageLogEntry, @@ -353,7 +353,10 @@ function setupContainer( paymentCounter = 0; themeSubscribers.clear(); - const provider = createIframeProvider({ iframe, url: config.productUrl }); + const provider = createDualChannelIframeProvider({ + iframe, + url: config.productUrl, + }); const container = createContainer(provider); // Derive keypairs for all requested accounts diff --git a/src/browser/truapi-port-handoff.ts b/src/browser/truapi-port-handoff.ts new file mode 100644 index 0000000..e32ca6c --- /dev/null +++ b/src/browser/truapi-port-handoff.ts @@ -0,0 +1,88 @@ +/** + * Product iframe provider serving both TrUAPI channel generations. + * + * Products embed one of two bootstraps, distinguished by how they open the + * frame channel: + * + * - `@novasamatech/host-api-wrapper` (truapi 0.3): exchanges raw Uint8Array + * frames directly with the parent window — `createIframeProvider`'s native + * channel. + * - `@parity/truapi/sandbox` (truapi 0.4): posts `{ type: "truapi-ready" }` + * to the parent and waits for `{ type: "truapi-init" }` carrying a + * transferred MessagePort, then runs all traffic over that port. It never + * listens on window postMessage. + * + * Wire frames are identical on both channels, so this provider wraps + * `createIframeProvider`, answers the ready ping with a fresh port pair, and + * routes frames to whichever channel the product opened — presenting a single + * Provider to the container. + * + * The provider's lifetime is one container generation: `setAccounts()` + * disposes the container (and this provider with it) and builds a fresh pair, + * so a handed-off port never outlives the product generation it serves. + */ + +import type { Provider } from '@novasamatech/host-api'; +import { createIframeProvider } from '@novasamatech/host-container'; + +export function createDualChannelIframeProvider(options: { + iframe: HTMLIFrameElement; + url: string; +}): Provider { + const { iframe, url } = options; + const inner = createIframeProvider({ iframe, url }); + const productOrigin = new URL(url, window.location.href).origin; + const subscribers = new Set<(message: Uint8Array) => void>(); + let port: MessagePort | null = null; + + const deliver = (message: Uint8Array): void => { + for (const subscriber of subscribers) subscriber(message); + }; + + const unsubscribeInner = inner.subscribe(deliver); + + const onWindowMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow) return; + if (event.origin !== productOrigin) return; + if ((event.data as { type?: unknown } | null)?.type !== 'truapi-ready') return; + + // The product sends one ready ping per page load; each load needs its own + // port pair (device-permission reloads, deep-link reloads). + port?.close(); + const channel = new MessageChannel(); + port = channel.port1; + port.onmessage = (e: MessageEvent) => { + if (e.data instanceof Uint8Array) deliver(e.data); + }; + iframe.contentWindow?.postMessage({ type: 'truapi-init' }, productOrigin, [channel.port2]); + }; + window.addEventListener('message', onWindowMessage); + + return { + logger: inner.logger, + isCorrectEnvironment: () => inner.isCorrectEnvironment(), + postMessage(message: Uint8Array): void { + // A product that completed the handoff listens only on the port; one + // that did not listens only on window postMessage. + if (port) { + port.postMessage(message); + } else { + inner.postMessage(message); + } + }, + subscribe(callback: (message: Uint8Array) => void): () => void { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + dispose(): void { + window.removeEventListener('message', onWindowMessage); + port?.close(); + port = null; + subscribers.clear(); + unsubscribeInner(); + inner.dispose(); + }, + }; +} diff --git a/test/build-test-product.mjs b/test/build-test-product.mjs index e5fcfe3..d0b6f6a 100644 --- a/test/build-test-product.mjs +++ b/test/build-test-product.mjs @@ -1,14 +1,22 @@ import { build } from 'esbuild'; -await build({ - entryPoints: ['test/test-product.ts'], - bundle: true, - format: 'iife', - platform: 'browser', - target: 'es2022', - outfile: 'test/test-product-bundle.js', - sourcemap: false, - conditions: ['browser'], -}); +const products = [ + { entry: 'test/test-product.ts', outfile: 'test/test-product-bundle.js' }, + { entry: 'test/test-product-truapi.ts', outfile: 'test/test-product-truapi-bundle.js' }, +]; -console.log('Test product bundle built: test/test-product-bundle.js'); +await Promise.all( + products.map(async ({ entry, outfile }) => { + await build({ + entryPoints: [entry], + bundle: true, + format: 'iife', + platform: 'browser', + target: 'es2022', + outfile, + sourcemap: false, + conditions: ['browser'], + }); + console.log(`Test product bundle built: ${outfile}`); + }), +); diff --git a/test/integration.spec.ts b/test/integration.spec.ts index d04a81f..17a54fd 100644 --- a/test/integration.spec.ts +++ b/test/integration.spec.ts @@ -9,59 +9,14 @@ */ import { test, expect } from '@playwright/test'; -import { createServer } from 'node:http'; -import { readFileSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { Keyring } from '@polkadot/keyring'; import { cryptoWaitReady, sr25519Verify } from '@polkadot/util-crypto'; import { compactFromU8a, hexToU8a, u8aToHex } from '@polkadot/util'; import { createTestHostServer } from '../dist/index.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// ── Test product server ───────────────────────────────────────────── - -async function serveTestProduct(): Promise<{ url: string; close: () => Promise }> { - const html = readFileSync(join(__dirname, 'test-product.html'), 'utf-8'); - const bundle = readFileSync(join(__dirname, 'test-product-bundle.js'), 'utf-8'); - - const server = createServer((req, res) => { - if (req.url?.endsWith('.js')) { - res.writeHead(200, { 'Content-Type': 'application/javascript' }); - res.end(bundle); - } else { - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(html); - } - }); - - const url = await new Promise((resolve, reject) => { - server.on('error', reject); - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - if (!addr || typeof addr === 'string') return reject(new Error('no address')); - resolve(`http://127.0.0.1:${addr.port}`); - }); - }); - - return { - url, - close: () => new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - }), - }; -} +import { loadHost, serveProduct } from './support'; // ── Helpers ───────────────────────────────────────────────────────── -/** Load the test host and wait for the product to connect. */ -async function loadHost(page: import('@playwright/test').Page, hostUrl: string) { - await page.goto(hostUrl); - await page.waitForFunction(() => !!window.__TEST_HOST__, { timeout: 15_000 }); - return page.frameLocator('#product-frame'); -} - /** Get the product iframe as a Frame (supports evaluate, unlike FrameLocator). */ function getProductFrame(page: import('@playwright/test').Page, productUrl: string) { const frame = page.frames().find(f => f.url().startsWith(productUrl)); @@ -95,13 +50,13 @@ async function getRootPublicKeys(page: import('@playwright/test').Page, hostUrl: // ── Setup ─────────────────────────────────────────────────────────── -let productServer: Awaited>; +let productServer: Awaited>; let keyring: Keyring; test.beforeAll(async () => { await cryptoWaitReady(); keyring = new Keyring({ type: 'sr25519', ss58Format: 42 }); - productServer = await serveTestProduct(); + productServer = await serveProduct('test-product.html', 'test-product-bundle.js'); }); test.afterAll(async () => { diff --git a/test/support.ts b/test/support.ts new file mode 100644 index 0000000..afc33fe --- /dev/null +++ b/test/support.ts @@ -0,0 +1,53 @@ +/** + * Shared scaffolding for the Playwright specs: ephemeral static servers for + * product pages and test-host page loading. + */ + +import { createServer } from 'node:http'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Serve a product page (one HTML shell + one script bundle) on an ephemeral port. */ +export async function serveProduct( + htmlFile: string, + bundleFile: string, +): Promise<{ url: string; close: () => Promise }> { + const html = readFileSync(join(__dirname, htmlFile), 'utf-8'); + const bundle = readFileSync(join(__dirname, bundleFile), 'utf-8'); + + const server = createServer((req, res) => { + if (req.url?.endsWith('.js')) { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(bundle); + } else { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(html); + } + }); + + const url = await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + if (!addr || typeof addr === 'string') return reject(new Error('no address')); + resolve(`http://127.0.0.1:${addr.port}`); + }); + }); + + return { + url, + close: () => new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + +/** Load the test host page, wait for its control API, return the product frame locator. */ +export async function loadHost(page: import('@playwright/test').Page, hostUrl: string) { + await page.goto(hostUrl); + await page.waitForFunction(() => !!window.__TEST_HOST__, { timeout: 15_000 }); + return page.frameLocator('#product-frame'); +} diff --git a/test/test-product-truapi.html b/test/test-product-truapi.html new file mode 100644 index 0000000..a81347a --- /dev/null +++ b/test/test-product-truapi.html @@ -0,0 +1,10 @@ + + +Test Product (truapi 0.4) + +
loading
+
+
+ + + diff --git a/test/test-product-truapi.ts b/test/test-product-truapi.ts new file mode 100644 index 0000000..8a8f596 --- /dev/null +++ b/test/test-product-truapi.ts @@ -0,0 +1,61 @@ +/** + * Minimal @parity/truapi 0.4 product page for integration tests. + * + * Boots through `@parity/truapi/sandbox`: posts `truapi-ready` to the parent + * window and waits for a `truapi-init` message carrying a transferred + * MessagePort. All protocol traffic then flows over that port — nothing is + * exchanged over direct window postMessage. + * + * Exercises a localStorage roundtrip and a product-account fetch, and writes + * the results into the DOM for the Playwright spec to read back. + */ + +import { getClientSync } from '@parity/truapi/sandbox'; + +const DOTNS_ID = 'test-product.dot'; +// "hello" as hex — valid UTF-8 so the host's text-based storage roundtrips it exactly. +const STORAGE_VALUE = '0x68656c6c6f' as const; + +function setResult(id: string, value: string): void { + const el = document.getElementById(id); + if (!el) return; + el.textContent = value; + el.setAttribute('data-ready', 'true'); +} + +async function main(): Promise { + const client = getClientSync(); + if (!client) { + setResult('status', 'no-host'); + return; + } + setResult('status', 'client-created'); + + const written = await client.localStorage.write({ + key: 'truapi-e2e', + value: STORAGE_VALUE, + }); + if (written.isErr()) { + setResult('storage', `write-error:${JSON.stringify(written.error)}`); + return; + } + const read = await client.localStorage.read({ key: 'truapi-e2e' }); + if (read.isErr()) { + setResult('storage', `read-error:${JSON.stringify(read.error)}`); + } else { + setResult('storage', `ok:${read.value.value ?? 'missing'}`); + } + + const account = await client.account.getAccount({ + productAccountId: { dotNsIdentifier: DOTNS_ID, derivationIndex: 0 }, + }); + if (account.isErr()) { + setResult('account', `error:${JSON.stringify(account.error)}`); + } else { + setResult('account', account.value.account.publicKey); + } +} + +main().catch((err) => { + setResult('status', `boot-error:${err instanceof Error ? err.message : String(err)}`); +}); diff --git a/test/truapi-product.spec.ts b/test/truapi-product.spec.ts new file mode 100644 index 0000000..dcb931b --- /dev/null +++ b/test/truapi-product.spec.ts @@ -0,0 +1,63 @@ +/** + * Integration test for @parity/truapi 0.4 products. + * + * A 0.4 product boots via `@parity/truapi/sandbox`: it posts `truapi-ready` + * to the parent window and expects a `truapi-init` answer carrying a + * transferred MessagePort, then runs all protocol traffic over that port. + * These tests verify the test host answers the handshake and serves real + * calls (localStorage roundtrip, product-account fetch) over the port, and + * that the container's own handshake ping reaches the product so + * `getConnectionStatus()` — what `waitForConnection` polls — turns + * "connected". + */ + +import { test, expect } from '@playwright/test'; +import { Keyring } from '@polkadot/keyring'; +import { cryptoWaitReady } from '@polkadot/util-crypto'; +import { u8aToHex } from '@polkadot/util'; +import { createTestHostServer } from '../dist/index.js'; +import { loadHost, serveProduct } from './support'; + +test.describe('truapi 0.4 product — MessagePort handoff', () => { + let productServer: Awaited>; + let host: Awaited>; + let keyring: Keyring; + + test.beforeAll(async () => { + await cryptoWaitReady(); + keyring = new Keyring({ type: 'sr25519', ss58Format: 42 }); + productServer = await serveProduct('test-product-truapi.html', 'test-product-truapi-bundle.js'); + host = await createTestHostServer({ + productUrl: productServer.url, + accounts: ['bob'], + }); + }); + + test.afterAll(async () => { + await host?.close(); + await productServer?.close(); + }); + + test('container reports the product connected', async ({ page }) => { + await loadHost(page, host.url); + await page.waitForFunction( + () => window.__TEST_HOST__?.getConnectionStatus() === 'connected', + { timeout: 15_000 }, + ); + }); + + test('serves calls over the transferred MessagePort', async ({ page }) => { + const frame = await loadHost(page, host.url); + + await expect(frame.locator('#status')).toHaveText('client-created', { timeout: 15_000 }); + await expect(frame.locator('#storage[data-ready="true"]')).toHaveText('ok:0x68656c6c6f', { + timeout: 15_000, + }); + + // Default product-account derivation: //Bob//test-product.dot/0 + const expectedKey = u8aToHex(keyring.addFromUri('//Bob//test-product.dot/0').publicKey); + await expect(frame.locator('#account[data-ready="true"]')).toHaveText(expectedKey, { + timeout: 15_000, + }); + }); +});