From 0d90893477f4251ee0e686b23d4e207141ffbbf8 Mon Sep 17 00:00:00 2001 From: Nidish Date: Fri, 17 Jul 2026 11:23:50 +0530 Subject: [PATCH] feat(mock): re-port MockPlatform + createMockHost onto current core --- .github/workflows/ci.yml | 51 +- js/packages/truapi-host/README.md | 14 + .../src/host-callbacks-adapter.test.ts | 10 +- .../src/web/create-mock-host.test.ts | 267 ++++++ .../truapi-host/src/web/create-mock-host.ts | 316 ++++++ js/packages/truapi-host/src/web/index.ts | 6 + .../truapi-host/src/web/wasm-bridge.test.ts | 63 ++ rust/crates/truapi-platform/Cargo.toml | 4 + rust/crates/truapi-platform/README.md | 20 +- rust/crates/truapi-platform/src/lib.rs | 4 + rust/crates/truapi-platform/src/mock.rs | 904 ++++++++++++++++++ 11 files changed, 1653 insertions(+), 6 deletions(-) create mode 100644 js/packages/truapi-host/src/web/create-mock-host.test.ts create mode 100644 js/packages/truapi-host/src/web/create-mock-host.ts create mode 100644 js/packages/truapi-host/src/web/wasm-bridge.test.ts create mode 100644 rust/crates/truapi-platform/src/mock.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64c251dc..541f0c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,6 +150,54 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi + host-wasm: + name: "@parity/truapi-host" + runs-on: ubuntu-latest + needs: codegen + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: dtolnay/rust-toolchain@5b842231ba77f5c045dba54ac5560fed2db780e2 # stable + with: + toolchain: stable + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install workspace deps + build @parity/truapi + run: | + npm ci --ignore-scripts + npm run build --prefix js/packages/truapi + + - name: Install wasm-pack + run: cargo install wasm-pack --version 0.15.0 --locked + + - name: Build WASM + run: npm run build:wasm --prefix js/packages/truapi-host + + - name: Test + run: bun test + working-directory: js/packages/truapi-host + # Fail loudly if the WASM artifact is missing so the bridge test can't + # silently skip green after a build:wasm drift. + env: + REQUIRE_WASM: "1" + playground: name: Playground (build + lint) runs-on: ubuntu-latest @@ -293,7 +341,7 @@ jobs: name: CI Status if: always() runs-on: ubuntu-latest - needs: [rust, licenses, codegen, ts-client, playground, explorer, e2e] + needs: [rust, licenses, codegen, ts-client, host-wasm, playground, explorer, e2e] steps: - name: Check all jobs run: | @@ -302,6 +350,7 @@ jobs: "${{ needs.licenses.result }}" "${{ needs.codegen.result }}" "${{ needs.ts-client.result }}" + "${{ needs.host-wasm.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" "${{ needs.e2e.result }}" diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 44e0cd0f..f44d4681 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -55,6 +55,20 @@ const secondProvider = await runtime.createProvider({ protocol-iframe MessageChannel handshake. Host code creates one worker runtime and then opens one provider per product id. +## Testing — in-memory mock host + +`@parity/truapi-host/web` also exports `createMockHost`, the JS sibling of +`truapi-platform`'s `MockPlatform`. It returns a complete +`RequiredHostCallbacks` set (in-memory storage, fixed permission policy, a +silent-or-scripted chain connection, recorded navigations/notifications) plus +accessor oracles for assertions, so tests and host simulators can drive the real +WASM core against a mocked OS seam with no device and no network. `MockHostConfig` +tunes permissions, feature support, theme, confirmation, and chain responses; +`mockRuntimeConfig` builds a matching `ProductRuntimeConfig`. Hand `host.callbacks` +straight to `createWebWorkerPairingHostRuntime` or to `createWasmRawCallbacks`. +Signing and login still need a paired wallet, so those flows park under the +default silent chain. + ## Publishing This package is published by the root `Release` workflow through diff --git a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts index f35b277c..b1d76fa9 100644 --- a/js/packages/truapi-host/src/host-callbacks-adapter.test.ts +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -186,8 +186,9 @@ describe("createWasmRawCallbacks", () => { ); case "AccountAlias": return ( - review.value.requestingProductId === "playground.dot" && - review.value.targetProductId === "wallet.dot" + review.value.callingProductId === "playground.dot" && + review.value.context.productId === "playground.dot" && + review.value.ringLocation.chainId === GENESIS ); case "AccountAccess": return ( @@ -286,8 +287,9 @@ describe("createWasmRawCallbacks", () => { UserConfirmationReview.enc({ tag: "AccountAlias", value: { - requestingProductId: "playground.dot", - targetProductId: "wallet.dot", + callingProductId: "playground.dot", + context: { productId: "playground.dot", suffix: "0x00" }, + ringLocation: { chainId: GENESIS, junctions: [] }, }, }), ), diff --git a/js/packages/truapi-host/src/web/create-mock-host.test.ts b/js/packages/truapi-host/src/web/create-mock-host.test.ts new file mode 100644 index 00000000..ae532276 --- /dev/null +++ b/js/packages/truapi-host/src/web/create-mock-host.test.ts @@ -0,0 +1,267 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import type { CoreStorageKey } from "../generated/host-callbacks.js"; +import { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; +import { createWebWorkerPairingHostRuntime } from "./index.js"; + +describe("createMockHost callbacks", () => { + it("product storage round-trips and is namespaced from core", async () => { + const { callbacks } = createMockHost(); + await callbacks.productStorage.write("k", new Uint8Array([1, 2, 3])); + expect(await callbacks.productStorage.read("k")).toEqual( + new Uint8Array([1, 2, 3]), + ); + // A product key never collides with a core slot. + expect( + await callbacks.coreStorage.readCoreStorage({ tag: "AuthSession" }), + ).toBeUndefined(); + await callbacks.productStorage.clear("k"); + expect(await callbacks.productStorage.read("k")).toBeUndefined(); + }); + + it("core storage round-trips per slot", async () => { + const { callbacks } = createMockHost(); + const key: CoreStorageKey = { + tag: "PermissionAuthorization", + value: { productId: "p", request: { tag: "Device", value: "Camera" } }, + }; + await callbacks.coreStorage.writeCoreStorage(key, new Uint8Array([9])); + expect(await callbacks.coreStorage.readCoreStorage(key)).toEqual( + new Uint8Array([9]), + ); + await callbacks.coreStorage.clearCoreStorage(key); + expect(await callbacks.coreStorage.readCoreStorage(key)).toBeUndefined(); + }); + + it("permissions follow per-capability policy", async () => { + const { callbacks } = createMockHost({ + devicePermissions: "allow-all", + remotePermissions: "deny-all", + }); + expect( + (await callbacks.permissions.devicePermission("Notifications")).granted, + ).toBe(true); + expect( + ( + await callbacks.permissions.remotePermission({ + permission: { tag: "WebRtc" }, + }) + ).granted, + ).toBe(false); + }); + + it("feature support and theme reflect config", async () => { + const { callbacks } = createMockHost({ + featureSupported: false, + theme: "Light", + }); + expect( + ( + await callbacks.features.featureSupported({ + tag: "Chain", + value: { genesisHash: "0x00" }, + }) + ).supported, + ).toBe(false); + const theme = await callbacks.theme + .subscribeTheme() + [Symbol.asyncIterator]() + .next(); + expect(theme.value).toEqual(ok("Light")); + }); + + it("records navigations and assigns monotonic notification ids", async () => { + const host = createMockHost(); + await host.callbacks.navigation.navigateTo("https://a"); + await host.callbacks.navigation.navigateTo("https://b"); + expect(host.navigations()).toEqual(["https://a", "https://b"]); + + const first = await host.callbacks.notifications.pushNotification({ + text: "one", + }); + const second = await host.callbacks.notifications.pushNotification({ + text: "two", + }); + expect([first.id, second.id]).toEqual([0, 1]); + expect(host.pushedNotifications().length).toBe(2); + }); + + it("confirms per config and records chain sends", async () => { + const denied = createMockHost({ confirmUserActions: false }); + expect( + await denied.callbacks.userConfirmation.confirmUserAction({ + tag: "ResourceAllocation", + value: { resources: [] }, + }), + ).toBe(false); + + const host = createMockHost(); + const conn = await host.callbacks.chain.connect(new Uint8Array(32)); + conn.send("rpc-1"); + expect(host.sentRpc()).toEqual(["rpc-1"]); + }); + + it("replays scripted chain frames", async () => { + const host = createMockHost({ chainResponses: ["f1", "f2"] }); + const conn = await host.callbacks.chain.connect(new Uint8Array(32)); + const frames: string[] = []; + for await (const frame of conn.responses()) { + frames.push(frame); + } + expect(frames).toEqual(["f1", "f2"]); + }); + + it("records confirmations and cancelled notifications", async () => { + const host = createMockHost(); + await host.callbacks.userConfirmation.confirmUserAction({ + tag: "ResourceAllocation", + value: { resources: [] }, + }); + expect(host.confirmations()).toEqual(["ResourceAllocation"]); + + const { id } = await host.callbacks.notifications.pushNotification({ + text: "x", + }); + await host.callbacks.notifications.cancelNotification(id); + expect(host.cancelledNotifications()).toEqual([id]); + }); + + it("chainClosed ends the response stream immediately", async () => { + const host = createMockHost({ chainClosed: true }); + const conn = await host.callbacks.chain.connect(new Uint8Array(32)); + const first = await conn.responses()[Symbol.asyncIterator]().next(); + expect(first.done).toBe(true); + }); + + it("preimage insert then lookup round-trips", async () => { + // The core owns Bulletin submission on current core; the host only + // retrieves content, so tests seed the content store directly. + const host = createMockHost(); + const key = host.insertPreimage(new Uint8Array([4, 5, 6])); + expect(key).toBeDefined(); + const found = await host.callbacks.preimage + .lookupPreimage(key) + [Symbol.asyncIterator]() + .next(); + expect(found.value).toEqual(ok(new Uint8Array([4, 5, 6]))); + }); + + it("preimage lookup misses on an unknown key", async () => { + const host = createMockHost(); + host.insertPreimage(new Uint8Array([1, 2, 3])); + const miss = await host.callbacks.preimage + .lookupPreimage(new Uint8Array([9, 9, 9, 9, 9, 9, 9, 9])) + [Symbol.asyncIterator]() + .next(); + expect(miss.value).toEqual(ok(undefined)); + }); + + it("permission policy can deny device and allow remote", async () => { + const { callbacks } = createMockHost({ + devicePermissions: "deny-all", + remotePermissions: "allow-all", + }); + expect( + (await callbacks.permissions.devicePermission("Notifications")).granted, + ).toBe(false); + expect( + ( + await callbacks.permissions.remotePermission({ + permission: { tag: "WebRtc" }, + }) + ).granted, + ).toBe(true); + }); + + it("records auth-state transitions in order", () => { + const host = createMockHost(); + host.callbacks.auth.authStateChanged({ tag: "Disconnected" }); + host.callbacks.auth.authStateChanged({ + tag: "Pairing", + value: { deeplink: "dl" }, + }); + expect(host.authStates().map((state) => state.tag)).toEqual([ + "Disconnected", + "Pairing", + ]); + }); + + it("silent chain records sends but never yields a response", async () => { + const host = createMockHost(); + const conn = await host.callbacks.chain.connect(new Uint8Array(32)); + conn.send("req"); + expect(host.sentRpc()).toEqual(["req"]); + // Silent (no frames, not closed): the stream parks rather than yielding or + // ending, so a race against a timer must be won by the timer. + const outcome = await Promise.race([ + conn + .responses() + [Symbol.asyncIterator]() + .next() + .then(() => "yielded" as const), + new Promise<"parked">((resolve) => setTimeout(() => resolve("parked"), 20)), + ]); + expect(outcome).toBe("parked"); + }); +}); + +/** Minimal `Worker` stand-in: records posted messages and lets the test drive + * the `message` event by hand, so the provider initializes without real WASM. */ +class FakeWorker { + listeners = new Map void>>(); + messages: Record[] = []; + + addEventListener(name: string, fn: (event: unknown) => void) { + const set = this.listeners.get(name) ?? new Set(); + set.add(fn); + this.listeners.set(name, set); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: Record) { + this.messages.push(message); + } + + terminate() {} + + emit(message: Record) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } +} + +describe("createMockHost with createWebWorkerPairingHostRuntime", () => { + it("initializes a worker provider with the mock callbacks (no real WASM)", async () => { + const worker = new FakeWorker(); + const host = createMockHost(); + const { productId, ...hostConfig } = mockRuntimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + worker as unknown as Worker, + host.callbacks, + { hostConfig }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + const providerPromise = runtime.createProvider({ productId }); + const createCore = [...worker.messages] + .reverse() + .find((m) => m.kind === "createCore"); + expect(createCore).toBeDefined(); + worker.emit({ kind: "coreReady", coreId: createCore!.coreId }); + + const provider = await providerPromise; + expect(provider).toBeDefined(); + const init = worker.messages.find((message) => message.kind === "init"); + expect(init).toBeDefined(); + + provider.dispose(); + runtime.dispose(); + }); +}); diff --git a/js/packages/truapi-host/src/web/create-mock-host.ts b/js/packages/truapi-host/src/web/create-mock-host.ts new file mode 100644 index 00000000..3fd2c092 --- /dev/null +++ b/js/packages/truapi-host/src/web/create-mock-host.ts @@ -0,0 +1,316 @@ +// A deterministic, in-memory mock host. `createMockHost` returns a complete +// `RequiredHostCallbacks` set (the JS sibling of `truapi-platform`'s +// `MockPlatform`) plus recordings for assertions. Hand `host.callbacks` to +// `createWebWorkerPairingHostRuntime` (or `createWasmRawCallbacks` directly) to +// run the real truapi-server WASM core against a mocked OS seam: storage is +// in-memory, permissions answer from a fixed policy, navigation/notifications +// are recorded, and the chain connection is silent (or replays canned frames). +// +// Signing and login require a paired wallet answering over the statement-store +// channel; the default silent chain records outbound requests and never +// answers, so those flows park. Everything else (storage, permissions, +// features, theme, navigation, notifications, preimage lookup) works without a +// wallet. +// +// Preimage submission is core-owned on current core (the core builds, signs, +// and submits the Bulletin `TransactionStorage.store` transaction itself), so +// the mock only implements host-side content retrieval via `lookupPreimage`; +// seed retrievable content with the returned `insertPreimage`. + +import { ok } from "neverthrow"; + +import type { + GenericError, + HostPushNotificationRequest, + Result, + ThemeVariant, +} from "@parity/truapi"; + +import type { + AuthState, + CoreStorageKey, + JsonRpcConnection, + RequiredHostCallbacks, +} from "../generated/host-callbacks.js"; +import type { ProductRuntimeConfig } from "../runtime.js"; + +/** How the mock answers a permission prompt for one capability. */ +export type PermissionPolicy = "allow-all" | "deny-all"; + +/** Behavior knobs for {@link createMockHost}. */ +export interface MockHostConfig { + /** Answer for `devicePermission`. Default `"allow-all"`. */ + devicePermissions?: PermissionPolicy; + /** Answer for `remotePermission`. Default `"allow-all"`. */ + remotePermissions?: PermissionPolicy; + /** Whether `featureSupported` reports support. Default `true`. */ + featureSupported?: boolean; + /** Theme emitted by `subscribeTheme`. Default `"Dark"`. */ + theme?: ThemeVariant; + /** Whether `confirmUserAction` confirms reviewed actions. Default `true`. */ + confirmUserActions?: boolean; + /** + * JSON-RPC response frames the chain connection replays, in order. Empty + * (the default) means a silent connection: it records outbound requests and + * never answers, so chain-dependent flows park. + */ + chainResponses?: string[]; + /** + * When `true`, the chain response stream ends immediately instead of parking, + * so disconnect/timeout paths can be asserted (fail-fast). Ignored when + * `chainResponses` is non-empty. + */ + chainClosed?: boolean; +} + +/** A mock host: the callbacks to wire into a provider, plus assertion oracles. */ +export interface MockHost { + /** + * The nested host-callback surface. Pass to `createWasmRawCallbacks` or hand + * to `createWebWorkerPairingHostRuntime` (both accept `RequiredHostCallbacks`). + */ + callbacks: RequiredHostCallbacks; + /** URLs the core asked the host to open, in order. */ + navigations(): string[]; + /** Notifications the core asked the host to show, in order. */ + pushedNotifications(): HostPushNotificationRequest[]; + /** Raw JSON-RPC the core sent over the chain connection, in order. */ + sentRpc(): string[]; + /** Auth-state transitions the core emitted, in order. */ + authStates(): AuthState[]; + /** Confirmation kinds the core requested (review `tag`s), in order. */ + confirmations(): string[]; + /** Notification ids the core asked the host to cancel, in order. */ + cancelledNotifications(): number[]; + /** + * Seed a preimage so a later `preimage.lookupPreimage` resolves it, and + * return the deterministic lookup key. The core (not the host) owns Bulletin + * submission on current core; this is the host-side content store the mock's + * `lookupPreimage` reads from. + */ + insertPreimage(value: Uint8Array): Uint8Array; +} + +/** Deterministic 8-byte key for a preimage value (FNV-1a), so `insertPreimage` + * then `lookupPreimage` round-trips without using the full value as its key. */ +function preimageKey(value: Uint8Array): Uint8Array { + let hash = 0xcbf29ce484222325n; + const prime = 0x100000001b3n; + const mask = 0xffffffffffffffffn; + for (const byte of value) { + hash = ((hash ^ BigInt(byte)) * prime) & mask; + } + const key = new Uint8Array(8); + for (let i = 0; i < 8; i++) { + key[i] = Number((hash >> BigInt(8 * i)) & 0xffn); + } + return key; +} + +function hex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +/** + * Build an in-memory mock host. The returned `callbacks` implement every + * `RequiredHostCallbacks` capability; the accessor methods expose what the core + * did. + */ +export function createMockHost(config: MockHostConfig = {}): MockHost { + const { + devicePermissions = "allow-all", + remotePermissions = "allow-all", + featureSupported = true, + theme = "Dark", + confirmUserActions = true, + chainResponses = [], + chainClosed = false, + } = config; + + const storage = new Map(); + const preimages = new Map(); + const navigations: string[] = []; + const pushedNotifications: HostPushNotificationRequest[] = []; + const sentRpc: string[] = []; + const authStates: AuthState[] = []; + const confirmations: string[] = []; + const cancelledNotifications: number[] = []; + let nextNotificationId = 0; + + // Product keys are namespaced from core slots so neither can shadow the other. + // This in-JS key scheme is internal and independent from the Rust MockPlatform's + // (state never crosses the boundary), so the two need not match byte-for-byte. + const productKey = (key: string): string => `product:${key}`; + const coreKey = (key: CoreStorageKey): string => + key.tag === "PermissionAuthorization" + ? `core:permission:${key.value.productId}:${JSON.stringify(key.value.request)}` + : `core:${key.tag}`; + const granted = (policy: PermissionPolicy): boolean => policy === "allow-all"; + + // `RequiredHostCallbacks` (each capability wrapped in `Required<…>`): every + // optional callback must be present, so a capability added to the generated + // surface fails `tsc` here until the mock covers it. This is the load-bearing + // coverage guarantee. `createWasmRawCallbacks` accepts this nested shape. + const callbacks: RequiredHostCallbacks = { + productStorage: { + async read(key) { + return storage.get(productKey(key)); + }, + async write(key, value) { + storage.set(productKey(key), value); + }, + async clear(key) { + storage.delete(productKey(key)); + }, + }, + + coreStorage: { + async readCoreStorage(key) { + return storage.get(coreKey(key)); + }, + async writeCoreStorage(key, value) { + storage.set(coreKey(key), value); + }, + async clearCoreStorage(key) { + storage.delete(coreKey(key)); + }, + }, + + navigation: { + async navigateTo(url) { + navigations.push(url); + }, + }, + + notifications: { + async pushNotification(notification) { + pushedNotifications.push(notification); + return { id: nextNotificationId++ }; + }, + async cancelNotification(id) { + cancelledNotifications.push(id); + }, + }, + + permissions: { + async devicePermission() { + return { granted: granted(devicePermissions) }; + }, + async remotePermission() { + return { granted: granted(remotePermissions) }; + }, + }, + + features: { + async featureSupported() { + return { supported: featureSupported }; + }, + }, + + chain: { + async connect(): Promise { + return { + send(request) { + sentRpc.push(request); + }, + async *responses(): AsyncGenerator { + for (const frame of chainResponses) { + yield frame; + } + if (chainResponses.length === 0 && !chainClosed) { + // Silent: never yields, so chain-dependent flows park. `chainClosed` + // instead ends the stream here for fail-fast disconnect tests. + await new Promise(() => {}); + } + }, + // The mock holds no real transport, so releasing the lease is a no-op. + // Note: a Silent connection whose `responses()` stream is already parked + // stays parked after close() — tests that need the stream to terminate use + // `chainClosed` (or scripted frames), not close(). + close() {}, + }; + }, + }, + + auth: { + authStateChanged(state) { + authStates.push(state); + }, + }, + + userConfirmation: { + async confirmUserAction(review) { + confirmations.push(review.tag); + return confirmUserActions; + }, + }, + + theme: { + async *subscribeTheme(): AsyncGenerator< + Result + > { + yield ok(theme); + // A live subscription never ends: emit the current theme, then stay open. + await new Promise(() => {}); + }, + }, + + preimage: { + async *lookupPreimage( + key, + ): AsyncGenerator> { + yield ok(preimages.get(hex(key))); + // Stay open for future updates (none, in the mock). + await new Promise(() => {}); + }, + }, + }; + + return { + callbacks, + navigations: () => [...navigations], + pushedNotifications: () => [...pushedNotifications], + sentRpc: () => [...sentRpc], + authStates: () => [...authStates], + confirmations: () => [...confirmations], + cancelledNotifications: () => [...cancelledNotifications], + insertPreimage(value) { + const key = preimageKey(value); + preimages.set(hex(key), value); + return key; + }, + }; +} + +/** + * A default {@link ProductRuntimeConfig} for a mock host. Override any field; + * the genesis hashes and product id are placeholders suitable for tests. + */ +export function mockRuntimeConfig( + overrides: Partial = {}, +): ProductRuntimeConfig { + return { + productId: "mock.dot", + host: { + name: "Mock Host", + icon: "https://example.invalid/mock.png", + version: "0.0.0", + }, + platform: { + type: "node", + version: "0", + }, + people: { + genesisHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + bulletin: { + genesisHash: + "0x0000000000000000000000000000000000000000000000000000000000000000", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts index acba3a71..74c40ee9 100644 --- a/js/packages/truapi-host/src/web/index.ts +++ b/js/packages/truapi-host/src/web/index.ts @@ -7,3 +7,9 @@ export type { WorkerPairingHostRuntime, } from "./create-worker-host-runtime.js"; export { createWebWorkerPairingHostRuntime } from "./create-worker-host-runtime.js"; +export { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; +export type { + MockHost, + MockHostConfig, + PermissionPolicy, +} from "./create-mock-host.js"; diff --git a/js/packages/truapi-host/src/web/wasm-bridge.test.ts b/js/packages/truapi-host/src/web/wasm-bridge.test.ts new file mode 100644 index 00000000..38f46cb0 --- /dev/null +++ b/js/packages/truapi-host/src/web/wasm-bridge.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { existsSync, readFileSync } from "node:fs"; + +import { createMockHost, mockRuntimeConfig } from "./create-mock-host.js"; + +// Drives the REAL truapi-server WASM core against createMockHost's callbacks — +// headless, no browser, no worker — to prove the JS↔SCALE↔WASM callback bridge. +// Requires the built WASM artifact (`npm run build:wasm`); skipped when it is +// absent so a plain `bun test` on a fresh checkout stays green. The `host-wasm` +// CI job builds the WASM and runs this suite. +const wasmUrl = new URL( + "../../dist/wasm/web/truapi_server_bg.wasm", + import.meta.url, +); +const glueUrl = new URL( + "../../dist/wasm/web/truapi_server.js", + import.meta.url, +); +const built = existsSync(wasmUrl); + +// The `host-wasm` CI job builds the WASM first and sets REQUIRE_WASM=1, so a +// missing artifact (a silent `build:wasm` path/output drift) fails loudly here +// instead of skipping green. A plain local `bun test` leaves REQUIRE_WASM unset +// and skips this suite cleanly on a fresh checkout. +if (process.env.REQUIRE_WASM === "1" && !built) { + throw new Error( + `REQUIRE_WASM=1 but the WASM artifact is missing at ${wasmUrl.pathname} — run \`npm run build:wasm\` first.`, + ); +} + +const suite = built ? describe : describe.skip; + +suite("real WASM core ↔ createMockHost bridge", () => { + it("the core invokes createMockHost callbacks across the JS↔SCALE↔WASM boundary", async () => { + const { initSync, WasmPairingHostRuntime } = await import(glueUrl.href); + const { createWasmRawCallbacks } = + await import("../generated/host-callbacks-adapter.js"); + initSync({ module: readFileSync(wasmUrl) }); + + const mock = createMockHost(); + const invoked: string[] = []; + const coreStorage = mock.callbacks.coreStorage; + const readCoreStorage = coreStorage.readCoreStorage.bind(coreStorage); + coreStorage.readCoreStorage = async (key) => { + invoked.push(`readCoreStorage:${key.tag}`); + return readCoreStorage(key); + }; + + // The pairing-host runtime takes the platform callbacks and host config; + // the per-product core is derived from it with its own frame sink. + const raw = createWasmRawCallbacks(mock.callbacks); + const { productId, ...hostConfig } = mockRuntimeConfig(); + const runtime = new WasmPairingHostRuntime(raw, hostConfig); + // The core emits response frames through `emitFrame`; the worker supplies + // it per-core outside the generated adapter, so the harness does too. + runtime.productRuntime({ productId }, { emitFrame: () => {} }); + // The real core reads its auth session on startup, which crosses the bridge + // into the mock's readCoreStorage with a SCALE-decoded CoreStorageKey. + await new Promise((resolve) => setTimeout(resolve, 200)); + + expect(invoked.some((c) => c.startsWith("readCoreStorage:"))).toBe(true); + }); +}); diff --git a/rust/crates/truapi-platform/Cargo.toml b/rust/crates/truapi-platform/Cargo.toml index cfbd0fc4..5f53b17c 100644 --- a/rust/crates/truapi-platform/Cargo.toml +++ b/rust/crates/truapi-platform/Cargo.toml @@ -5,6 +5,10 @@ edition.workspace = true description = "Platform capability traits for TrUAPI host implementations" license = "MIT" +[features] +# In-memory `MockPlatform` for tests and host simulators. +mock = [] + [dependencies] truapi = { path = "../truapi" } async-trait = "0.1" diff --git a/rust/crates/truapi-platform/README.md b/rust/crates/truapi-platform/README.md index 25067c05..52ceca39 100644 --- a/rust/crates/truapi-platform/README.md +++ b/rust/crates/truapi-platform/README.md @@ -27,7 +27,9 @@ exposed through the trait signatures below. - `UserConfirmation`: confirm signing, transaction, resource, alias, and preimage actions before the core asks the paired wallet. - `ThemeHost`: stream the host theme into the runtime. -- `PreimageHost`: submit and look up preimages through the host-selected backend. +- `PreimageHost`: look up preimage content through the host-selected backend. + The core builds, signs, and submits the Bulletin `TransactionStorage.store` + transaction itself; the host only owns content retrieval. `Platform` is a blanket-implemented supertrait that combines the capability traits above. @@ -37,3 +39,19 @@ traits above. `CoreAdmin` is not part of the host-provided `Platform` callback surface. It is the core-owned control API exposed to host UI for logout, pairing cancellation, session-store refresh, and permission administration. + +## Mock platform (`mock` feature) + +The `mock` feature — excluded from the default and production builds — provides +`MockPlatform`, a config-driven, in-memory implementation of all the capability +traits above. It is the canonical seam mock: plug it into the real +`truapi-server` core to exercise the production dispatcher without a device or a +paired wallet. + +`MockConfig` drives its behavior — `PermissionPolicy` (`AllowAll`/`DenyAll`, device +and remote separately), `ChainBehavior` (`Silent` | `Scripted` | `Closed` | +`ConnectError`), `MockFaults` for fault injection, and confirmation control — and it +records what the core asked the device to do (navigations, notifications, +confirmations, auth-state transitions, sent RPC) for assertions. Storage is +namespaced in-memory (`product:` / `core:`); seed retrievable preimage content with +`MockPlatform::insert_preimage`, then read it back through `lookup_preimage`. diff --git a/rust/crates/truapi-platform/src/lib.rs b/rust/crates/truapi-platform/src/lib.rs index 29a071fb..7918b8a3 100644 --- a/rust/crates/truapi-platform/src/lib.rs +++ b/rust/crates/truapi-platform/src/lib.rs @@ -15,6 +15,10 @@ use unicode_normalization::UnicodeNormalization; pub use async_trait::async_trait; +/// In-memory mock platform for tests and host simulators (enable the `mock` feature). +#[cfg(feature = "mock")] +pub mod mock; + use truapi::latest::{ GenericError, HostDevicePermissionRequest, HostDevicePermissionResponse, HostFeatureSupportedRequest, HostFeatureSupportedResponse, HostLocalStorageReadError, diff --git a/rust/crates/truapi-platform/src/mock.rs b/rust/crates/truapi-platform/src/mock.rs new file mode 100644 index 00000000..ef0f003a --- /dev/null +++ b/rust/crates/truapi-platform/src/mock.rs @@ -0,0 +1,904 @@ +//! In-memory mock [`Platform`](crate::Platform) for tests and host simulators. +//! +//! `MockPlatform` implements every capability trait with deterministic, +//! configurable behavior and no OS, device, or network dependency: storage is +//! an in-memory map, permission prompts answer from a fixed per-capability +//! policy (no UI), navigation and notifications are recorded, and chain access +//! returns a configurable connection. Because the protocol logic lives in +//! `truapi-server`, a `MockPlatform` wired into the core yields a faithful host +//! whose only mocked surface is the OS-primitive seam. +//! +//! Behavior is a [`MockConfig`] read on every call: per-capability permission +//! policy, feature support, theme, confirmation answer, [`ChainBehavior`], and +//! [`MockFaults`] error injection. Recordings (`navigations`, +//! `pushed_notifications`, `confirmations`, `auth_states`, `sent_rpc`, …) are +//! the test oracles. +//! +//! Signing and login require a paired wallet answering over the statement-store +//! channel. With [`ChainBehavior::Silent`] the chain connection records +//! outbound requests and never answers, so those flows park; use +//! [`ChainBehavior::Scripted`] to feed canned response frames. +//! +//! Preimage submission is core-owned on current core (the core builds, signs, +//! and submits the Bulletin `TransactionStorage.store` transaction itself), so +//! the mock only implements host-side content retrieval via `lookup_preimage`. +//! Seed retrievable content with [`MockPlatform::insert_preimage`]. + +use std::collections::HashMap; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::StreamExt; +use futures::stream::{self, BoxStream}; + +use truapi::latest; + +use crate::async_trait; +use crate::{ + AuthPresenter, AuthState, ChainProvider, CoreStorage, CoreStorageKey, Features, + JsonRpcConnection, Navigation, Notifications, Permissions, PreimageHost, ProductStorage, + ThemeHost, UserConfirmation, UserConfirmationReview, +}; + +/// How the mock answers a permission prompt for one capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PermissionPolicy { + /// Grant without prompting. + #[default] + AllowAll, + /// Deny. + DenyAll, +} + +impl PermissionPolicy { + fn granted(self) -> bool { + matches!(self, PermissionPolicy::AllowAll) + } +} + +/// The kind of action the core asked the host to confirm. Recorded on every +/// `confirm_user_action` so tests can assert what the core tried to do +/// (e.g. that a sign-payload review fired) even when the chain parks afterward. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmKind { + /// [`UserConfirmationReview::SignPayload`]. + SignPayload, + /// [`UserConfirmationReview::SignRaw`]. + SignRaw, + /// [`UserConfirmationReview::CreateTransaction`]. + CreateTransaction, + /// [`UserConfirmationReview::AccountAlias`]. + AccountAlias, + /// [`UserConfirmationReview::CreateProof`]. + CreateProof, + /// [`UserConfirmationReview::ResourceAllocation`]. + ResourceAllocation, + /// [`UserConfirmationReview::PreimageSubmit`]. + PreimageSubmit, + /// [`UserConfirmationReview::IdentityDisclosure`]. + IdentityDisclosure, + /// [`UserConfirmationReview::AccountAccess`]. + AccountAccess, +} + +impl ConfirmKind { + fn of(review: &UserConfirmationReview) -> Self { + match review { + UserConfirmationReview::SignPayload(_) => ConfirmKind::SignPayload, + UserConfirmationReview::SignRaw(_) => ConfirmKind::SignRaw, + UserConfirmationReview::CreateTransaction(_) => ConfirmKind::CreateTransaction, + UserConfirmationReview::AccountAlias(_) => ConfirmKind::AccountAlias, + UserConfirmationReview::CreateProof(_) => ConfirmKind::CreateProof, + UserConfirmationReview::ResourceAllocation(_) => ConfirmKind::ResourceAllocation, + UserConfirmationReview::PreimageSubmit(_) => ConfirmKind::PreimageSubmit, + UserConfirmationReview::IdentityDisclosure(_) => ConfirmKind::IdentityDisclosure, + UserConfirmationReview::AccountAccess(_) => ConfirmKind::AccountAccess, + } + } +} + +/// How the mock's chain connection behaves. +#[derive(Debug, Clone, Default)] +pub enum ChainBehavior { + /// Record outbound requests, never answer. Chain-dependent flows (login, + /// signing, statement store) park rather than complete, so drive any test + /// that reaches them under a timeout; use [`ChainBehavior::Closed`] to make + /// a disconnect observable instead. + #[default] + Silent, + /// Record outbound requests and replay these response frames in order, + /// then end the stream. + Scripted(Vec), + /// Record outbound requests; the response stream ends immediately, so + /// disconnect/timeout paths can be asserted (fail-fast) rather than parked. + Closed, + /// `connect` fails with this reason. + ConnectError(String), +} + +/// Optional error injection. When a field is `Some`, the matching host call +/// returns that error instead of succeeding, exercising the core's +/// error-handling paths. +#[derive(Debug, Clone, Default)] +pub struct MockFaults { + /// Product and core storage reads/writes/clears fail with this reason. + pub storage_error: Option, + /// `navigate_to` fails with this reason. + pub navigate_error: Option, + /// `push_notification` fails with this reason. + pub notification_error: Option, +} + +/// Behavior knobs for [`MockPlatform`], read on every call. +#[derive(Debug, Clone)] +pub struct MockConfig { + /// Answer for `device_permission`. + pub device_permissions: PermissionPolicy, + /// Answer for `remote_permission`. + pub remote_permissions: PermissionPolicy, + /// Whether `feature_supported` reports support. + pub feature_supported: bool, + /// Theme emitted by `subscribe_theme`. + pub theme: latest::ThemeVariant, + /// Whether `confirm_user_action` confirms reviewed actions. + pub confirm_user_actions: bool, + /// Chain connection behavior. + pub chain: ChainBehavior, + /// Error injection. + pub faults: MockFaults, +} + +impl Default for MockConfig { + fn default() -> Self { + Self { + device_permissions: PermissionPolicy::AllowAll, + remote_permissions: PermissionPolicy::AllowAll, + feature_supported: true, + theme: latest::ThemeVariant::Dark, + confirm_user_actions: true, + chain: ChainBehavior::Silent, + faults: MockFaults::default(), + } + } +} + +/// In-memory mock host platform. Cheap to `clone`; clones share recordings and +/// storage (state is `Arc`ed), so a recording made through one clone is visible +/// through another. +#[derive(Clone)] +pub struct MockPlatform { + config: Arc, + storage: Arc>>>, + preimages: Arc, Vec>>>, + navigations: Arc>>, + notifications: Arc>>, + cancelled_notifications: Arc>>, + confirmations: Arc>>, + auth_states: Arc>>, + sent_rpc: Arc>>, + next_notification_id: Arc, +} + +impl Default for MockPlatform { + fn default() -> Self { + Self::new() + } +} + +impl MockPlatform { + /// Build a mock platform with default behavior (allow-all permissions, + /// feature support on, dark theme, auto-confirm, silent chain, no faults). + pub fn new() -> Self { + Self::with_config(MockConfig::default()) + } + + /// Build a mock platform with explicit behavior. + pub fn with_config(config: MockConfig) -> Self { + Self { + config: Arc::new(config), + storage: Arc::new(Mutex::new(HashMap::new())), + preimages: Arc::new(Mutex::new(HashMap::new())), + navigations: Arc::new(Mutex::new(Vec::new())), + notifications: Arc::new(Mutex::new(Vec::new())), + cancelled_notifications: Arc::new(Mutex::new(Vec::new())), + confirmations: Arc::new(Mutex::new(Vec::new())), + auth_states: Arc::new(Mutex::new(Vec::new())), + sent_rpc: Arc::new(Mutex::new(Vec::new())), + next_notification_id: Arc::new(AtomicU32::new(0)), + } + } + + /// URLs the core asked the host to open, in order. + pub fn navigations(&self) -> Vec { + self.navigations + .lock() + .expect("navigations poisoned") + .clone() + } + + /// Notifications the core asked the host to show, in order. + pub fn pushed_notifications(&self) -> Vec { + self.notifications + .lock() + .expect("notifications poisoned") + .clone() + } + + /// Notification ids the core asked the host to cancel, in order. + pub fn cancelled_notifications(&self) -> Vec { + self.cancelled_notifications + .lock() + .expect("cancellations poisoned") + .clone() + } + + /// Confirmation kinds the core requested, in order. + pub fn confirmations(&self) -> Vec { + self.confirmations + .lock() + .expect("confirmations poisoned") + .clone() + } + + /// Auth state transitions the core emitted, in order. + pub fn auth_states(&self) -> Vec { + self.auth_states + .lock() + .expect("auth states poisoned") + .clone() + } + + /// Raw JSON-RPC requests the core sent over the chain connection. + pub fn sent_rpc(&self) -> Vec { + self.sent_rpc.lock().expect("sent rpc poisoned").clone() + } + + /// Seed a preimage so a later [`PreimageHost::lookup_preimage`] resolves it. + /// + /// On main the core (not the host) builds, signs, and submits the Bulletin + /// `TransactionStorage.store` transaction; the host's only preimage duty is + /// content retrieval. This helper lets tests and host simulators pre-load + /// the content-addressed store the mock's `lookup_preimage` reads from, and + /// returns the deterministic lookup key for the value. + pub fn insert_preimage(&self, value: Vec) -> Vec { + let key = preimage_key(&value); + self.preimages + .lock() + .expect("preimages poisoned") + .insert(key.clone(), value); + key + } +} + +/// Product keys are namespaced from core slots so neither can shadow the other. +fn product_key(key: &str) -> String { + format!("product:{key}") +} + +/// Stable string key for a typed core-storage slot. +fn core_key(key: &CoreStorageKey) -> String { + match key { + CoreStorageKey::AuthSession => "core:auth-session".to_string(), + CoreStorageKey::PairingDeviceIdentity => "core:pairing-device-identity".to_string(), + CoreStorageKey::PermissionAuthorization { + product_id, + request, + } => format!("core:permission:{product_id}:{request:?}"), + CoreStorageKey::AllowanceKeys { session_id } => { + format!("core:allowance-keys:{session_id}") + } + CoreStorageKey::LastProcessedPairingStatement => { + "core:last-processed-pairing-statement".to_string() + } + } +} + +/// Deterministic short key for a preimage value, so `submit` then `lookup` +/// round-trips without storing the full value as its own key. +fn preimage_key(value: &[u8]) -> Vec { + let mut hasher = DefaultHasher::new(); + value.hash(&mut hasher); + hasher.finish().to_le_bytes().to_vec() +} + +#[async_trait] +impl ProductStorage for MockPlatform { + async fn read( + &self, + key: String, + ) -> Result>, latest::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + Ok(self + .storage + .lock() + .expect("storage poisoned") + .get(&product_key(&key)) + .cloned()) + } + + async fn write( + &self, + key: String, + value: Vec, + ) -> Result<(), latest::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .insert(product_key(&key), value); + Ok(()) + } + + async fn clear(&self, key: String) -> Result<(), latest::HostLocalStorageReadError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::HostLocalStorageReadError::Unknown { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .remove(&product_key(&key)); + Ok(()) + } +} + +#[async_trait] +impl CoreStorage for MockPlatform { + async fn read_core_storage( + &self, + key: CoreStorageKey, + ) -> Result>, latest::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::GenericError { + reason: reason.clone(), + }); + } + Ok(self + .storage + .lock() + .expect("storage poisoned") + .get(&core_key(&key)) + .cloned()) + } + + async fn write_core_storage( + &self, + key: CoreStorageKey, + value: Vec, + ) -> Result<(), latest::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::GenericError { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .insert(core_key(&key), value); + Ok(()) + } + + async fn clear_core_storage(&self, key: CoreStorageKey) -> Result<(), latest::GenericError> { + if let Some(reason) = &self.config.faults.storage_error { + return Err(latest::GenericError { + reason: reason.clone(), + }); + } + self.storage + .lock() + .expect("storage poisoned") + .remove(&core_key(&key)); + Ok(()) + } +} + +#[async_trait] +impl Navigation for MockPlatform { + async fn navigate_to(&self, url: String) -> Result<(), latest::HostNavigateToError> { + if let Some(reason) = &self.config.faults.navigate_error { + return Err(latest::HostNavigateToError::Unknown { + reason: reason.clone(), + }); + } + self.navigations + .lock() + .expect("navigations poisoned") + .push(url); + Ok(()) + } +} + +#[async_trait] +impl Notifications for MockPlatform { + async fn push_notification( + &self, + notification: latest::HostPushNotificationRequest, + ) -> Result { + if let Some(reason) = &self.config.faults.notification_error { + return Err(latest::GenericError { + reason: reason.clone(), + }); + } + self.notifications + .lock() + .expect("notifications poisoned") + .push(notification); + let id = self.next_notification_id.fetch_add(1, Ordering::SeqCst); + Ok(latest::HostPushNotificationResponse { id }) + } + + async fn cancel_notification( + &self, + id: latest::NotificationId, + ) -> Result<(), latest::GenericError> { + self.cancelled_notifications + .lock() + .expect("cancellations poisoned") + .push(id); + Ok(()) + } +} + +#[async_trait] +impl Permissions for MockPlatform { + async fn device_permission( + &self, + _request: latest::HostDevicePermissionRequest, + ) -> Result { + Ok(latest::HostDevicePermissionResponse { + granted: self.config.device_permissions.granted(), + }) + } + + async fn remote_permission( + &self, + _request: latest::RemotePermissionRequest, + ) -> Result { + Ok(latest::RemotePermissionResponse { + granted: self.config.remote_permissions.granted(), + }) + } +} + +#[async_trait] +impl Features for MockPlatform { + async fn feature_supported( + &self, + _request: latest::HostFeatureSupportedRequest, + ) -> Result { + Ok(latest::HostFeatureSupportedResponse { + supported: self.config.feature_supported, + }) + } +} + +/// A configurable chain connection: records outbound requests, and either +/// stays silent (`responses` `None`) or replays canned frames. +struct MockConnection { + sent: Arc>>, + responses: Option>, +} + +impl JsonRpcConnection for MockConnection { + fn send(&self, request: String) { + self.sent.lock().expect("sent rpc poisoned").push(request); + } + + fn responses(&self) -> BoxStream<'static, String> { + match &self.responses { + None => Box::pin(stream::pending()), + Some(frames) => Box::pin(stream::iter(frames.clone())), + } + } + + // No real transport to release. A `None` (silent) `responses()` stream stays + // pending after close(); scripted/`Closed` behaviors terminate on their own. + fn close(&self) {} +} + +#[async_trait] +impl ChainProvider for MockPlatform { + async fn connect( + &self, + _genesis_hash: [u8; 32], + ) -> Result, latest::GenericError> { + match &self.config.chain { + ChainBehavior::ConnectError(reason) => Err(latest::GenericError { + reason: reason.clone(), + }), + ChainBehavior::Silent => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: None, + })), + ChainBehavior::Scripted(frames) => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: Some(frames.clone()), + })), + ChainBehavior::Closed => Ok(Box::new(MockConnection { + sent: self.sent_rpc.clone(), + responses: Some(Vec::new()), + })), + } + } +} + +impl AuthPresenter for MockPlatform { + fn auth_state_changed(&self, state: AuthState) { + self.auth_states + .lock() + .expect("auth states poisoned") + .push(state); + } +} + +#[async_trait] +impl UserConfirmation for MockPlatform { + async fn confirm_user_action( + &self, + review: UserConfirmationReview, + ) -> Result { + self.confirmations + .lock() + .expect("confirmations poisoned") + .push(ConfirmKind::of(&review)); + Ok(self.config.confirm_user_actions) + } +} + +impl ThemeHost for MockPlatform { + fn subscribe_theme( + &self, + ) -> BoxStream<'static, Result> { + let theme = self.config.theme; + // Emit the current theme, then stay open (a live subscription never + // ends), matching the real host contract. + Box::pin( + stream::once(async move { Ok::(theme) }) + .chain(stream::pending::< + Result, + >()), + ) + } +} + +impl PreimageHost for MockPlatform { + fn lookup_preimage( + &self, + key: Vec, + ) -> BoxStream<'static, Result>, latest::GenericError>> { + let found = self + .preimages + .lock() + .expect("preimages poisoned") + .get(&key) + .cloned(); + // Emit the current value/miss, then stay open — a live subscription that + // never ends, matching `subscribe_theme`, the trait doc, and the JS mock. + Box::pin( + stream::once(async move { Ok(found) }).chain(stream::pending::< + Result>, latest::GenericError>, + >()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + use futures::executor::block_on; + + fn resource_review() -> UserConfirmationReview { + UserConfirmationReview::ResourceAllocation(latest::HostRequestResourceAllocationRequest { + resources: vec![], + }) + } + + #[test] + fn implements_platform() { + fn assert_platform(_: &P) {} + assert_platform(&MockPlatform::new()); + } + + #[test] + fn product_storage_round_trips_and_is_namespaced() { + let p = MockPlatform::new(); + block_on(p.write("k".into(), vec![1, 2, 3])).unwrap(); + assert_eq!(block_on(p.read("k".into())).unwrap(), Some(vec![1, 2, 3])); + // A product key never collides with a core slot. + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + None + ); + block_on(p.clear("k".into())).unwrap(); + assert_eq!(block_on(p.read("k".into())).unwrap(), None); + } + + #[test] + fn core_storage_round_trips() { + let p = MockPlatform::new(); + block_on(p.write_core_storage(CoreStorageKey::AuthSession, vec![7])).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + Some(vec![7]) + ); + block_on(p.clear_core_storage(CoreStorageKey::AuthSession)).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::AuthSession)).unwrap(), + None + ); + } + + #[test] + fn core_and_product_keys_do_not_collide() { + let p = MockPlatform::new(); + block_on(p.write_core_storage(CoreStorageKey::AuthSession, vec![1])).unwrap(); + // Reading the same logical name as a product key must miss the core slot. + assert_eq!(block_on(p.read("auth-session".into())).unwrap(), None); + assert_eq!(block_on(p.read("core:auth-session".into())).unwrap(), None); + // ...and a product key must not be visible through core storage. + block_on(p.write("x".into(), vec![2])).unwrap(); + assert_eq!( + block_on(p.read_core_storage(CoreStorageKey::PairingDeviceIdentity)).unwrap(), + None + ); + } + + #[test] + fn permissions_deny_all_denies_device_and_remote() { + let p = MockPlatform::with_config(MockConfig { + device_permissions: PermissionPolicy::DenyAll, + remote_permissions: PermissionPolicy::DenyAll, + ..Default::default() + }); + assert!( + !block_on(p.device_permission(latest::HostDevicePermissionRequest::Notifications)) + .unwrap() + .granted + ); + assert!( + !block_on(p.remote_permission(latest::RemotePermissionRequest { + permission: latest::RemotePermission::WebRtc + })) + .unwrap() + .granted + ); + } + + #[test] + fn permissions_split_allows_device_denies_remote() { + let p = MockPlatform::with_config(MockConfig { + device_permissions: PermissionPolicy::AllowAll, + remote_permissions: PermissionPolicy::DenyAll, + ..Default::default() + }); + assert!( + block_on(p.device_permission(latest::HostDevicePermissionRequest::Notifications)) + .unwrap() + .granted + ); + assert!( + !block_on(p.remote_permission(latest::RemotePermissionRequest { + permission: latest::RemotePermission::WebRtc + })) + .unwrap() + .granted + ); + } + + #[test] + fn remote_permission_granted_when_allowed() { + let p = MockPlatform::with_config(MockConfig { + remote_permissions: PermissionPolicy::AllowAll, + ..Default::default() + }); + assert!( + block_on(p.remote_permission(latest::RemotePermissionRequest { + permission: latest::RemotePermission::WebRtc + })) + .unwrap() + .granted + ); + } + + #[test] + fn navigation_records_and_can_error() { + let p = MockPlatform::new(); + block_on(p.navigate_to("a".into())).unwrap(); + block_on(p.navigate_to("b".into())).unwrap(); + assert_eq!(p.navigations(), vec!["a".to_string(), "b".to_string()]); + + let p2 = MockPlatform::with_config(MockConfig { + faults: MockFaults { + navigate_error: Some("blocked".into()), + ..Default::default() + }, + ..Default::default() + }); + assert!(block_on(p2.navigate_to("c".into())).is_err()); + } + + #[test] + fn notifications_record_order_with_unique_ids() { + let p = MockPlatform::new(); + let make = |text: &str| latest::HostPushNotificationRequest { + text: text.to_string(), + deeplink: None, + scheduled_at: None, + }; + let id0 = block_on(p.push_notification(make("one"))).unwrap().id; + let id1 = block_on(p.push_notification(make("two"))).unwrap().id; + assert_eq!((id0, id1), (0, 1)); + assert_eq!(p.pushed_notifications().len(), 2); + block_on(p.cancel_notification(id1)).unwrap(); + assert_eq!(p.cancelled_notifications(), vec![1u32]); + } + + #[test] + fn notification_error_injected() { + let p = MockPlatform::with_config(MockConfig { + faults: MockFaults { + notification_error: Some("denied".into()), + ..Default::default() + }, + ..Default::default() + }); + let request = latest::HostPushNotificationRequest { + text: "x".into(), + deeplink: None, + scheduled_at: None, + }; + assert!(block_on(p.push_notification(request)).is_err()); + } + + #[test] + fn storage_error_injected() { + let p = MockPlatform::with_config(MockConfig { + faults: MockFaults { + storage_error: Some("disk".into()), + ..Default::default() + }, + ..Default::default() + }); + assert!(block_on(p.read("k".into())).is_err()); + assert!(block_on(p.read_core_storage(CoreStorageKey::AuthSession)).is_err()); + } + + #[test] + fn confirm_records_kind_and_answers() { + let p = MockPlatform::new(); + assert!(block_on(p.confirm_user_action(resource_review())).unwrap()); + assert_eq!(p.confirmations(), vec![ConfirmKind::ResourceAllocation]); + + let p2 = MockPlatform::with_config(MockConfig { + confirm_user_actions: false, + ..Default::default() + }); + assert!(!block_on(p2.confirm_user_action(resource_review())).unwrap()); + } + + #[test] + fn feature_supported_reflects_config() { + let p = MockPlatform::with_config(MockConfig { + feature_supported: false, + ..Default::default() + }); + let response = block_on( + p.feature_supported(latest::HostFeatureSupportedRequest::Chain { + genesis_hash: vec![0; 32], + }), + ) + .unwrap(); + assert!(!response.supported); + } + + #[test] + fn theme_emits_configured_variant_then_stays_open() { + let p = MockPlatform::new(); + let mut stream = p.subscribe_theme(); + assert_eq!( + block_on(stream.next()).unwrap().unwrap(), + latest::ThemeVariant::Dark + ); + // A live subscription does not end after the current value. + assert!(stream.next().now_or_never().is_none()); + } + + #[test] + fn theme_emits_configured_light() { + let p = MockPlatform::with_config(MockConfig { + theme: latest::ThemeVariant::Light, + ..Default::default() + }); + assert_eq!( + block_on(p.subscribe_theme().next()).unwrap().unwrap(), + latest::ThemeVariant::Light + ); + } + + #[test] + fn preimage_insert_then_lookup_round_trips() { + let p = MockPlatform::new(); + // The core owns Bulletin submission on main; the host only retrieves + // content, so tests seed the content-addressed store directly. + let key = p.insert_preimage(vec![1, 2, 3]); + let found = block_on(p.lookup_preimage(key).next()).unwrap().unwrap(); + assert_eq!(found, Some(vec![1, 2, 3])); + // An unknown key misses. + let miss = block_on(p.lookup_preimage(vec![9, 9, 9, 9, 9, 9, 9, 9]).next()) + .unwrap() + .unwrap(); + assert_eq!(miss, None); + } + + #[test] + fn chain_silent_records_sends_and_parks() { + let p = MockPlatform::new(); + let conn = block_on(p.connect([0u8; 32])).unwrap(); + conn.send("req-1".to_string()); + assert_eq!(p.sent_rpc(), vec!["req-1".to_string()]); + // Silent: the response stream never yields (parks rather than ends). + assert!(conn.responses().next().now_or_never().is_none()); + } + + #[test] + fn chain_scripted_replays_frames() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::Scripted(vec!["frame-1".into(), "frame-2".into()]), + ..Default::default() + }); + let conn = block_on(p.connect([0u8; 32])).unwrap(); + let frames: Vec = block_on(conn.responses().collect()); + assert_eq!(frames, vec!["frame-1".to_string(), "frame-2".to_string()]); + } + + #[test] + fn chain_closed_ends_stream_immediately() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::Closed, + ..Default::default() + }); + let conn = block_on(p.connect([0u8; 32])).unwrap(); + // Closed ends at once (None), so disconnect paths fail fast instead of + // parking like Silent. + assert!(block_on(conn.responses().next()).is_none()); + } + + #[test] + fn chain_connect_error() { + let p = MockPlatform::with_config(MockConfig { + chain: ChainBehavior::ConnectError("offline".into()), + ..Default::default() + }); + assert!(block_on(p.connect([0u8; 32])).is_err()); + } + + #[test] + fn auth_states_record_in_order() { + let p = MockPlatform::new(); + p.auth_state_changed(AuthState::Disconnected); + p.auth_state_changed(AuthState::Pairing { + deeplink: "dl".into(), + }); + assert_eq!(p.auth_states().len(), 2); + } + + #[test] + fn clone_shares_recordings() { + let p = MockPlatform::new(); + let clone = p.clone(); + block_on(clone.navigate_to("z".into())).unwrap(); + assert_eq!(p.navigations(), vec!["z".to_string()]); + } +}