diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index b462528b..6b8e2f61 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -79,6 +79,7 @@ "typescript": "^6.0" }, "dependencies": { + "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" } diff --git a/js/packages/truapi/src/scale.ts b/js/packages/truapi/src/scale.ts index ac92039b..f5670ffa 100644 --- a/js/packages/truapi/src/scale.ts +++ b/js/packages/truapi/src/scale.ts @@ -8,17 +8,25 @@ import { Bytes, Enum, + Struct, + _void, createCodec, createDecoder, enhanceCodec, + str as scaleStr, u8, type Codec, } from "scale-ts"; +import { + bytesToHex as encodeHex, + hexToBytes as decodeHex, +} from "@noble/hashes/utils.js"; export type { Codec }; export type { ResultPayload } from "scale-ts"; export { + Bytes, Enum, Option, Result, @@ -59,7 +67,9 @@ export const OptionBool: Codec = enhanceCodec( case 2: return false; default: - throw new Error(`Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`); + throw new Error( + `Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`, + ); } }, ); @@ -79,22 +89,12 @@ export function toHexString(value: string): HexString { /** Encode a byte array as a lower-case hex string with a `0x` prefix. */ export function bytesToHex(bytes: Uint8Array): HexString { - let hex = "0x"; - for (let i = 0; i < bytes.length; i++) { - hex += bytes[i]!.toString(16).padStart(2, "0"); - } - return hex as HexString; + return `0x${encodeHex(bytes)}`; } /** Decode a hex string into a byte array. Tolerates a missing `0x` prefix. */ export function hexToBytes(hex: string): Uint8Array { - const start = hex.startsWith("0x") ? 2 : 0; - const length = (hex.length - start) >> 1; - const bytes = new Uint8Array(length); - for (let i = 0; i < length; i++) { - bytes[i] = parseInt(hex.substring(start + i * 2, start + i * 2 + 2), 16); - } - return bytes; + return decodeHex(hex.startsWith("0x") ? hex.slice(2) : hex); } /** @@ -123,6 +123,51 @@ export function TaggedUnion( return Enum(inner) as unknown as Codec>; } +/** + * Wire codec for Rust `CallError`, projected to the public domain error `D`. + * + * Generated TypeScript APIs expose only the domain error union in + * `ResultAsync`. The Rust host still wraps that value in + * `CallError::Domain` on the wire so framework errors can share the response + * channel. Encoding always emits `Domain`; decoding returns the inner domain + * value and throws for framework-level failures that have no public `D` shape. + */ +export function CallError(domain: Codec): Codec { + type WireCallError = + | { tag: "Domain"; value: D } + | { tag: "Denied"; value?: undefined } + | { tag: "Unsupported"; value?: undefined } + | { tag: "MalformedFrame"; value: { reason: string } } + | { tag: "HostFailure"; value: { reason: string } }; + + const wire = Enum({ + Domain: domain, + Denied: _void, + Unsupported: _void, + MalformedFrame: Struct({ reason: scaleStr }), + HostFailure: Struct({ reason: scaleStr }), + }) as unknown as Codec; + + return enhanceCodec( + wire, + (value: D): WireCallError => ({ tag: "Domain", value }), + (value: WireCallError): D => { + switch (value.tag) { + case "Domain": + return value.value; + case "Denied": + throw new Error("Host denied the request"); + case "Unsupported": + throw new Error("Host does not support this request"); + case "MalformedFrame": + throw new Error(`Malformed request frame: ${value.value.reason}`); + case "HostFailure": + throw new Error(`Host failure: ${value.value.reason}`); + } + }, + ); +} + type TaggedUnionCodecs = { [Sym: symbol]: never; [Num: number]: never; diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index f2059342..0f9ad4de 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -1,3 +1,4 @@ +import { concatBytes } from "@noble/hashes/utils.js"; import { err, ok, type Result, type ResultAsync } from "neverthrow"; import { str, u8, type ResultPayload } from "./scale.js"; @@ -279,6 +280,10 @@ export interface WireProvider { /** * Register a callback for provider-level close or failure events. + * + * Providers keep a terminal close reason. The callback fires at most once + * for an active subscription, and fires immediately when registered after + * the provider has already closed. **/ subscribeClose?(callback: (error: Error) => void): () => void; @@ -288,21 +293,6 @@ export interface WireProvider { dispose(): void; } -/** - * Concatenate byte arrays without mutating the source arrays. - **/ -function concatBytes(parts: Uint8Array[]): Uint8Array { - let total = 0; - for (const p of parts) total += p.length; - const out = new Uint8Array(total); - let off = 0; - for (const p of parts) { - out.set(p, off); - off += p.length; - } - return out; -} - /** * Encode a `ProtocolMessage` into a SCALE wire frame. **/ @@ -314,11 +304,7 @@ export function encodeWireMessage( return err(new Error(`Invalid wire discriminant: ${id}`)); } return ok( - concatBytes([ - str.enc(message.requestId), - u8.enc(id), - message.payload.value, - ]), + concatBytes(str.enc(message.requestId), u8.enc(id), message.payload.value), ); } diff --git a/package-lock.json b/package-lock.json index d72aab67..51905f9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "version": "0.3.1", "license": "MIT", "dependencies": { + "@noble/hashes": "^2.2.0", "neverthrow": "^8.2.0", "scale-ts": "^1.6.1" }, @@ -365,6 +366,18 @@ "node": ">=6 <7 || >=8" } }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, diff --git a/playground/CLAUDE.md b/playground/CLAUDE.md index a5d6ce72..093e4c60 100644 --- a/playground/CLAUDE.md +++ b/playground/CLAUDE.md @@ -7,6 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co An interactive explorer for the TrUAPI, the Host API surface exposed to products running inside the Polkadot Desktop Browser webview. The app must be opened from within a Host environment. It talks to the host over iframe `postMessage` frames or the native webview `window.__HOST_API_PORT__` MessagePort. To develop locally, run `yarn dev` and open the app via `https://dot.li/localhost:3000` inside the Desktop Host. +For host-backed diagnosis/e2e runs, signer-bot settings may live in the +repo-root `.env`; if they are not present in the current worktree environment, +load or copy them from that file before treating signer-bot as unavailable. ## Commands diff --git a/playground/src/app/page.tsx b/playground/src/app/page.tsx index 9df8e0be..d8205f86 100644 --- a/playground/src/app/page.tsx +++ b/playground/src/app/page.tsx @@ -23,6 +23,7 @@ import { runDiagnosis, runSingleTest, } from "@/src/lib/auto-test"; +import { installE2EHooks } from "@/src/lib/e2e-hooks"; import packageJson from "../../package.json"; const VERSION_LABEL = `v${packageJson.version}`; @@ -188,6 +189,7 @@ export default function PlaygroundPage() { useEffect(() => { try { + installE2EHooks(); return subscribeConnectionStatus(setStatus); } catch { setStatus("disconnected"); diff --git a/playground/src/components/DiagnosisView.tsx b/playground/src/components/DiagnosisView.tsx index 7cbfa7bf..0200a0b6 100644 --- a/playground/src/components/DiagnosisView.tsx +++ b/playground/src/components/DiagnosisView.tsx @@ -72,13 +72,17 @@ export function DiagnosisView({ }; }, [services, testResults]); + const reportMarkdown = useMemo( + () => + hasResults && !isRunning + ? renderReportMarkdown(services, testResults) + : "", + [hasResults, isRunning, services, testResults], + ); + const handleCopyReport = async () => { try { - // Rendered on demand: the full report is only needed on copy, not on - // every per-method result update during a run. - await navigator.clipboard.writeText( - renderReportMarkdown(services, testResults), - ); + await navigator.clipboard.writeText(reportMarkdown); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { @@ -92,7 +96,7 @@ export function DiagnosisView({ // Copy the report to the clipboard first as a fallback if the body is // truncated. const handleSubmitReport = () => { - const report = renderReportMarkdown(services, testResults); + const report = reportMarkdown; void navigator.clipboard?.writeText(report).catch(() => {}); const url = reportIssueUrl(report, detectHostMode()); // No-op outside a host container; navigation is best-effort. @@ -143,7 +147,12 @@ export function DiagnosisView({ Stop ) : ( - @@ -151,6 +160,7 @@ export function DiagnosisView({ {hasResults && ( 0} > {passCount} success · {failCount} failed @@ -158,9 +168,17 @@ export function DiagnosisView({ )} {hasResults && !isRunning && (
+