From 13df6394b9b52c1362f8c030ad6379b4ff1fdf8b Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Wed, 12 Aug 2026 09:18:53 -0400 Subject: [PATCH] Zero-glue platform classes: pattern doc, parity pins, accessor-safe A2 probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #686-inspired pattern — satisfy a WIT resource import by passing a native platform class directly — already works here for method-shaped interfaces; claim it and pin its limits (deltic#115 tracks the upstream draft): - contracts/embedder-api.md: non-normative "binding platform classes directly" subsection under Resources — the pattern, the executable reference, and the three limits with their one-line bridges (getter- backed properties trap per-call; platform null is not WIT none, the conversion TypeError names the import; platform exceptions are unbranded and trap even from result-typed imports, only ComponentException crosses as err). - runtime/tests/embedder/platform_class_test.ts + platform-class.wat: characterization pins against native URLSearchParams/TextDecoder with no wrapper — happy path (ctor args, kebab->camel toString, string/ bool/list/record conversions, BufferSource compatibility), each limit's exact failure class + wording, and the wrapper recipe turning a native throw into a WIT err. Fixture note: wasmparser requires records referenced by imported functions to be NAMED type exports of the importing instance (all_valtypes_named_in_func). - runtime/src/embedder/instantiate.ts: the A2 wrap-time suspending probe now reads only DATA properties (descriptor walk, dataMember) — the old raw prototype read invoked platform getters with this = prototype, so binding URLSearchParams exploded at INSTANTIATION with Deno's brand-check TypeError, even for guests that never touch the getter- backed member. Accessor-backed members are now a per-call concern with the documented named Trap; an A2 mark cannot ride an accessor (doc'd). --- contracts/embedder-api.md | 49 ++++ runtime/src/embedder/instantiate.ts | 33 ++- runtime/tests/embedder/platform-class.wasm | Bin 0 -> 2313 bytes runtime/tests/embedder/platform-class.wat | 212 ++++++++++++++++++ runtime/tests/embedder/platform_class_test.ts | 169 ++++++++++++++ 5 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 runtime/tests/embedder/platform-class.wasm create mode 100644 runtime/tests/embedder/platform-class.wat create mode 100644 runtime/tests/embedder/platform_class_test.ts diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index be9a6ea..0fdf6a8 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -359,6 +359,55 @@ Ownership at the boundary, both directions: | host passes `own` | wrapper invalidated (transferred) | instance registered; guest owns its handle | | host passes `borrow` | wrapper stays valid | guest must not retain past the call (runtime-enforced per CABI); a never-registered instance gets a rep allocated for the call's duration (C2 amendment) | +### Pattern (non-normative): binding platform classes directly + +A host-implemented resource does not need a hand-written class: when a WIT +resource's shape matches a native platform class, pass the class itself — +the pattern the draft web embedding builds its import story on +(WebAssembly/component-model PR #686 "interface object" imports; tracked +in deltic#115), available here today because the pieces already line up: +method dispatch is a per-call `self[camelCase(member)]` lookup, WIT +constructor args flow to `new Class(...)`, and the value conventions are +the natural JS shapes (`Uint8Array` IS a `BufferSource`; a record is a +plain camelCase object, i.e. an options bag). + +```ts +const instance = await instantiate(artifacts, { + "test:platform/web": { params: URLSearchParams, decoder: TextDecoder }, +}); +``` + +Executable reference: `runtime/tests/embedder/platform_class_test.ts` + +`platform-class.wat` (kebab→camel `to-string`→`toString`, string/bool/ +`list`/record conversions, and each limit below, pinned with exact +failure modes). + +The limits, and the one-line bridges (a `class X extends Native { … }` +wrapper stays inside the pattern): + +1. **Getter-backed properties are not methods.** WIT has no attributes, so + a `size: func() -> u32` bound against an accessor (`URLSearchParams. + prototype.size`) finds no callable member: the call traps ("the + instance has no method 'size'"). The wrap-time suspending + probe reads only DATA properties — it never invokes accessors, so + merely binding such a class is safe; the limit surfaces per-call, and + only for guests that call the member. (Consequence: an A2 suspending + mark cannot ride an accessor-backed member.) Bridge: a real method + delegating to the property. +2. **Platform "absent" is `null`; WIT `none` is `undefined`.** A native + returning `null` where WIT expects `option` takes the `some` branch + and fails the inner conversion: the call rejects with the conversion + layer's `TypeError` naming the import — not a trap, never `none`. + Bridge: `get(k) { return super.get(k) ?? undefined; }`. +3. **Platform exceptions are unbranded, so they trap** — even from a + `result`-typed import (§"Error model"): a result-typed WIT signature + does not convert host exceptions into `err` values. Bridge: try/catch + in a subclass override, rethrowing `new ComponentException(payload)`. + +Named types in the imported interface (a `record decoder-options` the +constructor takes, say) need no imports-object entry — only functions and +resource classes are read from the embedder. + ## Streams and futures Handles, not raw shared objects (`SharedStreamImpl` identity stays diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 8f055b9..3c2a83a 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -627,8 +627,19 @@ class Facade { // suspendability (marking follows the WIT declaration, not the // object); the per-call lookup below still dispatches to the // override's BODY as before. - const protoFn = (cls as { prototype?: Record }) - ?.prototype?.[camelCase(m.member)]; + // + // The probe must not INVOKE accessors: a platform getter (e.g. + // `URLSearchParams.prototype.size`) brand-checks its receiver, and a + // raw `prototype[member]` read runs it with `this` = the prototype — + // an engine TypeError at instantiation, even for guests that never + // call the member. Only a data-property function can carry the A2 + // mark (stage-3 method decorators install data properties), so an + // accessor-backed member yields no wrap-time function here and stays + // a call-time concern for the per-call lookup below. + const protoFn = dataMember( + (cls as { prototype?: unknown })?.prototype, + camelCase(m.member), + ); const dispatch: (args: unknown[]) => unknown = (args) => { const [self, ...rest] = args; const fn = (self as Record)?.[camelCase(m.member)]; @@ -1104,6 +1115,24 @@ function pick( return undefined; } +/** + * Read a DATA property from `obj` (walking its prototype chain, nearest own + * descriptor wins) without ever invoking accessors. Accessor-backed and + * absent members both yield `undefined`. Used by the A2 wrap-time suspending + * probe, which must not run platform getters against a bare prototype. + */ +function dataMember(obj: unknown, key: string): unknown { + for ( + let o = obj; + o !== null && (typeof o === "object" || typeof o === "function"); + o = Object.getPrototypeOf(o) + ) { + const d = Object.getOwnPropertyDescriptor(o, key); + if (d !== undefined) return "value" in d ? d.value : undefined; + } + return undefined; +} + function isThenable(v: unknown): boolean { return v !== null && typeof v === "object" && "then" in v && typeof (v as { then: unknown }).then === "function"; diff --git a/runtime/tests/embedder/platform-class.wasm b/runtime/tests/embedder/platform-class.wasm new file mode 100644 index 0000000000000000000000000000000000000000..7122b362524e24e5f74750ef6dea679e37fcda9b GIT binary patch literal 2313 zcmbVN%WmUV3?+GAdN_&fJdmP`Dorq6Wx5>{C=eIZRWU_@?m8%HC01f3vZS$;Oeet9 z{hQ9ZU(mnkuHRB#Np|cw&Y}rmTNLjh?;#HfQ90QH1oNlBZ@-YBi)b26N`aUFo}o;# zq$&X=?7qFov$C4rUR3$?m&OYT$Lu9!8K;**X3-=r)m@a{#wCJ;{kN028t0?d`(;!V zaW+C(AENq@Y7W6nLhq6HI4V)su2{PoC?y;|a;oxE&MC>R(6O#JC7w0FZVT+K17F1z z`b)qa_+YQMnroTd$G9cIjN*%Y6i=CuDXPn;ic-#Xr?sBuMU^lu0(LOj=GZEY&f~N+ ztv!IQguNB!+LPGB@irD8@fu71Z?oQ&BaW~K`w|Z33AG1aHs5&Q9Uye8xU9Y|(x|%3 zr<1SV$L9+4Kj;Zq6c84vBRpL^*i;~RvKH_R`RgYv$u{m2tilFHVB?)XA#j^A9-xau zIpaT^2|`c;0ky#y4*m#z(H8go!QbGk+v@W46}+mu2c&qza9?s95P3R6#UJH8sVGgZGNH2geR(g%G_H){ ze$V3HDv~x!?ILh?HW|ktTTrao5Cty#`Fwz|zSNqpMj`Y*%`q8oaYe^vd_h7=^_+(! zp`>}v!;sL@#ZPiU^*8R2EYU+{37^4G)Vy0GG(~_RGd~;Z`n~n9`=kD74bXIAjLO zo#waMh@lE_++v!a$EP-e+y1uDp4ZrGe81B?w5T2LcNKD%gb-o?M>^CYg(8%p3NX|I z2*d#Vy$1)$uh=0V|8UJX2m(0>{4T%Xei|sogAfAce?GVHlRgrRkKB!6U3pog}&JurvJ zTczGT%^YvH3Y7YoXB^T}#ap4Rh{9vd`6P_&2-CI0bqNY)bBu>h*@Ur0!jaq3?9xs7 z2t}=;*dE)2vO~g^fi?=y06yrz4g^~2M@O@z3&wmVijVa^;6>7|8oLi1aP^D&+N*uL zDPE6+CwD#ndj$_Xp#$)s4RjYzoH=?tDIfHKKEO&O>;U|W+ZXwy$g?=BPVJ`09{`+2 zsEx$?Xc~q0NpiFHg|cGn%g&OP``Vq0zVAp5-S^rJ+vliV0y}Y$Rp3L9AGjF^6a0>o aU+T2~sR`WW>8hJt$+W`-xhf!{BKZ$wBq2cn literal 0 HcmV?d00001 diff --git a/runtime/tests/embedder/platform-class.wat b/runtime/tests/embedder/platform-class.wat new file mode 100644 index 0000000..d2f3d0a --- /dev/null +++ b/runtime/tests/embedder/platform-class.wat @@ -0,0 +1,212 @@ +;; "Zero-glue platform class" fixture for the embedder conventions layer +;; (contracts/embedder-api.md §"Resources", §"Value mapping (normative)", +;; §"Error model", §"Naming and casing" — see platform_class_test.ts for the +;; pins this exercises). Models a WIT interface whose two resources are bound +;; DIRECTLY to native web platform classes (`URLSearchParams`, `TextDecoder`) +;; with no host-side wrapper: +;; +;; resource params { +;; constructor(init: string); +;; append: func(name: string, value: string); +;; has: func(name: string) -> bool; +;; to-string: func() -> string; // kebab->camel: toString +;; get: func(name: string) -> option; +;; size: func() -> u32; // a GETTER on the native class, +;; // not a method (deliberate limit) +;; } +;; record decoder-options { fatal: bool } +;; resource decoder { +;; constructor(label: string, options: decoder-options); +;; decode: func(data: list) -> result; // result-typed +;; // on purpose: native throws are +;; // unbranded -> trap, not `err` +;; } +;; +;; Guest exports are thin lower/lift trampolines, one per probe, following +;; imports.wat / host-result-payload.wat / host-borrow.wat's style: own vs +;; borrow are both plain i32 handle-table indices at the core level (no +;; `canon resource.drop` calls here — nothing in the pins depends on +;; disposal, so the fixture stays minimal). +;; +;; Canonical ABI bookkeeping (definitions.py, this repo's tie-breaker): +;; - MAX_FLAT_RESULTS = 1: any multi-value result (string, option, +;; result) spills to a return pointer. For an *import* +;; call (`canon lower`), that pointer is an extra trailing i32 PARAM the +;; guest must supply itself (flatten_functype's 'lower' arm). For an +;; *export* (`canon lift`), the guest's core function simply RETURNS the +;; i32 address where it already wrote the tuple ('lift' arm) — so +;; `roundtrip`'s export result reuses the exact scratch address +;; `to-string`'s import call wrote into. +;; - `option` desugars to `variant { none, some(T) }` (despecialize): +;; case 0 = none, case 1 = some. `result` desugars to +;; `variant { ok(T), error(E) }`: case 0 = ok, case 1 = error. +;; - store_variant: 1-byte discriminant (2 cases), then the payload at the +;; max case alignment (4, since the payload is a string (ptr,len) pair) — +;; same layout host-result-payload.wat documents. +;; +;; Regenerate: wasm-tools parse platform-class.wat -o platform-class.wasm +(component + (import "test:platform/web" (instance $api + (export "params" (type $Params (sub resource))) + (export "[constructor]params" + (func (param "init" string) (result (own $Params)))) + (export "[method]params.append" + (func (param "self" (borrow $Params)) (param "name" string) (param "value" string))) + (export "[method]params.has" + (func (param "self" (borrow $Params)) (param "name" string) (result bool))) + (export "[method]params.to-string" + (func (param "self" (borrow $Params)) (result string))) + (export "[method]params.get" + (func (param "self" (borrow $Params)) (param "name" string) (result (option string)))) + (export "[method]params.size" + (func (param "self" (borrow $Params)) (result u32))) + (export "decoder" (type $Decoder (sub resource))) + ;; The options record must be a NAMED type export of this instance: + ;; wasmparser's import validation (`all_valtypes_named_in_func`) rejects + ;; anonymous records/variants in imported function signatures. + (type $optsDef (record (field "fatal" bool))) + (export "decoder-options" (type $Opts (eq $optsDef))) + (export "[constructor]decoder" + (func (param "label" string) (param "options" $Opts) + (result (own $Decoder)))) + (export "[method]decoder.decode" + (func (param "self" (borrow $Decoder)) (param "data" (list u8)) + (result (result string (error string))))))) + + (alias export $api "params" (type $Params)) + (alias export $api "decoder" (type $Decoder)) + (alias export $api "[constructor]params" (func $ctorParams)) + (alias export $api "[method]params.append" (func $append)) + (alias export $api "[method]params.has" (func $has)) + (alias export $api "[method]params.to-string" (func $toString)) + (alias export $api "[method]params.get" (func $get)) + (alias export $api "[method]params.size" (func $size)) + (alias export $api "[constructor]decoder" (func $ctorDecoder)) + (alias export $api "[method]decoder.decode" (func $decode)) + + (core module $Mem + (memory (export "mem") 1) + ;; The fixed-address label the decoder probes construct with; harmless + ;; content ("utf-8" is also a valid, if odd, URLSearchParams init string + ;; for probe-size, which does not care about its params' contents). + (data (i32.const 64) "utf-8") + (global $next (mut i32) (i32.const 4096)) + (func (export "realloc") + (param $old i32) (param $oldsz i32) (param $align i32) (param $newsz i32) + (result i32) + (local $ret i32) + (global.set $next + (i32.and (i32.add (global.get $next) (i32.sub (local.get $align) (i32.const 1))) + (i32.xor (i32.sub (local.get $align) (i32.const 1)) (i32.const -1)))) + (local.set $ret (global.get $next)) + (global.set $next (i32.add (global.get $next) (local.get $newsz))) + (local.get $ret))) + (core instance $mem (instantiate $Mem)) + + ;; Args-only lowerings: strings are read from guest memory but nothing is + ;; written back, so no realloc capability is needed. + (canon lower (func $ctorParams) (memory $mem "mem") (core func $ctorParams')) + (canon lower (func $append) (memory $mem "mem") (core func $append')) + (canon lower (func $has) (memory $mem "mem") (core func $has')) + (canon lower (func $size) (core func $size')) + (canon lower (func $ctorDecoder) (memory $mem "mem") (core func $ctorDecoder')) + ;; Result-bearing lowerings: the host's string payload must be written into + ;; guest memory, so these need realloc too. + (canon lower (func $toString) + (memory $mem "mem") (realloc (func $mem "realloc")) (core func $toString')) + (canon lower (func $get) + (memory $mem "mem") (realloc (func $mem "realloc")) (core func $get')) + (canon lower (func $decode) + (memory $mem "mem") (realloc (func $mem "realloc")) (core func $decode')) + + (core module $M + (import "" "ctorParams" (func $ctorParams (param i32 i32) (result i32))) + (import "" "append" (func $append (param i32 i32 i32 i32 i32))) + (import "" "has" (func $has (param i32 i32 i32) (result i32))) + (import "" "toString" (func $toString (param i32 i32))) + (import "" "get" (func $get (param i32 i32 i32 i32))) + (import "" "size" (func $size (param i32) (result i32))) + (import "" "ctorDecoder" (func $ctorDecoder (param i32 i32 i32) (result i32))) + (import "" "decode" (func $decode (param i32 i32 i32 i32))) + (import "mem" "mem" (memory 1)) + + ;; Scratch addresses for spilled (>1 flat value) results. Disjoint from + ;; each other, from the "utf-8" label at 64, and from the realloc bump + ;; region starting at 4096 where every argument string/list the runtime + ;; copies in for us actually lands. + ;; 0 : to-string's / roundtrip's string tuple (ptr, len) [8B] + ;; 16 : get's option (disc, ptr, len) [12B] + ;; 32 : decode's result (disc, ptr, len) [12B] + + ;; roundtrip(init, name, value) -> string + ;; construct(init); append(name, value); return to-string() + (func (export "roundtrip") + (param $ip i32) (param $il i32) + (param $np i32) (param $nl i32) + (param $vp i32) (param $vl i32) + (result i32) + (local $h i32) + (local.set $h (call $ctorParams (local.get $ip) (local.get $il))) + (call $append (local.get $h) (local.get $np) (local.get $nl) (local.get $vp) (local.get $vl)) + (call $toString (local.get $h) (i32.const 0)) + (i32.const 0)) + + ;; probe-has(init, name) -> bool + (func (export "probe-has") + (param $ip i32) (param $il i32) (param $np i32) (param $nl i32) + (result i32) + (local $h i32) + (local.set $h (call $ctorParams (local.get $ip) (local.get $il))) + (call $has (local.get $h) (local.get $np) (local.get $nl))) + + ;; probe-get(init, name) -> option + (func (export "probe-get") + (param $ip i32) (param $il i32) (param $np i32) (param $nl i32) + (result i32) + (local $h i32) + (local.set $h (call $ctorParams (local.get $ip) (local.get $il))) + (call $get (local.get $h) (local.get $np) (local.get $nl) (i32.const 16)) + (i32.const 16)) + + ;; probe-size() -> u32 (constructs a fixed instance; the getter-vs-method + ;; limit is what this probe is for, not the constructor argument) + (func (export "probe-size") (result i32) + (local $h i32) + (local.set $h (call $ctorParams (i32.const 64) (i32.const 5))) + (call $size (local.get $h))) + + ;; probe-decode(fatal, data) -> result + (func (export "probe-decode") + (param $fatal i32) (param $dp i32) (param $dl i32) + (result i32) + (local $h i32) + (local.set $h (call $ctorDecoder (i32.const 64) (i32.const 5) (local.get $fatal))) + (call $decode (local.get $h) (local.get $dp) (local.get $dl) (i32.const 32)) + (i32.const 32))) + + (core instance $i (instantiate $M + (with "" (instance + (export "ctorParams" (func $ctorParams')) + (export "append" (func $append')) + (export "has" (func $has')) + (export "toString" (func $toString')) + (export "get" (func $get')) + (export "size" (func $size')) + (export "ctorDecoder" (func $ctorDecoder')) + (export "decode" (func $decode')))) + (with "mem" (instance $mem)))) + + (func (export "roundtrip") + (param "init" string) (param "name" string) (param "value" string) (result string) + (canon lift (core func $i "roundtrip") (memory $mem "mem") (realloc (func $mem "realloc")))) + (func (export "probe-has") + (param "init" string) (param "name" string) (result bool) + (canon lift (core func $i "probe-has") (memory $mem "mem") (realloc (func $mem "realloc")))) + (func (export "probe-get") + (param "init" string) (param "name" string) (result (option string)) + (canon lift (core func $i "probe-get") (memory $mem "mem") (realloc (func $mem "realloc")))) + (func (export "probe-size") (result u32) + (canon lift (core func $i "probe-size"))) + (func (export "probe-decode") + (param "fatal" bool) (param "data" (list u8)) (result (result string (error string))) + (canon lift (core func $i "probe-decode") (memory $mem "mem") (realloc (func $mem "realloc"))))) diff --git a/runtime/tests/embedder/platform_class_test.ts b/runtime/tests/embedder/platform_class_test.ts new file mode 100644 index 0000000..ccae50b --- /dev/null +++ b/runtime/tests/embedder/platform_class_test.ts @@ -0,0 +1,169 @@ +// Characterization test: the "zero-glue platform class" pattern — a WIT +// resource import satisfied by handing the runtime a NATIVE web platform +// class directly (`URLSearchParams`, `TextDecoder`), no wrapper. This is the +// ergonomic idea from the upstream web-embedding draft +// (WebAssembly/component-model#686), claimed here in test form against +// deltic's EXISTING host-resource semantics. +// +// Governing docs (contracts/embedder-api.md): +// - §"Value mapping (normative)": the option rule — outermost `none` is +// `undefined`, never `null`. +// - §"Error model": an unbranded host throw traps; only a +// `throw new ComponentException(payload)` crosses as a WIT `err`. +// - §"Resources": host-implemented direction — a class sits at the +// resource's position in the imports object. +// - §"Naming and casing": kebab-case member names resolve through +// `camelCase` (`to-string` -> `toString`). +// +// Mechanics cited (instantiate.ts:580-660): `[method]` dispatch is a +// PER-CALL prototype lookup — `self[camelCase(member)]` then +// `.apply(self, rest)` — so a getter-backed property (no callable member) is +// a `Trap` raised at CALL time, not at instantiation. +// +// Fixture: platform-class.wat/.wasm in this directory (see its header for +// the WIT shape and the ABI bookkeeping behind the trampolines). + +import { assertEq } from "../support/asserts.ts"; +import { caught, haveFixture, instantiateFixture } from "./support.ts"; +import { ComponentException, isTrap, Trap } from "../../src/embedder/mod.ts"; + +const FIXTURE = "runtime/tests/embedder/platform-class.wasm"; +const ready = await haveFixture(FIXTURE); + +function bytesOf(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +// Instance imports are two-level: the interface name keys a namespace object, +// and the resources sit inside it — the classes themselves, nothing else. +const WEB = { + "test:platform/web": { params: URLSearchParams, decoder: TextDecoder }, +}; + +Deno.test({ + name: "platform class: happy path — native URLSearchParams/TextDecoder with no wrapper", + ignore: !ready, + fn: async () => { + const c = await instantiateFixture(FIXTURE, WEB); + + // Constructor args, string conversions, and kebab->camel (`to-string` -> + // `toString`) all round-trip through the native class untouched. + assertEq( + await c.exports.roundtrip("a=1&b=2", "c", "3"), + "a=1&b=2&c=3", + ); + assertEq(await c.exports.probeHas("a=1", "a"), true); + assertEq(await c.exports.probeGet("a=1", "a"), "1"); + + // `decode` is result-typed on purpose; a clean decode is the ok side and + // lifts as the plain string (result in function-result position: T on + // ok, ComponentException on err — contract's error model). + const ok = await c.exports.probeDecode(false, bytesOf("hello")); + assertEq(ok, "hello"); + }, +}); + +Deno.test({ + name: "platform class: option-limit — a missing key surfaces null, which fails (not none)", + ignore: !ready, + fn: async () => { + // `URLSearchParams.prototype.get` returns `null` for a missing key. The + // option rule maps JS `undefined` -> none; `null` is NOT `undefined`, so + // it takes the `some` branch and the inner string conversion of `null` + // fails. Characterized behavior: the failure surfaces as the conversion + // layer's own TypeError naming the import — NOT a Trap (the instance is + // not poisoned), and NOT a ComponentException (never an `err` value). + // Platform APIs speak null-for-absent; WIT option speaks + // undefined-for-none — bridging them needs a wrapper (`?? undefined`). + const c = await instantiateFixture(FIXTURE, WEB); + const e = await caught(() => c.exports.probeGet("a=1", "missing")); + assertEq(isTrap(e), false, `${e}`); + assertEq(e instanceof ComponentException, false, `${e}`); + assertEq(e instanceof TypeError, true, `expected a TypeError, got ${e}`); + // Pinned wording: the import label plus values.ts's string-case message. + assertEq( + String(e).includes( + "import 'test:platform/web/[method]params.get': string expects a string", + ), + true, + `${e}`, + ); + }, +}); + +Deno.test({ + name: "platform class: getter limit — `size` is a property, not a method, and traps", + ignore: !ready, + fn: async () => { + // `URLSearchParams.prototype.size` is an accessor (getter), so + // `self["size"]` is a number, not a function — the per-call dispatch at + // instantiate.ts:630-643 throws a Trap naming the missing method. + const c = await instantiateFixture(FIXTURE, WEB); + const e = await caught(() => c.exports.probeSize()); + assertEq(isTrap(e), true, `expected a Trap, got ${e}`); + assertEq( + /has no method 'size'/.test(String(e)), + true, + `${e}`, + ); + }, +}); + +Deno.test({ + name: "platform class: a native platform exception traps, even from a result-typed import", + ignore: !ready, + fn: async () => { + // `fatal: true` + invalid UTF-8 makes native TextDecoder.prototype.decode + // throw a TypeError. That throw is UNBRANDED (not a ComponentException), + // so per the error model it traps the component — even though + // `[method]decoder.decode` is `result` at the WIT level. + // A result-typed WIT signature does not make host exceptions into `err` + // values; only `throw new ComponentException(payload)` does that. + const c = await instantiateFixture(FIXTURE, WEB); + const e = await caught(() => + c.exports.probeDecode(true, new Uint8Array([0xff])) + ); + assertEq(isTrap(e), true, `expected a Trap (native throw is unbranded), got ${e}`); + assertEq( + e instanceof ComponentException, + false, + "a platform TypeError must never surface as the WIT err side", + ); + }, +}); + +Deno.test({ + name: "platform class: the one-line wrapper recipe turns a native throw into a WIT err", + ignore: !ready, + fn: async () => { + // Contrast pin for the previous test: wrapping just the fallible method + // to translate the platform exception into `throw new + // ComponentException(payload)` is enough to make the SAME failure + // surface as the guest's `err` case instead of a trap. This is the + // recipe the doc references for platform classes whose failure modes + // must reach the guest as WIT errors. + class CheckedDecoder extends TextDecoder { + override decode(input?: BufferSource): string { + try { + // deno-lint-ignore no-explicit-any + return super.decode(input as any); + } catch (e) { + throw new ComponentException(String(e)); + } + } + } + const c = await instantiateFixture(FIXTURE, { + "test:platform/web": { params: URLSearchParams, decoder: CheckedDecoder }, + }); + const e = await caught(() => + c.exports.probeDecode(true, new Uint8Array([0xff])) + ); + assertEq(e instanceof ComponentException, true, `expected ComponentException, got ${e}`); + const payload = (e as ComponentException).payload; + assertEq( + typeof payload === "string" && payload.length > 0, + true, + `expected a non-empty string payload, got ${payload}`, + ); + }, +});