Skip to content

Commit b787ba7

Browse files
authored
Playground e2e harness + SCALE codec additions (#238)
* test(truapi): adopt Vitest testing style for @parity/truapi Migrate the JS package tests from the bespoke Bun runner to Vitest, matching the product-sdk testing style: colocated src/**/*.test.ts files, describe/it blocks, and expect() assertions. - Add vitest devDep + vitest.config.ts; test script runs `vitest run`. - Move the five test/*.test.mjs suites next to the code they cover and rewrite them with describe/it + expect. The wire-table loop becomes an it.each over every generated frame id. - The explorer registry test imports the source module so it runs without a prior build. - Exclude src/**/*.test.ts from the build tsconfig so tests never land in dist. - Scope a Prettier override (4-space, 100 width) to the test files. - Drop the unused setup-bun step from the ts-client CI job and refresh the docs and ts-client-checks skill. * test(truapi): run the suite with bun test instead of Vitest Use Bun's built-in test runner: the repo already depends on bun, so this drops the Vitest devDependency (and its config) while keeping the same product-sdk-style describe/it/expect tests. - Imports come from "bun:test"; add @types/bun for editor support. - test script runs `bun test` (auto-discovers src/**/*.test.ts, no config). - Restore the setup-bun step on the ts-client CI job. - Refresh docs and the ts-client-checks skill. * refactor(truapi): iframe MessagePort handshake and SCALE codec additions Switch the in-iframe client to the host-transferred MessagePort handshake (truapi-ready / truapi-init) used by the WASM host runtime, and extend the SCALE primitives (Struct, _void, str, hex helpers) consumed by the generated codecs. Pulls @noble/hashes in as a hex-codec dependency. * revert(truapi): drop iframe MessagePort handshake from this PR The truapi-ready/truapi-init handshake pairs with the WASM host's createIframeHost (#104). Keep sandbox.ts at base so merging to main does not break the deployed playground against the current host. * refactor(truapi): use @noble/hashes concatBytes in wire encoding Replace the hand-rolled byte concatenation with the concatBytes helper now that @noble/hashes is a dependency.
1 parent 7d38e16 commit b787ba7

18 files changed

Lines changed: 415 additions & 69 deletions

js/packages/truapi/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"typescript": "^6.0"
8080
},
8181
"dependencies": {
82+
"@noble/hashes": "^2.2.0",
8283
"neverthrow": "^8.2.0",
8384
"scale-ts": "^1.6.1"
8485
}

js/packages/truapi/src/scale.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,25 @@
88
import {
99
Bytes,
1010
Enum,
11+
Struct,
12+
_void,
1113
createCodec,
1214
createDecoder,
1315
enhanceCodec,
16+
str as scaleStr,
1417
u8,
1518
type Codec,
1619
} from "scale-ts";
20+
import {
21+
bytesToHex as encodeHex,
22+
hexToBytes as decodeHex,
23+
} from "@noble/hashes/utils.js";
1724

1825
export type { Codec };
1926
export type { ResultPayload } from "scale-ts";
2027

2128
export {
29+
Bytes,
2230
Enum,
2331
Option,
2432
Result,
@@ -59,7 +67,9 @@ export const OptionBool: Codec<boolean | undefined> = enhanceCodec(
5967
case 2:
6068
return false;
6169
default:
62-
throw new Error(`Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`);
70+
throw new Error(
71+
`Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`,
72+
);
6373
}
6474
},
6575
);
@@ -79,22 +89,12 @@ export function toHexString(value: string): HexString {
7989

8090
/** Encode a byte array as a lower-case hex string with a `0x` prefix. */
8191
export function bytesToHex(bytes: Uint8Array): HexString {
82-
let hex = "0x";
83-
for (let i = 0; i < bytes.length; i++) {
84-
hex += bytes[i]!.toString(16).padStart(2, "0");
85-
}
86-
return hex as HexString;
92+
return `0x${encodeHex(bytes)}`;
8793
}
8894

8995
/** Decode a hex string into a byte array. Tolerates a missing `0x` prefix. */
9096
export function hexToBytes(hex: string): Uint8Array {
91-
const start = hex.startsWith("0x") ? 2 : 0;
92-
const length = (hex.length - start) >> 1;
93-
const bytes = new Uint8Array(length);
94-
for (let i = 0; i < length; i++) {
95-
bytes[i] = parseInt(hex.substring(start + i * 2, start + i * 2 + 2), 16);
96-
}
97-
return bytes;
97+
return decodeHex(hex.startsWith("0x") ? hex.slice(2) : hex);
9898
}
9999

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

126+
/**
127+
* Wire codec for Rust `CallError<D>`, projected to the public domain error `D`.
128+
*
129+
* Generated TypeScript APIs expose only the domain error union in
130+
* `ResultAsync<Ok, D>`. The Rust host still wraps that value in
131+
* `CallError::Domain` on the wire so framework errors can share the response
132+
* channel. Encoding always emits `Domain`; decoding returns the inner domain
133+
* value and throws for framework-level failures that have no public `D` shape.
134+
*/
135+
export function CallError<D>(domain: Codec<D>): Codec<D> {
136+
type WireCallError =
137+
| { tag: "Domain"; value: D }
138+
| { tag: "Denied"; value?: undefined }
139+
| { tag: "Unsupported"; value?: undefined }
140+
| { tag: "MalformedFrame"; value: { reason: string } }
141+
| { tag: "HostFailure"; value: { reason: string } };
142+
143+
const wire = Enum({
144+
Domain: domain,
145+
Denied: _void,
146+
Unsupported: _void,
147+
MalformedFrame: Struct({ reason: scaleStr }),
148+
HostFailure: Struct({ reason: scaleStr }),
149+
}) as unknown as Codec<WireCallError>;
150+
151+
return enhanceCodec(
152+
wire,
153+
(value: D): WireCallError => ({ tag: "Domain", value }),
154+
(value: WireCallError): D => {
155+
switch (value.tag) {
156+
case "Domain":
157+
return value.value;
158+
case "Denied":
159+
throw new Error("Host denied the request");
160+
case "Unsupported":
161+
throw new Error("Host does not support this request");
162+
case "MalformedFrame":
163+
throw new Error(`Malformed request frame: ${value.value.reason}`);
164+
case "HostFailure":
165+
throw new Error(`Host failure: ${value.value.reason}`);
166+
}
167+
},
168+
);
169+
}
170+
126171
type TaggedUnionCodecs = {
127172
[Sym: symbol]: never;
128173
[Num: number]: never;

js/packages/truapi/src/transport.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { concatBytes } from "@noble/hashes/utils.js";
12
import { err, ok, type Result, type ResultAsync } from "neverthrow";
23

34
import { str, u8, type ResultPayload } from "./scale.js";
@@ -279,6 +280,10 @@ export interface WireProvider {
279280

280281
/**
281282
* Register a callback for provider-level close or failure events.
283+
*
284+
* Providers keep a terminal close reason. The callback fires at most once
285+
* for an active subscription, and fires immediately when registered after
286+
* the provider has already closed.
282287
**/
283288
subscribeClose?(callback: (error: Error) => void): () => void;
284289

@@ -288,21 +293,6 @@ export interface WireProvider {
288293
dispose(): void;
289294
}
290295

291-
/**
292-
* Concatenate byte arrays without mutating the source arrays.
293-
**/
294-
function concatBytes(parts: Uint8Array[]): Uint8Array {
295-
let total = 0;
296-
for (const p of parts) total += p.length;
297-
const out = new Uint8Array(total);
298-
let off = 0;
299-
for (const p of parts) {
300-
out.set(p, off);
301-
off += p.length;
302-
}
303-
return out;
304-
}
305-
306296
/**
307297
* Encode a `ProtocolMessage` into a SCALE wire frame.
308298
**/
@@ -314,11 +304,7 @@ export function encodeWireMessage(
314304
return err(new Error(`Invalid wire discriminant: ${id}`));
315305
}
316306
return ok(
317-
concatBytes([
318-
str.enc(message.requestId),
319-
u8.enc(id),
320-
message.payload.value,
321-
]),
307+
concatBytes(str.enc(message.requestId), u8.enc(id), message.payload.value),
322308
);
323309
}
324310

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playground/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
77
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.
88

99
To develop locally, run `yarn dev` and open the app via `https://dot.li/localhost:3000` inside the Desktop Host.
10+
For host-backed diagnosis/e2e runs, signer-bot settings may live in the
11+
repo-root `.env`; if they are not present in the current worktree environment,
12+
load or copy them from that file before treating signer-bot as unavailable.
1013

1114
## Commands
1215

playground/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
runDiagnosis,
2424
runSingleTest,
2525
} from "@/src/lib/auto-test";
26+
import { installE2EHooks } from "@/src/lib/e2e-hooks";
2627
import packageJson from "../../package.json";
2728

2829
const VERSION_LABEL = `v${packageJson.version}`;
@@ -188,6 +189,7 @@ export default function PlaygroundPage() {
188189

189190
useEffect(() => {
190191
try {
192+
installE2EHooks();
191193
return subscribeConnectionStatus(setStatus);
192194
} catch {
193195
setStatus("disconnected");

playground/src/components/DiagnosisView.tsx

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,17 @@ export function DiagnosisView({
7272
};
7373
}, [services, testResults]);
7474

75+
const reportMarkdown = useMemo(
76+
() =>
77+
hasResults && !isRunning
78+
? renderReportMarkdown(services, testResults)
79+
: "",
80+
[hasResults, isRunning, services, testResults],
81+
);
82+
7583
const handleCopyReport = async () => {
7684
try {
77-
// Rendered on demand: the full report is only needed on copy, not on
78-
// every per-method result update during a run.
79-
await navigator.clipboard.writeText(
80-
renderReportMarkdown(services, testResults),
81-
);
85+
await navigator.clipboard.writeText(reportMarkdown);
8286
setCopied(true);
8387
setTimeout(() => setCopied(false), 1500);
8488
} catch {
@@ -92,7 +96,7 @@ export function DiagnosisView({
9296
// Copy the report to the clipboard first as a fallback if the body is
9397
// truncated.
9498
const handleSubmitReport = () => {
95-
const report = renderReportMarkdown(services, testResults);
99+
const report = reportMarkdown;
96100
void navigator.clipboard?.writeText(report).catch(() => {});
97101
const url = reportIssueUrl(report, detectHostMode());
98102
// No-op outside a host container; navigation is best-effort.
@@ -143,24 +147,38 @@ export function DiagnosisView({
143147
Stop
144148
</button>
145149
) : (
146-
<button type="button" className="btn btn--primary" onClick={onRun}>
150+
<button
151+
type="button"
152+
className="btn btn--primary"
153+
data-testid="diagnosis-run"
154+
onClick={onRun}
155+
>
147156
<span className="btn__glyph"></span>
148157
Run diagnosis
149158
</button>
150159
)}
151160
{hasResults && (
152161
<span
153162
className="autotest__summary"
163+
data-testid="diagnosis-summary"
154164
data-has-fail={!isRunning && failCount > 0}
155165
>
156166
{passCount} success · {failCount} failed
157167
</span>
158168
)}
159169
{hasResults && !isRunning && (
160170
<div className="diag__report-actions">
171+
<pre
172+
hidden
173+
data-testid="diagnosis-report-markdown"
174+
data-report-ready={reportMarkdown.length > 0}
175+
>
176+
{reportMarkdown}
177+
</pre>
161178
<button
162179
type="button"
163180
className="autotest__report-copy"
181+
data-testid="diagnosis-copy-report"
164182
onClick={handleCopyReport}
165183
>
166184
{copied ? "Copied ✓" : "Copy report"}
@@ -186,6 +204,7 @@ export function DiagnosisView({
186204
<div key={r.id}>
187205
<div
188206
className="diag__row"
207+
data-testid="diagnosis-row"
189208
data-status={r.status}
190209
data-expandable={expandable}
191210
onClick={

playground/src/components/ServiceTable.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export function ServiceTable({
3030
<button
3131
type="button"
3232
className="method method--autotest"
33+
data-testid="diagnosis-entry"
3334
data-active={isDiagnosisActive}
3435
data-supported="true"
3536
onClick={() => onSelect(DIAGNOSIS_ID, "")}

playground/src/lib/auto-test.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface TestEntry {
1414

1515
const UNARY_TIMEOUT_MS = 10_000;
1616
const SIGNING_TIMEOUT_MS = 30_000;
17+
const SSO_TIMEOUT_MS = 60_000;
1718

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

36+
const METHOD_TIMEOUT_MS = new Map<string, number>([
37+
["Account/get_account_alias", SSO_TIMEOUT_MS],
38+
]);
39+
3540
type RunOneOpts = {
3641
serviceName: string;
3742
method: MethodInfo;
@@ -59,9 +64,16 @@ async function runOne({
5964
const source = method.exampleSource;
6065
const logs: LogEntry[] = [];
6166
const onLog = (entry: LogEntry) => logs.push(entry);
62-
const timeoutMs = LONG_TIMEOUT_METHODS.has(id)
63-
? SIGNING_TIMEOUT_MS
64-
: UNARY_TIMEOUT_MS;
67+
const timeoutMs =
68+
METHOD_TIMEOUT_MS.get(id) ??
69+
(LONG_TIMEOUT_METHODS.has(id) ? SIGNING_TIMEOUT_MS : UNARY_TIMEOUT_MS);
70+
let timeout: ReturnType<typeof setTimeout> | undefined;
71+
const timeoutPromise = new Promise<never>((_, reject) => {
72+
timeout = setTimeout(
73+
() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)),
74+
timeoutMs,
75+
);
76+
});
6577

6678
// The example decides pass/fail explicitly: it resolves on success and throws
6779
// (via `assert(...)` or any uncaught error) on failure. `console.*` is pure
@@ -74,16 +86,11 @@ async function runOne({
7486
"App must be opened inside a TrUAPI host (iframe or webview).",
7587
);
7688
}
77-
run = await runExample({ source, client, onLog });
78-
await Promise.race([
79-
run.promise,
80-
new Promise<never>((_, reject) =>
81-
setTimeout(
82-
() => reject(new Error(`timed out after ${timeoutMs / 1000}s`)),
83-
timeoutMs,
84-
),
85-
),
89+
run = await Promise.race([
90+
runExample({ source, client, onLog }),
91+
timeoutPromise,
8692
]);
93+
await Promise.race([run.promise, timeoutPromise]);
8794
onUpdate(id, {
8895
status: "pass",
8996
request: source,
@@ -98,6 +105,7 @@ async function runOne({
98105
output: log ? `${log}\n${message}` : message,
99106
});
100107
} finally {
108+
if (timeout !== undefined) clearTimeout(timeout);
101109
run?.cancel();
102110
}
103111
}

playground/src/lib/diagnosis-report.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,9 @@ export function renderReportMarkdown(
6666
return lines.join("\n");
6767
}
6868

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

0 commit comments

Comments
 (0)