Skip to content
Merged
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
1 change: 1 addition & 0 deletions js/packages/truapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"typescript": "^6.0"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"neverthrow": "^8.2.0",
"scale-ts": "^1.6.1"
}
Expand Down
71 changes: 58 additions & 13 deletions js/packages/truapi/src/scale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -59,7 +67,9 @@ export const OptionBool: Codec<boolean | undefined> = 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.`,
);
}
},
);
Expand All @@ -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);
}

/**
Expand Down Expand Up @@ -123,6 +123,51 @@ export function TaggedUnion<O extends TaggedUnionCodecs>(
return Enum(inner) as unknown as Codec<TaggedUnionValue<O>>;
}

/**
* Wire codec for Rust `CallError<D>`, projected to the public domain error `D`.
*
* Generated TypeScript APIs expose only the domain error union in
* `ResultAsync<Ok, D>`. 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<D>(domain: Codec<D>): Codec<D> {
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<WireCallError>;

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;
Expand Down
26 changes: 6 additions & 20 deletions js/packages/truapi/src/transport.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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.
**/
Expand All @@ -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),
);
}

Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions playground/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions playground/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand Down Expand Up @@ -188,6 +189,7 @@ export default function PlaygroundPage() {

useEffect(() => {
try {
installE2EHooks();
return subscribeConnectionStatus(setStatus);
} catch {
setStatus("disconnected");
Expand Down
33 changes: 26 additions & 7 deletions playground/src/components/DiagnosisView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -143,24 +147,38 @@ export function DiagnosisView({
Stop
</button>
) : (
<button type="button" className="btn btn--primary" onClick={onRun}>
<button
type="button"
className="btn btn--primary"
data-testid="diagnosis-run"
onClick={onRun}
>
<span className="btn__glyph">▶</span>
Run diagnosis
</button>
)}
{hasResults && (
<span
className="autotest__summary"
data-testid="diagnosis-summary"
data-has-fail={!isRunning && failCount > 0}
>
{passCount} success · {failCount} failed
</span>
)}
{hasResults && !isRunning && (
<div className="diag__report-actions">
<pre
hidden
data-testid="diagnosis-report-markdown"
data-report-ready={reportMarkdown.length > 0}
>
{reportMarkdown}
</pre>
<button
type="button"
className="autotest__report-copy"
data-testid="diagnosis-copy-report"
onClick={handleCopyReport}
>
{copied ? "Copied ✓" : "Copy report"}
Expand All @@ -186,6 +204,7 @@ export function DiagnosisView({
<div key={r.id}>
<div
className="diag__row"
data-testid="diagnosis-row"
data-status={r.status}
data-expandable={expandable}
onClick={
Expand Down
1 change: 1 addition & 0 deletions playground/src/components/ServiceTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function ServiceTable({
<button
type="button"
className="method method--autotest"
data-testid="diagnosis-entry"
data-active={isDiagnosisActive}
data-supported="true"
onClick={() => onSelect(DIAGNOSIS_ID, "")}
Expand Down
32 changes: 20 additions & 12 deletions playground/src/lib/auto-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface TestEntry {

const UNARY_TIMEOUT_MS = 10_000;
const SIGNING_TIMEOUT_MS = 30_000;
const SSO_TIMEOUT_MS = 60_000;

// Services skipped wholesale in the diagnosis until hosts wire them up.
const SKIPPED_SERVICES = new Set(["Coin Payment"]);
Expand All @@ -32,6 +33,10 @@ const LONG_TIMEOUT_METHODS = new Set([
"Preimage/submit",
]);

const METHOD_TIMEOUT_MS = new Map<string, number>([
["Account/get_account_alias", SSO_TIMEOUT_MS],
]);

type RunOneOpts = {
serviceName: string;
method: MethodInfo;
Expand Down Expand Up @@ -59,9 +64,16 @@ async function runOne({
const source = method.exampleSource;
const logs: LogEntry[] = [];
const onLog = (entry: LogEntry) => logs.push(entry);
const timeoutMs = LONG_TIMEOUT_METHODS.has(id)
? SIGNING_TIMEOUT_MS
: UNARY_TIMEOUT_MS;
const timeoutMs =
METHOD_TIMEOUT_MS.get(id) ??
(LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS);
let timeout: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(
() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)),
timeoutMs,
);
});

// The example decides pass/fail explicitly: it resolves on success and throws
// (via `assert(...)` or any uncaught error) on failure. `console.*` is pure
Expand All @@ -74,16 +86,11 @@ async function runOne({
"App must be opened inside a TrUAPI host (iframe or webview).",
);
}
run = await runExample({ source, client, onLog });
await Promise.race([
run.promise,
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)),
timeoutMs,
),
),
run = await Promise.race([
runExample({ source, client, onLog }),
timeoutPromise,
]);
await Promise.race([run.promise, timeoutPromise]);
onUpdate(id, {
status: "pass",
request: source,
Expand All @@ -98,6 +105,7 @@ async function runOne({
output: log ? `${log}\n${message}` : message,
});
} finally {
if (timeout !== undefined) clearTimeout(timeout);
run?.cancel();
}
}
Expand Down
5 changes: 2 additions & 3 deletions playground/src/lib/diagnosis-report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,9 @@ export function renderReportMarkdown(
return lines.join("\n");
}

// The failure reason for a method, flattened to a single escaped table cell.
// Only failures carry details; other statuses leave the cell empty.
// Method output flattened to a single escaped table cell.
function detailCell(entry: TestEntry | undefined): string {
if (entry?.status !== "fail" || entry.output == null) return "";
if (entry?.output == null) return "";
return entry.output.replace(/\s+/g, " ").replace(/\|/g, "\\|").trim();
}

Expand Down
Loading