feat(webidl): derive native interface brand checks

Add interface and brand_check field attributes to argument and dictionary derives, including variadic object arguments. Preserve native object identity and validate brands at each field conversion position.

Cover nullability, conversion order, cross-realm errors, registered native proxies, and invalid attribute combinations.
This commit is contained in:
ldm0
2026-10-06 13:35:34 +08:00
committed by Donough Liu
parent b9fc881f87
commit 761b39bc84
10 changed files with 594 additions and 17 deletions
Generated
+1
View File
@@ -3446,6 +3446,7 @@ version = "0.1.0"
dependencies = [
"moli-v8-test-util",
"moli-v8-util",
"moli-webapi-declare",
"moli-webidl-callback",
"moli-webidl-derive",
"v8",
+38
View File
@@ -56,6 +56,8 @@ pub(crate) struct FieldAttrs {
pub(crate) name: Option<LitStr>,
pub(crate) default: Option<Expr>,
pub(crate) converter: Option<crate::converter::ConverterKind>,
pub(crate) interface: Option<Path>,
pub(crate) brand_check: Option<Path>,
pub(crate) missing_message: Option<LitStr>,
pub(crate) index: Option<usize>,
pub(crate) legacy_nullish: bool,
@@ -119,9 +121,45 @@ pub(crate) fn parse_field_attrs(field: &Field) -> Result<FieldAttrs, Error> {
parsed.converter = Some(crate::converter::ConverterKind::from_lit(&converter)?);
return Ok(());
}
if meta.path.is_ident("interface") {
if parsed.interface.is_some() {
return Err(meta.error("duplicate interface attribute"));
}
parsed.interface = Some(meta.value()?.parse()?);
return Ok(());
}
if meta.path.is_ident("brand_check") {
if parsed.brand_check.is_some() {
return Err(meta.error("duplicate brand_check attribute"));
}
parsed.brand_check = Some(meta.value()?.parse()?);
return Ok(());
}
Err(meta.error("unsupported #[webidl(...)] field attribute"))
})?;
}
if let Some(brand_check) = &parsed.brand_check
&& parsed.interface.is_none()
{
return Err(Error::new(
brand_check.span(),
"brand_check requires #[webidl(interface = Type)]",
));
}
if let Some(interface) = &parsed.interface {
if parsed.converter.is_some() || parsed.with.is_some() {
return Err(Error::new(
interface.span(),
"interface cannot be combined with converter or with",
));
}
if parsed.treat_null_as_empty_string {
return Err(Error::new(
interface.span(),
"treat_null_as_empty_string only applies to string converters",
));
}
}
Ok(parsed)
}
+53 -3
View File
@@ -27,6 +27,7 @@ pub(crate) enum ConverterKind {
UnrestrictedDouble,
Boolean,
Enum,
Interface,
Raw,
}
@@ -92,6 +93,7 @@ impl ConverterKind {
Self::UnrestrictedDouble => quote!(::moli_webidl::UnrestrictedDouble),
Self::Boolean => quote!(::moli_webidl::Boolean),
Self::Enum => quote!(::moli_webidl::EnumValue<#ty>),
Self::Interface => quote!(::moli_webidl::InterfaceObject<'_>),
Self::Raw => quote!(#ty),
}
}
@@ -131,6 +133,7 @@ impl ConverterKind {
Self::UnrestrictedDouble => quote!(::moli_webidl::UnrestrictedDouble(#expr)),
Self::Boolean => quote!(::moli_webidl::Boolean(#expr)),
Self::Enum => quote!(::moli_webidl::EnumValue(#expr)),
Self::Interface => quote!(::moli_webidl::InterfaceObject(#expr)),
Self::Raw => quote!(#expr),
}
}
@@ -156,20 +159,33 @@ impl ConverterKind {
| Self::Double
| Self::UnrestrictedDouble
| Self::Boolean
| Self::Enum => quote!(#value.0),
| Self::Enum
| Self::Interface => quote!(#value.0),
Self::Raw => value,
}
}
/// Builds the options value passed to `WebIdlConverter::convert`.
///
/// Today only string converters accept options. Rejecting
/// String and interface converters accept options. Rejecting
/// `treat_null_as_empty_string` on non-string converters keeps invalid
/// derive usage as a compile-time error instead of silently ignoring it.
pub(crate) fn options_expr(
self,
attrs: &FieldAttrs,
) -> Result<proc_macro2::TokenStream, Error> {
if self == Self::Interface {
let interface = attrs.interface.as_ref().expect("interface converter path");
let brand_check = attrs
.brand_check
.as_ref()
.map(|path| quote!(#path))
.unwrap_or_else(|| quote!(<#interface>::is_instance));
return Ok(quote!(::moli_webidl::InterfaceOptions {
name: <#interface>::NAME,
brand_check: #brand_check,
}));
}
if attrs.treat_null_as_empty_string {
match self {
Self::DomString | Self::UsvString | Self::ByteString => {
@@ -190,10 +206,27 @@ impl ConverterKind {
}
pub(crate) fn converter_kind(field: &Field, attrs: &FieldAttrs) -> Result<ConverterKind, Error> {
converter_kind_for_field_type(field, attrs, inner_type_for_field(field))
}
pub(crate) fn converter_kind_for_field_type(
field: &Field,
attrs: &FieldAttrs,
ty: &Type,
) -> Result<ConverterKind, Error> {
if attrs.interface.is_some() {
if !is_v8_object_type(ty) {
return Err(Error::new(
ty.span(),
"interface fields must use v8::Local<'_, v8::Object> (or Option/variadic Vec of it)",
));
}
return Ok(ConverterKind::Interface);
}
if let Some(converter) = attrs.converter {
return Ok(converter);
}
converter_kind_for_type(inner_type_for_field(field)).ok_or_else(|| {
converter_kind_for_type(ty).ok_or_else(|| {
Error::new(
field.span(),
"could not infer converter; add #[webidl(converter = \"...\")]",
@@ -305,6 +338,23 @@ fn is_v8_local_type(ty: &Type) -> bool {
.is_some_and(|segment| segment.ident == "Local")
}
fn is_v8_object_type(ty: &Type) -> bool {
let Type::Path(type_path) = ty else {
return false;
};
let Some(segment) = type_path.path.segments.last() else {
return false;
};
if segment.ident != "Local" {
return false;
}
let PathArguments::AngleBracketed(arguments) = &segment.arguments else {
return false;
};
matches!(arguments.args.last(), Some(GenericArgument::Type(Type::Path(path)))
if path.path.segments.last().is_some_and(|segment| segment.ident == "Object"))
}
fn option_inner_type(ty: &Type) -> Option<&Type> {
let Type::Path(type_path) = ty else {
return None;
+82 -12
View File
@@ -8,7 +8,8 @@ use crate::attrs::{
parse_container_attrs, parse_enum_attrs, parse_field_attrs, parse_variant_attrs,
};
use crate::converter::{
converter_kind, converter_kind_for_type, inner_type_for_field, is_option_type, vec_inner_type,
converter_kind, converter_kind_for_field_type, inner_type_for_field, is_option_type,
vec_inner_type,
};
pub(crate) fn expand_webidl_args(input: DeriveInput) -> Result<proc_macro2::TokenStream, Error> {
@@ -295,16 +296,7 @@ fn expand_args_field(
"variadic WebIdlArgs fields must use Vec<T>",
));
};
let converter = if let Some(converter) = attrs.converter {
converter
} else {
converter_kind_for_type(item_type).ok_or_else(|| {
Error::new(
field.span(),
"could not infer variadic item converter; add #[webidl(converter = \"...\")]",
)
})?
};
let converter = converter_kind_for_field_type(field, &attrs, item_type)?;
let arg_index = attrs.index.unwrap_or(index) as i32;
let converter_ty = converter.wrapper_type(item_type);
let unwrap_value = converter.unwrap_value(quote!(value));
@@ -612,9 +604,87 @@ fn expand_dictionary_field(
#[cfg(test)]
mod tests {
use super::impl_parts_for_scope;
use super::{expand_webidl_args, expand_webidl_dictionary, impl_parts_for_scope};
use syn::parse_quote;
#[test]
fn interface_attributes_reject_conflicting_or_incomplete_metadata() {
let cases = [
(
quote::quote!(brand_check = check),
"brand_check requires #[webidl(interface = Type)]",
),
(
quote::quote!(interface = Base, converter = "raw"),
"interface cannot be combined with converter or with",
),
(
quote::quote!(interface = Base, with = parse),
"interface cannot be combined with converter or with",
),
(
quote::quote!(interface = Base, treat_null_as_empty_string),
"treat_null_as_empty_string only applies to string converters",
),
(
quote::quote!(interface = Base, interface = Other),
"duplicate interface attribute",
),
(
quote::quote!(interface = Base, brand_check = check, brand_check = other),
"duplicate brand_check attribute",
),
];
for (attrs, message) in cases {
let input: syn::DeriveInput = parse_quote! {
struct Arguments<'s> {
#[webidl(#attrs)]
value: Option<v8::Local<'s, v8::Object>>,
}
};
for error in [
expand_webidl_args(input.clone()).unwrap_err(),
expand_webidl_dictionary(input).unwrap_err(),
] {
assert_eq!(error.to_string(), message);
}
}
}
#[test]
fn interface_attributes_require_object_fields_and_variadic_items() {
for ty in [
quote::quote!(String),
quote::quote!(v8::Local<'s, v8::Value>),
quote::quote!(v8::Local<'s, v8::Function>),
] {
let input: syn::DeriveInput = parse_quote! {
struct Arguments<'s> {
#[webidl(interface = Base)]
value: Option<#ty>,
}
};
for error in [
expand_webidl_args(input.clone()).unwrap_err(),
expand_webidl_dictionary(input).unwrap_err(),
] {
assert!(error.to_string().starts_with("interface fields must use"));
}
let input = parse_quote! {
struct Arguments<'s> {
#[webidl(variadic, interface = Base)]
value: Vec<#ty>,
}
};
assert!(
expand_webidl_args(input)
.unwrap_err()
.to_string()
.starts_with("interface fields must use")
);
}
}
#[test]
fn scope_lifetime_is_inferred_for_single_lifetime_structs() {
let generics: syn::Generics = parse_quote!(<'scope>);
+5
View File
@@ -11,6 +11,9 @@ use syn::{DeriveInput, parse_macro_input};
/// and returns a Rust struct. Field attributes control required arguments,
/// explicit indexes, defaults, nullable values, variadic tails, custom
/// converters, and hand-written parser hooks.
/// `#[webidl(interface = Type)]` validates a V8 object using `Type::NAME` and
/// `Type::is_instance`, with an optional `brand_check = path` override. It
/// composes with optional/nullable fields and variadic object arguments.
#[proc_macro_derive(WebIdlArgs, attributes(webidl))]
pub fn derive_webidl_args(input: TokenStream) -> TokenStream {
match expand::expand_webidl_args(parse_macro_input!(input as DeriveInput)) {
@@ -27,6 +30,8 @@ pub fn derive_webidl_args(input: TokenStream) -> TokenStream {
/// `nullable`, legacy nullish handling, explicit converters, and hand-written
/// member parser hooks. Unnamed fields use `camelCase` member names by default,
/// matching common WebIDL dictionary spelling.
/// Object members support `interface = Type` and an optional `brand_check = path`
/// using the same native identity conversion as positional arguments.
#[proc_macro_derive(WebIdlDictionary, attributes(webidl))]
pub fn derive_webidl_dictionary(input: TokenStream) -> TokenStream {
match expand::expand_webidl_dictionary(parse_macro_input!(input as DeriveInput)) {
+1
View File
@@ -12,6 +12,7 @@ v8 = "152.2.0"
[dev-dependencies]
moli-v8-test-util = { path = "../moli-v8-test-util" }
moli-webapi-declare = { path = "../moli-webapi-declare" }
[lints]
workspace = true
+18
View File
@@ -449,6 +449,24 @@ where
}
}
impl<'s> WebIdlConverter<'s> for crate::InterfaceObject<'s> {
type Options = crate::InterfaceOptions;
fn convert(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
context: Context,
options: &Self::Options,
) -> Result<Self, WebIdlError> {
let object = v8::Local::<v8::Object>::try_from(value)
.map_err(|_| WebIdlError::cannot_convert(context, options.name))?;
if !(options.brand_check)(scope, object) {
return Err(WebIdlError::cannot_convert(context, options.name));
}
Ok(Self(object))
}
}
impl<'s> WebIdlConverter<'s> for v8::Local<'s, v8::Value> {
type Options = ();
+11 -2
View File
@@ -29,6 +29,15 @@
//! generates named property parsing for dictionary objects. Both derives use
//! the converter wrappers in `types` and only produce Rust values; object shape
//! declaration and Web API wrapper allocation remain outside this crate.
//!
//! Interface fields use `#[webidl(interface = interfaces::EventTarget)]` with
//! `v8::Local<'s, v8::Object>`, optionally wrapped in `Option` or a variadic
//! argument's `Vec`. The interface supplies `NAME` and `is_instance`; a
//! `brand_check = path` attribute overrides the native identity predicate.
//! `Option` skips missing/undefined values, while `nullable` additionally accepts
//! null. Identity is checked at the field's position in conversion order, and
//! the original object is retained. Interface conversion cannot be combined
//! with `converter`, `with`, or string conversion options.
extern crate self as moli_webidl;
@@ -64,8 +73,8 @@ pub use traits::{ParseOutcome, WebIdlArguments, WebIdlConverter, WebIdlDictionar
pub use types::{
Boolean, BufferSource, ByteString, ClampedUnsignedShort, DomString, DomString16, Double,
EnforceRangeLong, EnforceRangeUnsignedLong, EnforceRangeUnsignedLongLong, EnumValue,
EventListenerOptions, Long, Record, Sequence, Short, StringOptions, UnrestrictedDouble,
UnsignedLong, UnsignedLongLong, UnsignedShort, UsvString,
EventListenerOptions, InterfaceObject, InterfaceOptions, Long, Record, Sequence, Short,
StringOptions, UnrestrictedDouble, UnsignedLong, UnsignedLongLong, UnsignedShort, UsvString,
};
/// Restricted WebIDL float represented as a finite binary32 value.
+28
View File
@@ -3,6 +3,34 @@ use crate::WebIdlDictionary;
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct EnumValue<T>(pub T);
/// A platform object validated against native WebIDL interface identity.
///
/// Argument and dictionary derives unwrap this to `v8::Local<v8::Object>`.
/// Validation preserves the original object, including registered native proxies.
#[derive(Debug, Clone, Copy)]
pub struct InterfaceObject<'s>(pub v8::Local<'s, v8::Object>);
/// Native interface metadata supplied by `#[webidl(interface = Type)]`.
///
/// The name comes from `Type::NAME` and the predicate defaults to
/// `Type::is_instance`. `brand_check = path` overrides only the predicate.
/// Predicates must check native identity without invoking author code.
#[derive(Clone, Copy)]
pub struct InterfaceOptions {
pub name: &'static str,
pub brand_check: for<'s, 'i> fn(&mut v8::PinScope<'s, 'i>, v8::Local<'s, v8::Object>) -> bool,
}
impl Default for InterfaceOptions {
/// Without explicit interface metadata, conversion rejects every object.
fn default() -> Self {
Self {
name: "interface",
brand_check: |_, _| false,
}
}
}
/// WebIDL `DOMString` represented at the Rust boundary as UTF-8 `String`.
///
/// This intentionally follows Deno's practical boundary model rather than
+357
View File
@@ -0,0 +1,357 @@
use moli_webidl::{Context, WebIdlArgs, WebIdlDictionary};
mod interfaces {
moli_webapi_declare::declare_web_api_interfaces! {
pub Base = "RenamedBase";
pub Derived: Base;
pub Unrelated;
}
}
#[derive(WebIdlArgs, WebIdlDictionary)]
#[webidl(prefix = "Test.interfaces")]
struct InterfaceMembers<'scope> {
#[webidl(default = "")]
before: String,
#[webidl(interface = interfaces::Base)]
optional: Option<v8::Local<'scope, v8::Object>>,
#[webidl(nullable, interface = interfaces::Base)]
nullable: Option<v8::Local<'scope, v8::Object>>,
#[webidl(interface = interfaces::Base, brand_check = is_current_global)]
special: Option<v8::Local<'scope, v8::Object>>,
#[webidl(default = 0)]
after: i32,
}
#[derive(WebIdlArgs)]
#[webidl(prefix = "Test.required")]
struct RequiredArgs<'scope> {
#[webidl(required)]
before: String,
#[webidl(required, interface = interfaces::Base)]
object: v8::Local<'scope, v8::Object>,
}
#[derive(WebIdlArgs, WebIdlDictionary)]
#[webidl(prefix = "Test.requiredNullable")]
struct RequiredNullable<'scope> {
#[webidl(required, nullable, interface = interfaces::Base)]
object: Option<v8::Local<'scope, v8::Object>>,
}
#[derive(WebIdlArgs)]
#[webidl(prefix = "Test.variadic")]
struct VariadicArgs<'scope> {
#[webidl(variadic, interface = interfaces::Base)]
objects: Vec<v8::Local<'scope, v8::Object>>,
}
fn is_current_global<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> bool {
object.strict_equals(scope.get_current_context().global(scope).into())
}
fn object_or_null<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: Option<v8::Local<'s, v8::Object>>,
) -> v8::Local<'s, v8::Value> {
object
.map(Into::into)
.unwrap_or_else(|| v8::null(scope).into())
}
fn members_array<'s>(
scope: &mut v8::PinScope<'s, '_>,
parsed: InterfaceMembers<'s>,
) -> v8::Local<'s, v8::Array> {
let values = [
v8::String::new(scope, &parsed.before).unwrap().into(),
object_or_null(scope, parsed.optional),
object_or_null(scope, parsed.nullable),
object_or_null(scope, parsed.special),
v8::Integer::new(scope, parsed.after).into(),
];
v8::Array::new_with_elements(scope, &values)
}
fn optional_args<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
if let Some(parsed) = moli_webidl::parse_args::<InterfaceMembers>(scope, &args) {
rv.set(members_array(scope, parsed).into());
}
}
fn dictionary<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
match moli_webidl::parse_dictionary::<InterfaceMembers>(
scope,
args.get(0),
Context::argument("Test.dictionary", 1),
) {
Ok(Some(parsed)) => rv.set(members_array(scope, parsed).into()),
Ok(None) => {}
Err(error) => moli_webidl::throw_error(scope, &error),
}
}
fn required_args<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
if let Some(parsed) = moli_webidl::parse_args::<RequiredArgs>(scope, &args) {
let before = v8::String::new(scope, &parsed.before).unwrap();
rv.set(v8::Array::new_with_elements(scope, &[before.into(), parsed.object.into()]).into());
}
}
fn required_nullable_args<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
if let Some(parsed) = moli_webidl::parse_args::<RequiredNullable>(scope, &args) {
rv.set(object_or_null(scope, parsed.object));
}
}
fn required_nullable_dictionary<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
match moli_webidl::parse_dictionary::<RequiredNullable>(
scope,
args.get(0),
Context::argument("Test.requiredNullableDictionary", 1),
) {
Ok(Some(parsed)) => rv.set(object_or_null(scope, parsed.object)),
Ok(None) => {}
Err(error) => moli_webidl::throw_error(scope, &error),
}
}
fn variadic_args<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
if let Some(parsed) = moli_webidl::parse_args::<VariadicArgs>(scope, &args) {
let values: Vec<_> = parsed.objects.into_iter().map(Into::into).collect();
rv.set(v8::Array::new_with_elements(scope, &values).into());
}
}
fn run_checks(source: &str) {
moli_v8_test_util::ensure_v8();
let mut isolate = v8::Isolate::new(Default::default());
let scope = std::pin::pin!(v8::HandleScope::new(&mut isolate));
let scope = &mut scope.init();
let callee = v8::Context::new(scope, Default::default());
let exports = {
let scope = &mut v8::ContextScope::new(scope, callee);
let foreign = v8::Object::new(scope);
interfaces::Derived::DESCRIPTOR
.initialize(scope, foreign)
.unwrap();
let handler = v8::Object::new(scope);
let native = v8::Proxy::new(scope, foreign, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, native).unwrap();
let key = v8::String::new(scope, "TypeError").unwrap();
let type_error = callee.global(scope).get(scope, key.into()).unwrap();
[
(
"optionalArgs",
v8::Function::new(scope, optional_args).unwrap().into(),
),
(
"dictionary",
v8::Function::new(scope, dictionary).unwrap().into(),
),
(
"requiredArgs",
v8::Function::new(scope, required_args).unwrap().into(),
),
(
"requiredNullableArgs",
v8::Function::new(scope, required_nullable_args)
.unwrap()
.into(),
),
(
"requiredNullableDictionary",
v8::Function::new(scope, required_nullable_dictionary)
.unwrap()
.into(),
),
(
"variadicArgs",
v8::Function::new(scope, variadic_args).unwrap().into(),
),
("CalleeTypeError", type_error),
("foreign", foreign.into()),
("native", native.into()),
("special", callee.global(scope).into()),
]
};
let caller = v8::Context::new(scope, Default::default());
let scope = &mut v8::ContextScope::new(scope, caller);
let real = v8::Object::new(scope);
interfaces::Derived::DESCRIPTOR
.initialize(scope, real)
.unwrap();
let unrelated = v8::Object::new(scope);
interfaces::Unrelated::DESCRIPTOR
.initialize(scope, unrelated)
.unwrap();
for (name, value) in exports
.into_iter()
.chain([("real", real.into()), ("unrelated", unrelated.into())])
{
let key = v8::String::new(scope, name).unwrap();
assert_eq!(
caller.global(scope).set(scope, key.into(), value),
Some(true)
);
}
let scope = std::pin::pin!(v8::TryCatch::new(scope));
let scope = &mut scope.init();
let source = v8::String::new(scope, source).unwrap();
let script = v8::Script::compile(scope, source, None).unwrap();
let result = script.run(scope);
if result.is_none() {
let exception = scope.exception().unwrap();
panic!(
"interface conversion assertions failed: {}",
exception.to_rust_string_lossy(scope)
);
}
assert!(result.unwrap().is_true());
}
#[test]
fn interface_arguments_preserve_native_identity_conversion_order_and_callee_errors() {
run_checks(
r#"
(() => {
const assert = (ok, message) => { if (!ok) throw Error(message); };
const caught = fn => { try { fn(); } catch (error) { return error; } };
const typeError = fn => {
const error = caught(fn);
assert(error instanceof CalleeTypeError && !(error instanceof TypeError), 'callee TypeError');
return error;
};
Object.setPrototypeOf(real, null);
for (const value of [real, foreign, native]) {
const result = optionalArgs('before', value, value, special, 7);
assert(result[1] === value && result[2] === value && result[3] === special && result[4] === 7,
'original objects, native proxies, inheritance and cross-realm brands');
assert(requiredArgs('before', value)[1] === value, 'required object');
assert(requiredNullableArgs(value) === value, 'required nullable object');
}
for (const value of [null, undefined]) {
assert(optionalArgs('before', undefined, value)[2] === null, 'nullable optional');
assert(requiredNullableArgs(value) === null, 'nullable required accepts present nullish');
}
assert(optionalArgs().slice(1, 4).every(value => value === null), 'missing optional members');
typeError(() => requiredNullableArgs());
let conversions = 0;
const sentinel = {};
const poison = {toString() { conversions++; throw sentinel; }};
typeError(() => requiredArgs(poison));
assert(conversions === 0, 'arity preflight precedes conversions');
assert(caught(() => optionalArgs(poison)) === sentinel, 'original conversion exception');
let traps = 0;
const handler = {get() { traps++; throw Error('get trap'); },
getPrototypeOf() { traps++; throw Error('prototype trap'); }};
const revoked = Proxy.revocable(real, {}); revoked.revoke();
const invalid = [null, {}, 1, 'object', true, Symbol('object'), 1n, unrelated,
Object.create(real), new Proxy(real, handler), new Proxy(native, handler), revoked.proxy];
for (const value of invalid) {
const order = [];
const before = {toString() { order.push('before'); return 'before'; }};
const after = {valueOf() { order.push('after'); throw sentinel; }};
const error = typeError(() => optionalArgs(before, value, null, undefined, after));
assert(error.message.includes('RenamedBase'), 'declared interface name');
assert(order.join() === 'before', 'interface validation at field position');
if (value !== null) typeError(() => requiredNullableArgs(value));
}
typeError(() => requiredArgs('before', undefined));
typeError(() => optionalArgs('before', real, real, real));
assert(traps === 0, 'identity checks never invoke author proxy traps');
const order = [];
const result = optionalArgs({toString() { order.push('before'); return 'label'; }}, real,
native, special, {valueOf() { order.push('after'); return 9; }});
assert(order.join() === 'before,after' && result[4] === 9, 'successful conversion order');
assert(variadicArgs().length === 0, 'empty variadic');
const values = variadicArgs(real, foreign, native);
assert(values[0] === real && values[1] === foreign && values[2] === native, 'variadic identity');
for (const value of [...invalid, undefined]) typeError(() => variadicArgs(real, value, foreign));
return true;
})()
"#,
);
}
#[test]
fn interface_dictionary_members_preserve_getters_nullability_and_native_identity() {
run_checks(
r#"
(() => {
const assert = (ok, message) => { if (!ok) throw Error(message); };
const caught = fn => { try { fn(); } catch (error) { return error; } };
const typeError = fn => {
const error = caught(fn);
assert(error instanceof CalleeTypeError && !(error instanceof TypeError), 'callee TypeError');
return error;
};
const result = dictionary({optional: real, nullable: native, special, after: 7});
assert(result[1] === real && result[2] === native && result[3] === special && result[4] === 7,
'interface members preserve original identity and custom predicate');
assert(dictionary(Object.create({optional: foreign}))[1] === foreign, 'inherited dictionary member');
for (const input of [{}, {optional: undefined, nullable: undefined}, {nullable: null}]) {
assert(dictionary(input).slice(1, 4).every(value => value === null), 'optional and nullable dictionary members');
}
typeError(() => dictionary({optional: null}));
typeError(() => dictionary({special: real}));
assert(requiredNullableDictionary({object: null}) === null, 'required nullable null');
assert(requiredNullableDictionary({object: native}) === native, 'required nullable object');
typeError(() => requiredNullableDictionary({}));
typeError(() => requiredNullableDictionary({object: undefined}));
const sentinel = {};
let traps = 0;
const proxy = new Proxy(real, {get() { traps++; throw sentinel; }, getPrototypeOf() { traps++; throw sentinel; }});
for (const value of [{}, proxy, Object.create(real), unrelated, 1, 'object']) {
const order = [];
const error = typeError(() => dictionary({
get before() { order.push('before'); return 'label'; },
get optional() { order.push('optional'); return value; },
get nullable() { order.push('nullable'); throw sentinel; }
}));
assert(error.message.includes('RenamedBase') && error.message.includes('optional'), 'interface member error context');
assert(order.join() === 'before,optional', 'validation precedes later getter');
}
assert(traps === 0, 'member value identity checks never invoke author traps');
assert(caught(() => dictionary({get optional() { throw sentinel; }})) === sentinel, 'getter exception identity');
const order = [];
dictionary({
get before() { order.push('before'); return 'label'; },
get optional() { order.push('optional'); return real; },
get nullable() { order.push('nullable'); return native; },
get special() { order.push('special'); return special; },
get after() { order.push('after'); return 1; }
});
assert(order.join() === 'before,optional,nullable,special,after', 'successful getter order');
return true;
})()
"#,
);
}