Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,50 @@ jobs:
- name: Test
run: npm test --prefix js/packages/truapi

host-wasm:
name: '@parity/truapi-host-wasm'
runs-on: ubuntu-latest
needs: codegen
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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 --locked

- name: Build WASM
run: npm run build:wasm --prefix js/packages/truapi-host-wasm

- name: Test
run: bun test
working-directory: js/packages/truapi-host-wasm

playground:
name: Playground (build + lint)
runs-on: ubuntu-latest
Expand Down Expand Up @@ -279,7 +323,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: |
Expand All @@ -288,6 +332,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 }}"
Expand Down
4 changes: 2 additions & 2 deletions js/packages/truapi-host-wasm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@
"test": "bun test"
},
"dependencies": {
"@parity/truapi": "file:../truapi"
"@parity/truapi": "file:../truapi",
"neverthrow": "^8.2.0"
},
"devDependencies": {
"@types/bun": "^1.3.0",
"neverthrow": "^8.2.0",
"typescript": "^5.7"
}
}
168 changes: 168 additions & 0 deletions js/packages/truapi-host-wasm/src/web/create-mock-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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 { createWebWorkerProvider } from "./index.js";

describe("createMockHost callbacks", () => {
it("product storage round-trips and is namespaced from core", async () => {
const { callbacks } = createMockHost();
await callbacks.write("k", new Uint8Array([1, 2, 3]));
expect(await callbacks.read("k")).toEqual(new Uint8Array([1, 2, 3]));
// A product key never collides with a core slot.
expect(await callbacks.readCoreStorage({ tag: "AuthSession" })).toBeUndefined();
await callbacks.clear("k");
expect(await callbacks.read("k")).toBeUndefined();
});

it("core storage round-trips per slot", async () => {
const { callbacks } = createMockHost();
const key: CoreStorageKey = {
tag: "PermissionAuthorization",
value: { storageKey: "cam" },
};
await callbacks.writeCoreStorage(key, new Uint8Array([9]));
expect(await callbacks.readCoreStorage(key)).toEqual(new Uint8Array([9]));
await callbacks.clearCoreStorage(key);
expect(await callbacks.readCoreStorage(key)).toBeUndefined();
});

it("permissions follow per-capability policy", async () => {
const { callbacks } = createMockHost({
devicePermissions: "allow-all",
remotePermissions: "deny-all",
});
expect((await callbacks.devicePermission("Notifications")).granted).toBe(true);
expect((await callbacks.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.featureSupported({
tag: "Chain",
value: { genesisHash: "0x00" },
})
).supported,
).toBe(false);
const theme = await callbacks.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.navigateTo("https://a");
await host.callbacks.navigateTo("https://b");
expect(host.navigations()).toEqual(["https://a", "https://b"]);

const first = await host.callbacks.pushNotification({ text: "one" });
const second = await host.callbacks.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.confirmUserAction?.({
tag: "ResourceAllocation",
value: { resources: [] },
}),
).toBe(false);

const host = createMockHost();
const conn = await host.callbacks.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.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.confirmUserAction?.({
tag: "ResourceAllocation",
value: { resources: [] },
});
expect(host.confirmations()).toEqual(["ResourceAllocation"]);

const { id } = await host.callbacks.pushNotification({ text: "x" });
await host.callbacks.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.connect(new Uint8Array(32));
const first = await conn.responses()[Symbol.asyncIterator]().next();
expect(first.done).toBe(true);
});

it("preimage submit then lookup round-trips", async () => {
const { callbacks } = createMockHost();
const key = await callbacks.submitPreimage?.(new Uint8Array([4, 5, 6]));
expect(key).toBeDefined();
const found = await callbacks.lookupPreimage(key!)[Symbol.asyncIterator]().next();
expect(found.value).toEqual(ok(new Uint8Array([4, 5, 6])));
});
});

/** 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<string, Set<(event: unknown) => void>>();
messages: Record<string, unknown>[] = [];

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<string, unknown>) {
this.messages.push(message);
}

terminate() {}

emit(message: Record<string, unknown>) {
for (const listener of this.listeners.get("message") ?? []) {
listener({ data: message });
}
}
}

describe("createMockHost with createWebWorkerProvider", () => {
it("initializes a worker provider with the mock callbacks (no real WASM)", async () => {
const worker = new FakeWorker();
const host = createMockHost();
const providerPromise = createWebWorkerProvider(
worker as unknown as Worker,
host.callbacks,
{ runtimeConfig: mockRuntimeConfig() },
);
worker.emit({ kind: "loaded" });
worker.emit({ kind: "ready" });

const provider = await providerPromise;
expect(provider).toBeDefined();
const init = worker.messages.find((message) => message.kind === "init");
expect(init).toBeDefined();
});
});
Loading
Loading