diff --git a/bindings/js/README.md b/bindings/js/README.md index b45bf24e..c799f379 100644 --- a/bindings/js/README.md +++ b/bindings/js/README.md @@ -280,6 +280,9 @@ Generated `IReference` values use `T | null` in JavaScript. Native values, `null`, and generated `IReference_*` wrappers are accepted as inputs. Collection factories take arrays of these inputs, typed as `(T | null | IReference_*)[]`, not a scalar or a `null` container. +Automatic JavaScript array and `Map` inputs box these elements in the same +way, including native PassArray arguments. Reads, iteration and returned +arrays contain `T | null`, never an `IReference_*` wrapper. The same projection applies when `IReference` appears inside a WinRT struct; packing boxes the field automatically and unpacking returns the native value. @@ -363,6 +366,22 @@ properties, and codegen emits the paired `IVector` binding automatically. ### Creating WinRT collections +Generated methods accept JavaScript arrays for `IVector`, `IVectorView` +and `IIterable`, and JavaScript `Map` values for `IMap` and +`IMapView`. A map-view input is an independently owned snapshot obtained +through `getView()`, not a query for another interface on the mutable map. +All five collection reference inputs also accept `null`, but not `undefined` +or an omitted argument. Empty arrays/maps create non-null collections. + +**Strict TypeScript consumers:** returned collection references can be `null`, +including getters, async results and collection-valued elements. Check for +`null` before accessing a returned collection. A map's `get()` distinguishes +a present `null` from a missing key's `undefined`; `at()` similarly returns +`undefined` only outside the vector's range. Collection factories still +return non-null wrappers, and array containers remain non-nullable. +Map `get()` uses separate `HasKey` and `Lookup` calls, so it is not atomic +against concurrent native mutation; a lookup failure still propagates. + `DynWinRtValue.createVector(items, elementType)` and `DynWinRtValue.createMap(keys, values, keyType, valueType)` keep their public signatures. They validate element identity, native layout, ownership, and the diff --git a/bindings/js/__test__/collection-factories.e2e.mjs b/bindings/js/__test__/collection-factories.e2e.mjs index 78919f62..43b4d598 100644 --- a/bindings/js/__test__/collection-factories.e2e.mjs +++ b/bindings/js/__test__/collection-factories.e2e.mjs @@ -10,6 +10,7 @@ import { basename, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { runCodegen } from '../scripts/run-codegen.mjs' import { checkNullableCollectionInputs } from './fixtures/nullable-collection-inputs.mjs' +import { checkCollectionContracts } from './fixtures/collection-contracts.mjs' const require = createRequire(import.meta.url) const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -103,6 +104,10 @@ test('generated nullable collection inputs reach the native slots without option t.diagnostic(JSON.stringify(checkNullableCollectionInputs(generated, require(runtimeRoot)))) }) +test('generated collection input and output contracts agree with native roundtrips', (t) => { + t.diagnostic(JSON.stringify(checkCollectionContracts(generated, require(runtimeRoot)))) +}) + test('generated string map factory converts both keys and values', (t) => { const keep = own(t) const keys = ['first', '\u03bb', '\ud83d\ude00'] diff --git a/bindings/js/__test__/fixtures/collection-contracts.mjs b/bindings/js/__test__/fixtures/collection-contracts.mjs new file mode 100644 index 00000000..1dc38426 --- /dev/null +++ b/bindings/js/__test__/fixtures/collection-contracts.mjs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +function ownership(g) { + const values = new Set() + const keep = (value) => { + if (value !== null) values.add(value) + return value + } + const release = (value) => { + if ('_obj' in value) g.releaseProjected(value) + else value.release() + values.delete(value) + } + return { keep, release, close: () => [...values].reverse().forEach(release) } +} + +function projected(g, prefix) { + const names = Object.keys(g).filter((name) => name.startsWith(prefix)) + assert.equal(names.length, 1, `Expected one projection: ${prefix}: ${names}`) + return g[names[0]] +} + +function unused() { + throw new Error('Unused complete metadata fixture slot') +} + +const numberExpected = { + code: 'NumberExpected', + message: 'Failed to convert napi value String into rust type `u32`', +} + +export function checkCollectionContracts(g, runtime) { + const scope = ownership(g) + const { keep, release } = scope + const received = [] + function sequence(kind, value) { + try { + const items = [] + if (typeof value.toArray === 'function') items.push(...value.toArray()) + else { + const iterator = keep(value.first()) + while (iterator.hasCurrent) { + items.push(iterator.current) + iterator.moveNext() + } + release(iterator) + } + received.push({ kind, items }) + } finally { + g.releaseProjected(value) + } + } + const owner = g.IProbe.implement({ + takeVector: (value) => sequence('vector', value), + takeView: (value) => sequence('view', value), + takeIterable: (value) => sequence('iterable', value), + takeObservable: unused, + takeMap: (value) => received.push({ kind: 'map', value: keep(value) }), + takeKeys: (value) => received.push({ kind: 'keys', value: keep(value) }), + takeMapView: (value) => received.push({ kind: 'mapView', value: keep(value) }), + takeNested: unused, + takeNestedValues: unused, + takeNestedKeys: unused, + takeVectorArray: (value) => received.push({ kind: 'array', items: value }), + takeArray: (value) => received.push({ kind: 'array', items: value }), + takeStrings: unused, + takeNumbers: unused, + takeBytes: unused, + getCollection: () => null, + setCollection: unused, + setWriteOnlyCollection: unused, + takePositions: unused, + }) + try { + const probe = owner.value + for (const name of ['takeVector', 'takeView', 'takeIterable', 'takeArray']) { + probe[name]([17, null]) + assert.deepEqual(received.at(-1).items, [17, null]) + } + const nil = keep(runtime.DynWinRtValue.nullValue()) + const carrier = keep(runtime.DynWinRtValue.boxReference(runtime.DynWinRtValue.u32(23), runtime.DynWinRtType.u32())) + const boxed = keep(g.IReference_UInt32.from(carrier)) + let reads = 0 + const wrapped = { + get _obj() { + reads++ + return boxed._obj + }, + } + for (const name of ['takeVector', 'takeView', 'takeIterable', 'takeArray']) { + const before = reads + probe[name]([17, null, nil, boxed, wrapped]) + assert.equal(reads, before + 1) + assert.deepEqual(received.at(-1).items, [17, null, null, 23, 23]) + } + probe.takeMap( + new Map([ + ['value', 17], + ['null', null], + ['managed', nil], + ['boxed', boxed], + ]), + ) + const map = received.at(-1).value + assert.equal(map.lookup('value'), 17) + assert.equal(map.get('null'), null) + assert.equal(map.get('managed'), null) + assert.equal(map.lookup('boxed'), 23) + assert.equal(map.get('missing'), undefined) + probe.takeKeys( + new Map([ + [boxed, 23], + [null, 17], + ]), + ) + const keys = received.at(-1).value + assert.equal(keys.lookup(boxed), 23) + assert.equal(keys.get(null), 17) + const beforeLookup = reads + assert.equal(keys.get(wrapped), 23) + assert.equal(reads, beforeLookup + 1) + assert.throws(() => keys.get('invalid'), numberExpected) + probe.takeKeys(new Map([[17, 23]])) + assert.equal(received.at(-1).value.size, 1) + // Keep IReference's existing undefined convention separate from collection inputs. + probe.takeArray([undefined]) + assert.deepEqual(received.at(-1).items, [null]) + + let keyReads = 0 + let valueReads = 0 + class CountedMap extends Map { + keys() { + keyReads++ + return super.keys() + } + values() { + valueReads++ + return super.values() + } + } + for (const items of [ + [], + [ + ['value', 17], + ['null', null], + ['boxed', boxed], + ], + ]) { + const source = new CountedMap(items) + probe.takeMapView(source) + const view = received.at(-1).value + assert.equal(view.size, items.length) + if (items.length) { + assert.equal(view.lookup('value'), 17) + assert.equal(view.get('null'), null) + assert.equal(view.lookup('boxed'), 23) + } + source.clear() + source.set('later', 99) + assert.equal(view.size, items.length) + assert.equal(view.hasKey('later'), false) + assert.equal(view.get('later'), undefined) + } + assert.equal(keyReads, 2) + assert.equal(valueReads, 2) + + const Vector = projected(g, 'IVector_WindowsFoundationIReference_UInt32_') + const vector = keep(Vector.create([17, null])) + assert.deepEqual(vector.getMany(0, [null, null]), [17, null]) + assert.deepEqual(vector.toArray(), [17, null]) + assert.equal(vector.at(1), null) + assert.equal(vector.at(2), undefined) + const Nested = projected(g, 'IVector_WindowsFoundationCollectionsIVectorView_UInt32_') + const Values = projected(g, 'IMap_String_WindowsFoundationCollectionsIVectorView_UInt32_') + const nested = keep(Nested.create([null])) + const nullableMap = keep(Values.create(['present'], [null])) + assert.equal(nested.getAt(0), null) + assert.equal(nested.at(-1), null) + assert.equal(nested.at(1), undefined) + assert.deepEqual(nested.toArray(), [null]) + assert.deepEqual([...nested], [null]) + assert.equal(nullableMap.lookup('present'), null) + assert.equal(nullableMap.get('present'), null) + assert.equal(nullableMap.get('missing'), undefined) + probe.takeVectorArray([null]) + assert.deepEqual(received.at(-1).items, [null]) + assert.equal(probe.collection, null) + assert.equal(owner.takeError(), null) + + const count = received.length + for (const name of ['takeVector', 'takeView', 'takeIterable', 'takeMap', 'takeMapView']) { + assert.throws(() => probe[name](undefined), /cast/) + } + const wrong = keep(g.NotificationData.createDefault()) + assert.throws(() => probe.takeMapView(wrong), /QueryInterface/) + assert.throws(() => keys.get(wrong), { code: 'GenericFailure', message: /^0x80004002:/ }) + assert.throws(() => probe.takeVector(['bad']), numberExpected) + assert.throws(() => probe.takeMap(new Map([['bad', 'bad']])), numberExpected) + assert.throws(() => probe.takeMapView(new Map([['bad', 'bad']])), numberExpected) + assert.equal(received.length, count, 'conversion failures must not dispatch') + assert.equal(owner.takeError(), null) + return { automaticBoxing: true, ownedMapViews: true, nullableReadbacks: true, reads, keyReads, valueReads } + } finally { + scope.close() + owner.dispose() + owner.release() + } +} + +export function checkNestedMapViews(g) { + const scope = ownership(g) + const { keep, release } = scope + const received = [] + const owner = g.IMapViewProbe.implement({ + take: (value) => received.push(keep(value)), + takeNested: (value) => received.push(keep(value)), + takeVector: (value) => received.push(keep(value)), + }) + try { + owner.value.take(new Map([['k', 17]])) + const view = received.at(-1) + assert.equal(view.lookup('k'), 17) + assert.equal(view.hasKey('absent'), false) + const map = keep(g.IMap_String_UInt32.create(['k'], [23])) + const snapshot = keep(map.getView()) + map.set('k', 99) + assert.equal(snapshot.lookup('k'), 23) + release(map) + assert.equal(snapshot.lookup('k'), 23) + owner.value.takeNested( + new Map([ + ['present', snapshot], + ['null', null], + ]), + ) + const outer = received.at(-1) + release(snapshot) + assert.equal(keep(outer.lookup('present')).lookup('k'), 23) + assert.equal(outer.lookup('null'), null) + assert.equal(outer.get('null'), null) + assert.equal(outer.get('missing'), undefined) + owner.value.takeVector([view, null]) + const vector = received.at(-1) + release(view) + assert.equal(keep(vector.getAt(0)).lookup('k'), 17) + assert.equal(vector.getAt(1), null) + const count = received.length + assert.throws(() => owner.value.takeNested(new Map([['invalid', undefined]])), /cast/) + assert.equal(received.length, count) + assert.equal(owner.takeError(), null) + return { nestedViewsRetained: true, snapshotAfterSourceRelease: true } + } finally { + scope.close() + owner.dispose() + owner.release() + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const require = createRequire(import.meta.url) + const [generated, packageRoot] = process.argv.slice(2) + const runtime = require(resolve(packageRoot)) + runtime.roInitialize(1) + const g = require(resolve(generated)) + if (g.IProbe) console.log(JSON.stringify(checkCollectionContracts(g, runtime))) + if (g.IMapViewProbe) console.log(JSON.stringify(checkNestedMapViews(g))) +} diff --git a/docs/architecture/javascript-binding-internals.md b/docs/architecture/javascript-binding-internals.md index 12477728..b5802984 100644 --- a/docs/architecture/javascript-binding-internals.md +++ b/docs/architecture/javascript-binding-internals.md @@ -109,6 +109,11 @@ and values) using the same metadata-directed argument projection as ordinary methods. Native `createVector`/`createMap` receive managed `DynWinRtValue` carriers, not unconverted JavaScript primitives. Conversion does not expand the native producer's supported ABI or ownership boundary. +Automatic array/Map inputs and native PassArray elements reuse the ordinary +`IReference` argument conversion: native values are boxed, native/managed +nulls remain null, and existing reference wrappers are unwrapped once. Reading +an `IReference` element (including a returned native array) produces the +native value or `null`, not a reference wrapper. Runtime-class and collection-reference conversions preserve managed null carriers without querying an interface; non-null carriers still query the declared interface before being passed to native code. @@ -133,6 +138,29 @@ native PassArray/FillArray containers stay nonnullable. Scalar and struct elements are not made nullable. This projection does not add support for previously rejected struct collection ABI shapes. +JavaScript `Map` inputs for `IMapView` create a temporary typed `IMap` +and call its metadata-generated `getView()`. The returned view owns its +snapshot independently of the temporary map, which is released before the +view is passed onward. QueryInterface is only used to select existing +interfaces; it never creates a snapshot or a new COM identity. The JavaScript +projection resolves the source `IMap` through the ordinary generic metadata +dependency traversal, including when only an `IMapView` appears in the input +metadata. Map keys and values are each enumerated once, as for mutable map +inputs. + +**Strict TypeScript migration:** outputs of `IVector`, `IVectorView`, +`IIterable`, `IMap`, and `IMapView` are nullable references. +Methods, property getters, async results, native array elements, and collection +read helpers now declare the `null` that their conversion can return. Add a +null guard before dereferencing a returned collection. `get()` and `at()` +also retain `undefined` for a missing key or out-of-range index; a present +null element is not missing. Map `get()` converts the key once and checks +`HasKey` before `Lookup`, so conversion/native failures propagate instead of +being mistaken for missing entries. These are separate native calls, not an +atomic lookup: concurrent mutation between them can change the result or cause +`Lookup` to fail. Array containers, scalar/struct values, +and non-null collection factories are not made nullable. + Typed map construction uses the same key equality as `Insert`. For duplicate keys, the last value wins while the first key and its insertion position are retained. All keys and values are validated before publishing the map, including @@ -153,6 +181,12 @@ fixture used by `test:collection-factories`. That production-artifact E2E path always calls the complete generated WinRT implementation through native vtable dispatch to check null inputs, preserved argument roles, rejection before dispatch, and distinct empty collections. It does not require test hooks. +Setting `DYNWINRT_JS_PACKAGE` for the declaration test additionally checks the +real runtime declarations and executes the generated roundtrips, including a +standalone MapView-only dependency graph. Its supplemental WinMD builder covers +nullable nested collections, key/value pair properties, arrays and async +signatures. The plain `fixtures/collection-contracts.mjs` runner also works on +Node 18 without `node:test` lifecycle hooks. `value.rs` defines `DynWinRTValue` with named WinRT data, independent call storage, and the private `com_value.rs` sidecar. Callers use constructors and accessors, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/input.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/input.rs index 71da0fee..50f7acce 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/input.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/input.rs @@ -9,6 +9,7 @@ use crate::types::TypeMeta; pub(crate) enum CollectionInput<'a> { Vector(&'a TypeMeta), Map(&'a TypeMeta, &'a TypeMeta), + MapView(&'a TypeMeta, &'a TypeMeta), } impl<'a> CollectionInput<'a> { @@ -28,17 +29,25 @@ impl<'a> CollectionInput<'a> { { Some(Self::Vector(element)) } - [key, value] - if [ - "3c2925fe-8519-45c1-aa79-197b6718c1c1", - "e480ce40-a338-4ada-adcf-272272e48cb9", - ] - .iter() - .any(|expected| piid.eq_ignore_ascii_case(expected)) => - { + [key, value] if piid.eq_ignore_ascii_case("3c2925fe-8519-45c1-aa79-197b6718c1c1") => { Some(Self::Map(key, value)) } + [key, value] if piid.eq_ignore_ascii_case("e480ce40-a338-4ada-adcf-272272e48cb9") => { + Some(Self::MapView(key, value)) + } _ => None, } } + + pub(crate) fn map_view_source(typ: &'a TypeMeta) -> Option { + let Self::MapView(key, value) = Self::from_type(typ)? else { + return None; + }; + Some(TypeMeta::Parameterized { + namespace: crate::meta::WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE.into(), + name: "IMap".into(), + piid: "3c2925fe-8519-45c1-aa79-197b6718c1c1".into(), + args: vec![key.clone(), value.clone()], + }) + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/method.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/method.rs index 6669cb08..334d8bb4 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/method.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/method.rs @@ -125,7 +125,7 @@ pub(crate) fn ts_param_type_dts( let elem_ts = ts_reference_input_type(context, element, known); format!("{} | {} | null", base, ts_array_type(&elem_ts)) } - CollectionInput::Map(key, value) => { + CollectionInput::Map(key, value) | CollectionInput::MapView(key, value) => { let k_ts = ts_reference_input_type(context, key, known); let v_ts = ts_reference_input_type(context, value, known); format!("{} | Map<{}, {}> | null", base, k_ts, v_ts) @@ -154,6 +154,20 @@ pub(crate) fn ts_return_type_safe( } match typ { + Some(typ) if CollectionInput::from_type(typ).is_some() => { + let name = ts_param_type(context, typ); + let base = if known.contains(&name) { + &name + } else { + "DynWinRtValue" + }; + let result = format!("{base} | null"); + if is_async { + format!("Promise<{result}>") + } else { + result + } + } Some(TypeMeta::RuntimeClass { name, .. }) | Some(TypeMeta::Enum { name, .. }) | Some(TypeMeta::Interface { name, .. }) @@ -179,7 +193,7 @@ pub(crate) fn ts_return_type_safe( ) } Some(TypeMeta::Array(inner)) => { - let s = ts_array_element_type(inner, known); + let s = ts_array_element_type(context, inner, known); if is_async { format!("Promise<{}>", s) } else { @@ -259,7 +273,7 @@ fn ts_return_type( return "Promise & { progress(cb: (value: unknown) => void): Promise & { progress: any; toPromise(): Promise; cancel(): void; }; toPromise(): Promise; cancel(): void; }".to_string(); } Some(TypeMeta::Array(inner)) => { - let s = ts_array_element_type(inner, &HashSet::new()); + let s = ts_array_element_type(context, inner, &HashSet::new()); return if is_async { format!("Promise<{}>", s) } else { @@ -286,7 +300,19 @@ fn ts_return_type( } /// TypeScript return type annotation for an array element type. -pub(crate) fn ts_array_element_type(inner: &TypeMeta, known_types: &HashSet) -> String { +pub(crate) fn ts_array_element_type( + context: &JavaScriptProjectionContext, + inner: &TypeMeta, + known_types: &HashSet, +) -> String { + if ireference_inner_type(inner).is_some() || CollectionInput::from_type(inner).is_some() { + return ts_array_type(&ts_return_type_safe( + context, + Some(inner), + false, + known_types, + )); + } match inner { TypeMeta::Bool => "boolean[]".to_string(), TypeMeta::String | TypeMeta::Guid => "string[]".to_string(), @@ -356,4 +382,70 @@ mod tests { ); } } + + #[test] + fn collection_output_types_follow_identity_and_return_conversion() { + let context = JavaScriptProjectionContext::default(); + for (piid, args) in [ + ("913337e9-11a1-4345-a3a2-4e7f956e222d", vec![TypeMeta::U32]), + ("bbe1fa4c-b0e3-4583-baef-1f1b2e483e56", vec![TypeMeta::U32]), + ("faa585ea-6214-4217-afda-7f46de5869b3", vec![TypeMeta::U32]), + ( + "3c2925fe-8519-45c1-aa79-197b6718c1c1", + vec![TypeMeta::String, TypeMeta::U32], + ), + ( + "e480ce40-a338-4ada-adcf-272272e48cb9", + vec![TypeMeta::String, TypeMeta::U32], + ), + ] { + let typ = TypeMeta::Parameterized { + namespace: "Tests".into(), + name: "Renamed".into(), + piid: piid.to_uppercase(), + args, + }; + let name = ts_param_type(&context, &typ); + let known = HashSet::from([name.clone()]); + assert_eq!( + ts_return_type_safe(&context, Some(&typ), false, &known), + format!("{name} | null") + ); + assert_eq!( + ts_return_type_safe(&context, Some(&typ), true, &known), + format!("Promise<{name} | null>") + ); + assert_eq!( + ts_array_element_type(&context, &typ, &known), + format!("({name} | null)[]") + ); + assert_eq!( + ts_return_type_safe(&context, Some(&typ), false, &HashSet::new()), + "DynWinRtValue | null" + ); + assert_eq!( + ts_array_element_type(&context, &typ, &HashSet::new()), + "(DynWinRtValue | null)[]" + ); + } + for (typ, expected) in [ + (TypeMeta::U32, "number"), + (TypeMeta::String, "string"), + ( + TypeMeta::Struct { + namespace: "Tests".into(), + name: "Point".into(), + fields: vec![], + }, + "Point", + ), + (TypeMeta::Array(Box::new(TypeMeta::U32)), "number[]"), + (TypeMeta::Array(Box::new(TypeMeta::U8)), "Buffer"), + ] { + assert_eq!( + ts_return_type_safe(&context, Some(&typ), false, &HashSet::new()), + expected + ); + } + } } diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs index 3949c717..5f3b2cce 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/mod.rs @@ -25,6 +25,31 @@ use super::shared::implementation_symbols::{HelperOwner, allocate_helpers}; pub use super::shared::implementation_symbols::{ImplementationHelper, interface_helpers}; use super::shared::structs::{collect_used_structs_from_class, collect_used_structs_from_iface}; +pub fn resolve_dependencies( + winmd_paths: &str, + classes: &[ClassMeta], + interfaces: &[InterfaceMeta], + enums: &[TypeMeta], +) -> crate::meta::ResolvedDeps { + crate::meta::resolve_dependencies_with_projection( + winmd_paths, + classes, + interfaces, + enums, + |interface| { + let typ = TypeMeta::Parameterized { + namespace: interface.namespace.clone(), + name: interface.name.clone(), + piid: interface.generic_piid.clone().unwrap_or_default(), + args: interface.generic_args.clone(), + }; + input::CollectionInput::map_view_source(&typ) + .into_iter() + .collect() + }, + ) +} + pub fn implementation_helper_records(interface: &InterfaceMeta) -> Vec { let context = JavaScriptProjectionContext::default(); let projection = implementation::project(&context, interface, &Default::default()); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/collections.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/collections.rs index bf20b75f..fc946231 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/collections.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/collections.rs @@ -122,7 +122,8 @@ pub(super) fn project_collection_helpers( match piid { PIID_IVECTOR | PIID_IVECTOR_VIEW if iface.generic_args.len() == 1 => { - let elem_ts = ts_param_type_safe(context, &iface.generic_args[0], known_types); + let elem_ts = + ts_return_type_safe(context, Some(&iface.generic_args[0]), false, known_types); members.push(ProjectedMember::Symbol(ProjectedSymbol { kind: SymbolKind::CollectionLength, doc: Some( @@ -304,7 +305,8 @@ pub(super) fn project_collection_helpers( } } PIID_IITERATOR if iface.generic_args.len() == 1 => { - let elem_ts = ts_param_type_safe(context, &iface.generic_args[0], known_types); + let elem_ts = + ts_return_type_safe(context, Some(&iface.generic_args[0]), false, known_types); members.push(ProjectedMember::Symbol(ProjectedSymbol { kind: SymbolKind::IteratorNext { element_type: elem_ts.clone(), @@ -321,7 +323,8 @@ pub(super) fn project_collection_helpers( })); } PIID_IITERABLE if iface.generic_args.len() == 1 => { - let elem_ts = ts_param_type_safe(context, &iface.generic_args[0], known_types); + let elem_ts = + ts_return_type_safe(context, Some(&iface.generic_args[0]), false, known_types); members.push(ProjectedMember::Symbol(ProjectedSymbol { kind: SymbolKind::Iterator { element_type: elem_ts, @@ -332,7 +335,8 @@ pub(super) fn project_collection_helpers( } PIID_IMAP | PIID_IMAP_VIEW if iface.generic_args.len() == 2 => { let key_ts = ts_reference_input_type(context, &iface.generic_args[0], known_types); - let val_ts = ts_param_type_safe(context, &iface.generic_args[1], known_types); + let val_ts = + ts_return_type_safe(context, Some(&iface.generic_args[1]), false, known_types); let val_input_ts = ts_reference_input_type(context, &iface.generic_args[1], known_types); let key_ts = if key_ts == "DynWinRtValue" { @@ -353,23 +357,25 @@ pub(super) fn project_collection_helpers( // JS Map-like aliases let iface_var = format!("_{}", iface.name); // get(key) — alias for lookup - if let Some(lookup_idx) = iface - .methods - .iter() - .find(|m| m.name == "Lookup") - .map(|m| m.vtable_index) - { + if let (Some(lookup_idx), Some(has_idx)) = ( + iface + .methods + .iter() + .find(|m| m.name == "Lookup") + .map(|m| m.vtable_index), + iface + .methods + .iter() + .find(|m| m.name == "HasKey") + .map(|m| m.vtable_index), + ) { let key_wrap = wrap_arg(context, "key", &iface.generic_args[0]); - // A failed collection-key conversion is not a missing map entry. - let (key_setup, key_arg) = - if CollectionInput::from_type(&iface.generic_args[0]).is_some() { - (format!("const _key = {key_wrap}; "), "_key".to_string()) - } else { - (String::new(), key_wrap) - }; + // Conversion failures must not be mistaken for missing entries. + let invoke = + format!("{iface_var}.method({lookup_idx}).invoke({object_expr}, [_key])"); let return_convert = convert_return( context, - &format!("{iface_var}.method({lookup_idx}).invoke({object_expr}, [{key_arg}])"), + &invoke, Some(&iface.generic_args[1]), false, known_types, @@ -395,7 +401,7 @@ pub(super) fn project_collection_helpers( is_static: false, invoke_expr: String::new(), sync_return_expr: Some(format!( - "(() => {{ {key_setup}try {{ return {}; }} catch {{ return undefined; }} }})()", + "(() => {{ const _key = {key_wrap}; if (!{iface_var}.method({has_idx}).invoke({object_expr}, [_key]).toBool()) return undefined; return {}; }})()", return_convert )), async_convert_v: None, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/methods.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/methods.rs index d917433e..73776e53 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/methods.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/methods.rs @@ -197,7 +197,7 @@ fn output_ts_type( { "Array".into() } - TypeMeta::Array(inner) => ts_array_element_type(inner, known_types), + TypeMeta::Array(inner) => ts_array_element_type(context, inner, known_types), _ => projected_ts_return_type(context, Some(typ), false, known_types, delegate_names), } } @@ -517,7 +517,7 @@ pub(super) fn project_static_method( if is_projected_delegate_type(context, Some(elem), delegate_names) { "Array".into() } else { - ts_array_element_type(elem, known_types) + ts_array_element_type(context, elem, known_types) } } else if let Some(output) = single_out { output_ts_type(context, output, known_types, delegate_names) @@ -946,7 +946,7 @@ pub(super) fn project_instance_method( if is_projected_delegate_type(context, Some(elem), delegate_type_names) { "Array".into() } else { - ts_array_element_type(elem, known_types) + ts_array_element_type(context, elem, known_types) } } else if let Some(output) = single_out { output_ts_type(context, output, known_types, delegate_type_names) diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs index 413eede8..c03de170 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs @@ -68,6 +68,15 @@ fn visit_projected_generics( args, } => { names.insert(context.projected_parameterized_name(namespace, name, piid, args)); + if let Some(TypeMeta::Parameterized { + namespace, + name, + piid, + args, + }) = CollectionInput::map_view_source(typ) + { + names.insert(context.projected_parameterized_name(&namespace, &name, &piid, &args)); + } for argument in args { visit_projected_generics(context, argument, names); } @@ -106,6 +115,51 @@ fn collect_used_generics_from_methods( names } +#[cfg(test)] +mod generic_dependency_tests { + use super::*; + use crate::meta::{ParamMeta, WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE}; + + #[test] + fn nested_map_views_collect_both_argument_branches_and_deduplicate_names() { + let context = JavaScriptProjectionContext::default(); + let view = |args| TypeMeta::Parameterized { + namespace: WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE.into(), + name: "IMapView".into(), + piid: PIID_IMAP_VIEW.into(), + args, + }; + let number_args = vec![TypeMeta::String, TypeMeta::U32]; + let string_args = vec![TypeMeta::String, TypeMeta::String]; + let outer_args = vec![view(number_args.clone()), view(string_args.clone())]; + let nested = view(outer_args.clone()); + let method = MethodMeta { + params: vec![ParamMeta { + name: "value".into(), + typ: nested.clone(), + direction: ParamDirection::In, + }], + return_type: Some(nested), + ..Default::default() + }; + let actual = collect_used_generics_from_methods(&context, &[method]); + let mut expected = Vec::new(); + for args in [number_args, string_args, outer_args] { + for (name, piid) in [("IMapView", PIID_IMAP_VIEW), ("IMap", PIID_IMAP)] { + expected.push(context.projected_parameterized_name( + WINDOWS_FOUNDATION_COLLECTIONS_NAMESPACE, + name, + piid, + &args, + )); + } + } + expected.sort(); + assert_eq!(actual, expected); + assert_eq!(actual.len(), 6); + } +} + fn collect_used_generics_from_class( context: &JavaScriptProjectionContext, class: &ClassMeta, diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/signature.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/signature.rs index 1d543bac..ac9fb2b3 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/signature.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/signature.rs @@ -392,16 +392,35 @@ pub(crate) fn wrap_arg( "({name} === null ? DynWinRtValue.nullValue() : Array.isArray({name}) ? DynWinRtValue.createVector({name}.map(_i => {item_wrap}), {elem_type}).cast({target_iid_expr}) : {reference})" ) } - // The map producer also needs QI from its identity IIterable view. - Some(CollectionInput::Map(key, value)) => { + Some(CollectionInput::Map(key, value) | CollectionInput::MapView(key, value)) => { let key_type = ts_dynwinrt_type(context, key); let val_type = ts_dynwinrt_type(context, value); let k_wrap = vector_item_wrap_expr(context, "_k", key); let v_wrap = vector_item_wrap_expr(context, "_v", value); let target_iid_expr = format!("{}.iid()", ts_dynwinrt_type(context, typ)); let reference = nullable_reference_cast_expr(name, &target_iid_expr); + let map = format!( + "DynWinRtValue.createMap([...{name}.keys()].map(_k => {k_wrap}), [...{name}.values()].map(_v => {v_wrap}), {key_type}, {val_type})" + ); + let collection = if let Some(TypeMeta::Parameterized { + namespace, + name, + piid, + args, + }) = CollectionInput::map_view_source(typ) + { + let source = ref_marker( + &context.projected_parameterized_name(&namespace, &name, &piid, &args), + ); + // GetView owns a separate snapshot; QI cannot manufacture that identity. + format!( + "((value) => {{ let map; try {{ map = new {source}(value); return map.getView()._obj; }} finally {{ map?._obj.release(); value.release(); }} }})({map})" + ) + } else { + format!("{map}.cast({target_iid_expr})") + }; format!( - "({name} === null ? DynWinRtValue.nullValue() : {name} instanceof Map ? DynWinRtValue.createMap([...{name}.keys()].map(_k => {k_wrap}), [...{name}.values()].map(_v => {v_wrap}), {key_type}, {val_type}).cast({target_iid_expr}) : {reference})" + "({name} === null ? DynWinRtValue.nullValue() : {name} instanceof Map ? {collection} : {reference})" ) } None => format!("_unwrap({})", name), @@ -522,6 +541,9 @@ fn vector_item_wrap_expr( var: &str, elem: &TypeMeta, ) -> String { + if ireference_inner_type(elem).is_some() { + return wrap_arg(context, var, elem); + } match elem { TypeMeta::Struct { name, .. } if name == "HResult" => { format!("DynWinRtValue.hresult({})", var) @@ -613,6 +635,10 @@ pub(crate) fn convert_array_return( known_types: &HashSet, deferred: &HashSet, ) -> String { + if ireference_inner_type(inner).is_some() { + let value = convert_return(context, "v", Some(inner), false, known_types, deferred); + return format!("{arr_expr}.toValues().map(v => {value})"); + } match inner { TypeMeta::I8 => format!("{}.toI8Vec()", arr_expr), // U8 returns: hand back a Node Buffer (Uint8Array view), avoiding the diff --git a/tools/dynwinrt-codegen/src/main.rs b/tools/dynwinrt-codegen/src/main.rs index 894a8ac7..c0c33448 100644 --- a/tools/dynwinrt-codegen/src/main.rs +++ b/tools/dynwinrt-codegen/src/main.rs @@ -63,7 +63,7 @@ fn resolve_dependencies_for_lang( if lang == "py" { meta::resolve_python_dependencies(winmd, classes, interfaces, enums) } else { - meta::resolve_dependencies(winmd, classes, interfaces, enums) + javascript::resolve_dependencies(winmd, classes, interfaces, enums) } } diff --git a/tools/dynwinrt-codegen/src/meta.rs b/tools/dynwinrt-codegen/src/meta.rs index fdccd0df..b9fb01ef 100644 --- a/tools/dynwinrt-codegen/src/meta.rs +++ b/tools/dynwinrt-codegen/src/meta.rs @@ -528,6 +528,26 @@ pub fn resolve_dependencies( existing_enums, false, true, + |_| Vec::new(), + ) +} + +/// Resolve metadata plus language-projection interface dependencies at each fixpoint step. +pub fn resolve_dependencies_with_projection( + winmd_paths: &str, + classes: &[ClassMeta], + existing_interfaces: &[InterfaceMeta], + existing_enums: &[TypeMeta], + projection_dependencies: fn(&InterfaceMeta) -> Vec, +) -> ResolvedDeps { + resolve_dependencies_impl( + winmd_paths, + classes, + existing_interfaces, + existing_enums, + false, + true, + projection_dependencies, ) } @@ -545,6 +565,7 @@ pub fn resolve_python_dependencies( existing_enums, true, true, + |_| Vec::new(), ) } @@ -555,6 +576,7 @@ fn resolve_dependencies_impl( existing_enums: &[TypeMeta], include_inheritance: bool, preserve_semantic_identity: bool, + projection_dependencies: fn(&InterfaceMeta) -> Vec, ) -> ResolvedDeps { let index = match load_index(winmd_paths) { Some(idx) => idx, @@ -610,6 +632,7 @@ fn resolve_dependencies_impl( include_inheritance, preserve_semantic_identity, ); + param_worklist.extend(existing_interfaces.iter().flat_map(projection_dependencies)); // Fixpoint: keep resolving until no new types are discovered loop { @@ -740,6 +763,7 @@ fn resolve_dependencies_impl( include_inheritance, preserve_semantic_identity, ); + param_worklist.extend(new_interfaces.iter().flat_map(projection_dependencies)); dep_classes.extend(new_classes); dep_interfaces.extend(new_interfaces); diff --git a/tools/dynwinrt-codegen/tests/collection_factory_test.rs b/tools/dynwinrt-codegen/tests/collection_factory_test.rs index a078d41c..c98177ea 100644 --- a/tools/dynwinrt-codegen/tests/collection_factory_test.rs +++ b/tools/dynwinrt-codegen/tests/collection_factory_test.rs @@ -201,7 +201,7 @@ fn nullable_collection_declarations_group_array_elements_in_every_role() { "{dts}" ); if piid == VECTOR { - assert!(dts.contains(&format!("toArray(): {array};")), "{dts}"); + assert!(dts.contains("toArray(): (number | null)[];"), "{dts}"); } } let (_, dts) = project_factory(MAP, vec![reference.clone(), reference]); diff --git a/tools/dynwinrt-codegen/tests/union_array_declaration_test.rs b/tools/dynwinrt-codegen/tests/union_array_declaration_test.rs index d760302e..7c93dfbd 100644 --- a/tools/dynwinrt-codegen/tests/union_array_declaration_test.rs +++ b/tools/dynwinrt-codegen/tests/union_array_declaration_test.rs @@ -24,11 +24,10 @@ fn closed(namespace: &str, name: &str, generics: Vec) -> Type { }) } -fn fixture() -> Vec { - let mut file = writer::File::new("UnionArrayDeclarations"); +fn add_interface(file: &mut writer::File, name: &str, guid_prefix: u32) { let interface = file.TypeDef( "Tests", - "IProbe", + name, writer::TypeDefOrRef::default(), TypeAttributes::Public | TypeAttributes::Interface @@ -61,7 +60,7 @@ fn fixture() -> Vec { writer::HasAttribute::TypeDef(interface), writer::AttributeType::MemberRef(constructor), &[ - Value::U32(0x86270e31), + Value::U32(guid_prefix), Value::U16(0x9c04), Value::U16(0x4fc2), Value::U8(0x9c), @@ -77,6 +76,11 @@ fn fixture() -> Vec { .map(|value| (String::new(), value)) .collect::>(), ); +} + +fn fixture() -> Vec { + let mut file = writer::File::new("UnionArrayDeclarations"); + add_interface(&mut file, "IProbe", 0x86270e31); let reference = closed("Windows.Foundation", "IReference`1", vec![Type::U32]); let vector = closed(COLLECTIONS, "IVectorView`1", vec![Type::U32]); for (name, typ) in [ @@ -195,6 +199,117 @@ fn fixture() -> Vec { file.into_stream() } +fn output_fixture() -> Vec { + let mut file = writer::File::new("CollectionOutputs"); + add_interface(&mut file, "ICollectionOutputs", 0x0f40a0d1); + let types = [ + ("Vector", closed(COLLECTIONS, "IVector`1", vec![Type::U32])), + ( + "View", + closed(COLLECTIONS, "IVectorView`1", vec![Type::U32]), + ), + ( + "Iterable", + closed(COLLECTIONS, "IIterable`1", vec![Type::U32]), + ), + ( + "Map", + closed(COLLECTIONS, "IMap`2", vec![Type::String, Type::U32]), + ), + ( + "MapView", + closed(COLLECTIONS, "IMapView`2", vec![Type::String, Type::U32]), + ), + ]; + for (suffix, typ) in types { + for (name, result) in [ + (format!("Read{suffix}"), typ.clone()), + (format!("get_{suffix}"), typ.clone()), + ( + format!("Read{suffix}Array"), + Type::Array(Box::new(typ.clone())), + ), + ( + format!("Read{suffix}Async"), + closed("Windows.Foundation", "IAsyncOperation`1", vec![typ.clone()]), + ), + ( + format!("Read{suffix}Progress"), + closed( + "Windows.Foundation", + "IAsyncOperationWithProgress`2", + vec![typ.clone(), Type::U32], + ), + ), + ( + format!("ReadNested{suffix}"), + closed(COLLECTIONS, "IVector`1", vec![typ.clone()]), + ), + ( + format!("Read{suffix}Map"), + closed(COLLECTIONS, "IMap`2", vec![typ.clone(), typ.clone()]), + ), + ( + format!("Read{suffix}Pairs"), + closed( + COLLECTIONS, + "IIterable`1", + vec![closed( + COLLECTIONS, + "IKeyValuePair`2", + vec![typ.clone(), typ.clone()], + )], + ), + ), + ] { + file.MethodDef( + &name, + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: result, + types: vec![], + }, + MethodAttributes::Public + | MethodAttributes::Abstract + | MethodAttributes::Virtual + | MethodAttributes::NewSlot + | if name.starts_with("get_") { + MethodAttributes::SpecialName + } else { + MethodAttributes::default() + }, + MethodImplAttributes::default(), + ); + } + } + add_interface(&mut file, "IMapViewProbe", 0x0f40a0d2); + let view = closed(COLLECTIONS, "IMapView`2", vec![Type::String, Type::U32]); + for (name, typ) in [ + ("Take", view.clone()), + ( + "TakeNested", + closed(COLLECTIONS, "IMapView`2", vec![Type::String, view.clone()]), + ), + ("TakeVector", closed(COLLECTIONS, "IVector`1", vec![view])), + ] { + file.MethodDef( + name, + &Signature { + flags: MethodCallAttributes::HASTHIS, + return_type: Type::Void, + types: vec![typ], + }, + MethodAttributes::Public + | MethodAttributes::Abstract + | MethodAttributes::Virtual + | MethodAttributes::NewSlot, + MethodImplAttributes::default(), + ); + file.Param("value", 1, ParamAttributes::In); + } + file.into_stream() +} + #[test] fn native_collection_input_fixture_matches_metadata_builder() { assert_eq!( @@ -255,30 +370,46 @@ fn sdk_backed_union_arrays_pass_strict_tsc_and_reject_scalar_containers() { include_bytes!("fixtures/nullable_collection_inputs.winmd"), ) .unwrap(); + let outputs = directory.join("Outputs.winmd"); + fs::write(&outputs, output_fixture()).unwrap(); let generated = directory.join("generated"); let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) .args([ "generate", "--class-name", - "Tests.IProbe,Windows.UI.Notifications.NotificationData", + "Tests.IProbe,Tests.ICollectionOutputs,Tests.IMapViewProbe,Windows.UI.Notifications.NotificationData", "--lang", "js", "--winmd", ]) - .arg(format!("{};{}", input.display(), windows_winmd.display())) + .arg(format!("{};{};{}", input.display(), outputs.display(), windows_winmd.display())) .arg("--output") .arg(&generated) .output() .expect("run freshly built codegen"); assert!(output.status.success(), "codegen failed: {output:?}"); - // No native addon is needed: declarations use only these opaque runtime types. + let native_runtime = std::env::var_os("DYNWINRT_JS_PACKAGE").map(PathBuf::from); let runtime = directory.join("node_modules/@microsoft/dynwinrt"); - fs::create_dir_all(&runtime).unwrap(); - fs::write(runtime.join("package.json"), r#"{"types":"index.d.ts"}"#).unwrap(); - fs::write( - runtime.join("index.d.ts"), - r#" + if let Some(source) = &native_runtime { + fs::create_dir_all(runtime.parent().unwrap()).unwrap(); + let link = Command::new("node") + .args([ + "-e", + "require('node:fs').symlinkSync(process.argv[1], process.argv[2], 'junction')", + ]) + .arg(source) + .arg(&runtime) + .output() + .expect("link the production runtime without copying or modifying it"); + assert!(link.status.success(), "Runtime link failed: {link:?}"); + } else { + // Declarations can also be checked without a built native addon. + fs::create_dir_all(&runtime).unwrap(); + fs::write(runtime.join("package.json"), r#"{"types":"index.d.ts"}"#).unwrap(); + fs::write( + runtime.join("index.d.ts"), + r#" export declare class WinGuid { private readonly brand: unknown; } export declare class DynWinRtType { private readonly brand: unknown; } export declare class DynWinRtValue { private readonly brand: unknown; } @@ -293,8 +424,9 @@ export interface DynWinRtImplementationOptions>() .join(", "); let prefix = format!( - "import {{ IProbe, IReference_UInt32, NotificationData, {imports} }} from './generated/index.js';\n\ + "import {{ IProbe, ICollectionOutputs, IReference_UInt32, NotificationData, IVector_UInt32, IVectorView_UInt32, IIterable_UInt32, IMap_String_UInt32, IMapView_String_UInt32, {imports} }} from './generated/index.js';\n\ declare const probe: IProbe;\n\ declare const boxed: IReference_UInt32;\n\ declare const vector: Vector;\n\ @@ -351,7 +483,7 @@ export interface DynWinRtImplementationOptions = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +function exact(): void {{}} +exact, number | null>>(); +exact, number | null | undefined>>(); +exact, (number | null)[]>>(); +exact, (number | null)[]>>(); +exact, IterableIterator>>(); +exact, number | null>>(); +exact, number | null | undefined>>(); +exact, Vector>>(); +exact, Values>>(); +exact, IVectorView_UInt32 | null>>(); +exact, IVectorView_UInt32 | null | undefined>>(); +exact, (IVectorView_UInt32 | null)[]>>(); +exact, IVectorView_UInt32 | null | undefined>>(); +const fromNullMap = NestedValues.create(['present'], [null]).get('present'); +exact>(); +if (fromNullMap !== undefined && fromNullMap !== null) {{ + const size: number = fromNullMap.size; +}} +const fromNullVector = Nested.create([null]).getAt(0); +exact>(); +exact>(); +exact, number[]>>(); +exact, string[]>>(); +exact, number | undefined>>(); Strings.create(['value']); Numbers.create([17]); probe.takeStrings(['value']); @@ -418,6 +575,35 @@ probe.takeBytes(new Uint8Array([17])); probe.takeBytes([17]); "# ); + for (suffix, output_type) in [ + ("Vector", "IVector_UInt32"), + ("View", "IVectorView_UInt32"), + ("Iterable", "IIterable_UInt32"), + ("Map", "IMap_String_UInt32"), + ("MapView", "IMapView_String_UInt32"), + ] { + let property = format!("{}{}", suffix[..1].to_lowercase(), &suffix[1..]); + valid.push_str(&format!(r#" +exact, {output_type} | null>>(); +exact>(); +exact, ({output_type} | null)[]>>(); +exact>, {output_type} | null>>(); +exact>, {output_type} | null>>(); +type Nested{suffix} = NonNullable>; +exact, {output_type} | null>>(); +exact, ({output_type} | null)[]>>(); +exact, IterableIterator<{output_type} | null>>>(); +type MapOf{suffix} = NonNullable>; +exact, {output_type} | null>>(); +exact, {output_type} | null | undefined>>(); +type Pairs{suffix} = NonNullable>; +type Iterator{suffix} = ReturnType; +type Pair{suffix} = Iterator{suffix}['current']; +exact>(); +exact>(); +exact, IteratorResult>>(); +"#)); + } fs::write(directory.join("valid.ts"), valid).unwrap(); let invalid = [ ("Vector.create(17);", "2345"), @@ -475,6 +661,15 @@ probe.takeBytes([17]); ("Vector.create({ value: 17 });", "2561"), ("Strings.create('value');", "2345"), ("Numbers.create(17);", "2345"), + ("nested.getAt(0).size;", "2531"), + ("nestedValues.lookup('present').size;", "2531"), + ( + "const read = nestedValues.get('present'); if (read !== undefined) read.size;", + "18047", + ), + ("probe.collection.size;", "18047"), + ("nested.toArray()[0].size;", "2531"), + ("for (const item of nested) item.size;", "18047"), ]; fs::write( directory.join("invalid.ts"), @@ -489,8 +684,14 @@ probe.takeBytes([17]); ) .unwrap(); let compile = |file| { - Command::new("node") - .arg(&tsc) + let mut command = Command::new("node"); + command.arg(&tsc); + if let Some(runtime) = &native_runtime { + command + .arg("--typeRoots") + .arg(runtime.join("node_modules/@types")); + } + command .args([ "--noEmit", "--strict", @@ -538,5 +739,42 @@ probe.takeBytes([17]); Expected: {expected:?}\nActual: {actual:?}\nFixture: {}", directory.display() ); + if let Some(runtime) = native_runtime { + let script = manifest.join("../../bindings/js/__test__/fixtures/collection-contracts.mjs"); + let native = Command::new("node") + .arg(script) + .arg(&generated) + .arg(&runtime) + .output() + .expect("run production generated collection contracts"); + assert!( + native.status.success(), + "Native collection contracts: {native:?}" + ); + eprintln!("{}", String::from_utf8_lossy(&native.stdout)); + let standalone = directory.join("map-view-only"); + let output = Command::new(env!("CARGO_BIN_EXE_dynwinrt-codegen")) + .args(["generate", "--class-name", "Tests.IMapViewProbe", "--winmd"]) + .arg(format!("{};{}", outputs.display(), windows_winmd.display())) + .arg("--output") + .arg(&standalone) + .output() + .expect("generate map-view-only metadata closure"); + assert!( + output.status.success(), + "Standalone MapView generation: {output:?}" + ); + let native = Command::new("node") + .arg(manifest.join("../../bindings/js/__test__/fixtures/collection-contracts.mjs")) + .arg(standalone) + .arg(runtime) + .output() + .expect("execute map-view-only metadata closure"); + assert!( + native.status.success(), + "Standalone MapView contracts: {native:?}" + ); + eprintln!("{}", String::from_utf8_lossy(&native.stdout)); + } fs::remove_dir_all(directory).unwrap(); }