diff --git a/bindings/js/__test__/fixtures/interface-inputs.ts b/bindings/js/__test__/fixtures/interface-inputs.ts new file mode 100644 index 00000000..0c62aa6f --- /dev/null +++ b/bindings/js/__test__/fixtures/interface-inputs.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { FileIO } from './generated/windows/storage/FileIO.js' +import { StorageFile } from './generated/windows/storage/StorageFile.js' +import { StorageFolder } from './generated/windows/storage/StorageFolder.js' +import { IStorageFile } from './generated/windows/storage/IStorageFile.js' +import { IStorageFolder } from './generated/windows/storage/IStorageFolder.js' +import { IStorageItem } from './generated/windows/storage/IStorageItem.js' +import { NameCollisionOption } from './generated/windows/storage/NameCollisionOption.js' +import { Buffer as WinRTBuffer } from './generated/windows/storage/streams/Buffer.js' +import { IBuffer } from './generated/windows/storage/streams/IBuffer.js' +import { Uri } from './generated/windows/foundation/Uri.js' +import { IStringable } from './generated/windows/foundation/IStringable.js' +import { FileIO as PackageFileIO, StorageFile as PackageStorageFile, releaseProjected } from 'projected-storage' + +export async function roundtrip(path: string, text: string): Promise { + const file = await StorageFile.getFileFromPathAsync(path) + try { + await FileIO.writeTextAsync(file, text) + return await FileIO.readTextAsync(file) + } finally { + releaseProjected(file) + } +} + +export async function packageRoundtrip(path: string, text: string): Promise { + const file = await PackageStorageFile.getFileFromPathAsync(path) + try { + await PackageFileIO.writeTextAsync(file, text) + return await PackageFileIO.readTextAsync(file) + } finally { + releaseProjected(file) + } +} + +// Compile-only controls; none of the negative calls execute. +export function inputContracts(file: StorageFile, folder: StorageFolder, uri: Uri, buffer: WinRTBuffer) { + const fileInput: IStorageFile = file + const folderInput: IStorageFolder = folder + const fileItem: IStorageItem = file + const folderItem: IStorageItem = folder + const bufferInput: IBuffer = buffer + const stringable: IStringable = uri + const signal = new AbortController().signal + FileIO.writeTextAsync(file, 'natural input') + FileIO.readTextAsync(file) + FileIO.writeBufferAsync(file, bufferInput) + file.copyAsync(folder) + file.copyAsync(folder, signal) + file.copyAsync(folder, 'copy.txt') + file.copyAsync(folder, 'copy.txt', NameCollisionOption.FailIfExists) + file.moveAsync(folder) + file.moveAsync(folder, 'move.txt') + file.moveAsync(folder, 'move.txt', NameCollisionOption.FailIfExists) + file.copyAndReplaceAsync(file) + file.moveAndReplaceAsync(file) + fileInput.copyOverloadDefaultNameAndOptions(folder) + fileInput.copyOverloadDefaultOptions(folder, 'copy.txt') + fileInput.copyOverload(folder, 'copy.txt', NameCollisionOption.FailIfExists) + fileInput.moveOverloadDefaultNameAndOptions(folder) + fileInput.moveOverloadDefaultOptions(folder, 'move.txt') + fileInput.moveOverload(folder, 'move.txt', NameCollisionOption.FailIfExists) + file.copyOverloadDefaultOptions(folderInput, 'class-alias.txt') + file.copyOverloadDefaultOptions(folderInput, 'class-alias.txt', signal) + file.moveOverloadDefaultOptions(folderInput, 'class-alias.txt') + FileIO.readTextAsync(file.as(IStorageFile)) + FileIO.writeTextAsync(file.as(IStorageFile), 'explicit interface') + file.as(IStorageFile).copyOverloadDefaultOptions(folder.as(IStorageFolder), 'legacy.txt') + folder.createFileAsync('created.txt') + folder.createFileAsyncOverloadDefaultOptions('alias.txt') + folder.createFolderAsyncOverloadDefaultOptions('child') + folder.getFilesAsyncOverloadDefaultOptionsStartAndCount() + folder.getFoldersAsyncOverloadDefaultOptionsStartAndCount() + folder.getItemsAsyncOverloadDefaultStartAndCount() + fileItem.isOfType(0) + file.isEqual(folderItem) + stringable.toString() + // @ts-expect-error Uri does not implement IStorageFile. + FileIO.writeTextAsync(uri, 'wrong IID') + // @ts-expect-error Uri does not implement IStorageFile. + FileIO.readTextAsync(uri) + // @ts-expect-error Uri does not implement IStorageFolder. + file.copyAsync(uri) + // @ts-expect-error Uri does not implement IStorageFolder. + fileInput.moveOverloadDefaultNameAndOptions(uri) + // @ts-expect-error A folder is not a file. + FileIO.readTextAsync(folder) + // @ts-expect-error A file is not a folder. + file.copyAsync(file) +} diff --git a/bindings/js/__test__/interface-inputs.e2e.mjs b/bindings/js/__test__/interface-inputs.e2e.mjs new file mode 100644 index 00000000..6b1f51a0 --- /dev/null +++ b/bindings/js/__test__/interface-inputs.e2e.mjs @@ -0,0 +1,231 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { before, test } from 'node:test' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { runCodegen } from '../scripts/run-codegen.mjs' + +const require = createRequire(import.meta.url) +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const runtimeRoot = resolve(process.env.DYNWINRT_JS_PACKAGE ?? packageRoot) +const repositoryRoot = resolve(packageRoot, '..', '..') +const winmd = + process.env.DYNWINRT_WINDOWS_WINMD ?? + String.raw`C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd` +const tsc = process.env.DYNWINRT_TSC ?? join(packageRoot, 'node_modules', 'typescript', 'bin', 'tsc') +const classes = [ + 'Windows.Storage.StorageFile', + 'Windows.Storage.StorageFolder', + 'Windows.Storage.FileIO', + 'Windows.Storage.Streams.Buffer', + 'Windows.Foundation.Uri', +].join(',') +const interfaces = [ + 'Windows.Storage.IStorageFile', + 'Windows.Storage.IStorageFolder', + 'Windows.Storage.IStorageItem', + 'Windows.Storage.Streams.IBuffer', + 'Windows.Foundation.IStringable', +].join(',') + +before(() => { + assert.ok(statSync(winmd).isFile(), 'Windows SDK metadata is required') + assert.ok(statSync(tsc).isFile(), 'TypeScript is required') + require(runtimeRoot).roInitialize(1) +}) + +async function exercise(generated, directory) { + mkdirSync(directory) + const retained = new Set() + const keep = (value) => { + assert.notEqual(value, null) + retained.add(value) + return value + } + try { + const { FileIO, StorageFile, StorageFolder, IStorageFile, IStorageFolder, NameCollisionOption } = generated + const path = join(directory, 'source.txt') + const text = 'natural StorageFile input' + writeFileSync(path, '') + const file = keep(await StorageFile.getFileFromPathAsync(path)) + await FileIO.writeTextAsync(file, text) + assert.equal(await FileIO.readTextAsync(file), text) + const folder = keep(await StorageFolder.getFolderFromPathAsync(directory)) + const copyDirectory = join(directory, 'copies') + const defaultCopyDirectory = join(directory, 'default-copies') + mkdirSync(copyDirectory) + mkdirSync(defaultCopyDirectory) + const copies = keep(await StorageFolder.getFolderFromPathAsync(copyDirectory)) + const defaultCopies = keep(await StorageFolder.getFolderFromPathAsync(defaultCopyDirectory)) + const option = NameCollisionOption.FailIfExists + const signal = new AbortController().signal + for (const [method, args, destination] of [ + ['copyOverloadDefaultNameAndOptions', [copies, signal], join(copyDirectory, 'source.txt')], + ['copyOverloadDefaultOptions', [copies, 'alias-default.txt', signal], join(copyDirectory, 'alias-default.txt')], + ['copyOverload', [copies, 'alias-full.txt', option, signal], join(copyDirectory, 'alias-full.txt')], + ['copyAsync', [defaultCopies, signal], join(defaultCopyDirectory, 'source.txt')], + ['copyAsync', [copies, 'canonical-default.txt', signal], join(copyDirectory, 'canonical-default.txt')], + ['copyAsync', [copies, 'canonical-full.txt', option, signal], join(copyDirectory, 'canonical-full.txt')], + ]) { + const copied = keep(await file[method](...args)) + assert.equal(realpathSync.native(copied.path), realpathSync.native(destination)) + assert.equal(await FileIO.readTextAsync(copied), text) + } + const moveCases = [ + ['moveOverloadDefaultNameAndOptions', []], + ['moveOverloadDefaultOptions', ['alias-move-default.txt']], + ['moveOverload', ['alias-move-full.txt', option]], + ['moveAsync', []], + ['moveAsync', ['canonical-move-default.txt']], + ['moveAsync', ['canonical-move-full.txt', option]], + ] + for (const [index, [method, args]] of moveCases.entries()) { + const source = join(directory, `move-${index}.txt`) + writeFileSync(source, text) + const moving = keep(await StorageFile.getFileFromPathAsync(source)) + await moving[method](copies, ...args, signal) + assert.equal(existsSync(source), false) + assert.equal( + realpathSync.native(moving.path), + realpathSync.native(join(copyDirectory, args[0] ?? `move-${index}.txt`)), + ) + assert.equal(await FileIO.readTextAsync(moving), text) + } + + const fileView = keep(file.as(IStorageFile)) + const folderView = keep(copies.as(IStorageFolder)) + assert.equal(await FileIO.readTextAsync(fileView), text) + const legacy = keep(await fileView.copyOverloadDefaultOptions(folderView, 'legacy.txt')) + await keep(legacy.as(IStorageFile)).moveOverloadDefaultOptions(folderView, 'legacy-moved.txt') + assert.equal(await FileIO.readTextAsync(legacy), text) + await legacy.renameAsyncOverloadDefaultOptions('renamed-by-required-interface.txt') + const legacyPath = legacy.path + await legacy.deleteAsyncOverloadDefaultOptions() + assert.equal(existsSync(legacyPath), false) + + const created = keep(await folder.createFileAsyncOverloadDefaultOptions('created-by-alias.txt')) + await FileIO.writeTextAsync(created, text) + keep(await folder.createFolderAsyncOverloadDefaultOptions('child-by-alias')) + for (const [method, expected] of [ + ['getFilesAsyncOverloadDefaultOptionsStartAndCount', 'created-by-alias.txt'], + ['getFoldersAsyncOverloadDefaultOptionsStartAndCount', 'child-by-alias'], + ['getItemsAsyncOverloadDefaultStartAndCount', 'created-by-alias.txt'], + ]) { + const view = keep(await folder[method]()) + const items = view.toArray().map(keep) + assert.ok( + items.some((item) => item.name === expected), + `${method} must call its declared slot`, + ) + } + + const bytes = Uint8Array.of(1, 2, 3, 4) + const buffer = keep(generated.Buffer.fromBuffer(bytes)) + const bufferFile = keep(await folder.createFileAsync('buffer.txt')) + await FileIO.writeBufferAsync(bufferFile, buffer) + assert.deepEqual([...keep(await FileIO.readBufferAsync(bufferFile)).toBuffer()], [...bytes]) + const uri = keep(generated.Uri.createUri('https://example.invalid/interface-input')) + assert.equal(keep(uri.as(generated.IStringable)).toString(), uri.toString()) + assert.throws(() => uri.as(IStorageFile)) + assert.throws(() => uri.as(IStorageFolder)) + } finally { + for (const value of [...retained].reverse()) generated.releaseProjected(value) + } +} + +for (const [selection, generations] of [ + ['together', [`${classes},${interfaces}`]], + ['classes-only', [classes]], + ['interface-roots', [`${interfaces},Windows.Storage.FileIO,Windows.Storage.Streams.Buffer`]], + ['class-first', [classes, interfaces]], + ['interface-first', [interfaces, classes]], +]) { + test(`interface inputs: ${selection}, strict TS and native CJS/ESM`, async (t) => { + const directory = realpathSync.native(mkdtempSync(join(tmpdir(), 'dynwinrt-interface-inputs-'))) + try { + const output = join(directory, 'generated') + for (const roots of generations) { + const result = runCodegen(['generate', '--winmd', winmd, '--class-name', roots, '--output', output], { + cwd: repositoryRoot, + encoding: 'utf8', + windowsHide: true, + timeout: 120_000, + }) + assert.equal(result.status, 0, `${result.error ?? ''}\n${result.stdout}\n${result.stderr}`) + } + const scope = join(directory, 'node_modules', '@microsoft') + mkdirSync(scope, { recursive: true }) + symlinkSync(runtimeRoot, join(scope, 'dynwinrt'), 'junction') + symlinkSync(output, join(directory, 'node_modules', 'projected-storage'), 'junction') + for (const extension of ['cts', 'mts']) { + copyFileSync( + join(packageRoot, '__test__', 'fixtures', 'interface-inputs.ts'), + join(directory, `consumer.${extension}`), + ) + } + const compilation = spawnSync( + process.execPath, + [ + tsc, + '--strict', + '--skipLibCheck', + 'false', + '--target', + 'ES2022', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--types', + 'node', + '--typeRoots', + join(packageRoot, 'node_modules', '@types'), + join(directory, 'consumer.cts'), + join(directory, 'consumer.mts'), + ], + { encoding: 'utf8', windowsHide: true, timeout: 120_000 }, + ) + assert.equal(compilation.status, 0, `${compilation.error ?? ''}\n${compilation.stdout}\n${compilation.stderr}`) + const modules = [ + ['cjs', require(output), require(join(directory, 'consumer.cjs'))], + [ + 'esm', + await import(pathToFileURL(join(output, 'index.mjs')).href), + await import(pathToFileURL(join(directory, 'consumer.mjs')).href), + ], + ] + for (const [mode, generated, consumer] of modules) { + const data = join(directory, mode) + await exercise(generated, data) + const path = join(data, 'compiled-consumer.txt') + writeFileSync(path, '') + assert.equal(await consumer.roundtrip(path, `${selection} ${mode} direct`), `${selection} ${mode} direct`) + assert.equal( + await consumer.packageRoundtrip(path, `${selection} ${mode} package`), + `${selection} ${mode} package`, + ) + } + t.diagnostic( + JSON.stringify({ architecture: process.arch, selection, strictTypeScript: true, native: ['cjs', 'esm'] }), + ) + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }) +} diff --git a/bindings/js/package.json b/bindings/js/package.json index fd483554..f5119798 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -108,7 +108,8 @@ "format:toml": "taplo format", "format:rs": "cargo fmt", "lint": "oxlint", - "test": "npm run test:types && ava && npm run test:native-classes", + "test": "npm run test:types && ava && npm run test:native-classes && npm run test:interface-inputs", + "test:interface-inputs": "node --test __test__/interface-inputs.e2e.mjs", "test:native-classes": "node --test __test__/native-class-boundary.e2e.mjs", "test:native-classes:hooks": "node --test __test__/native-class-boundary-hooks.e2e.mjs", "test:imports": "ava __test__/native-imports.spec.ts", diff --git a/bindings/js/src/com_completion_test_hooks.rs b/bindings/js/src/com_completion_test_hooks.rs index a1285d2b..732bb670 100644 --- a/bindings/js/src/com_completion_test_hooks.rs +++ b/bindings/js/src/com_completion_test_hooks.rs @@ -652,15 +652,30 @@ unsafe extern "system" fn result_u32(this: *mut c_void, output: *mut u32) -> HRE } macro_rules! stub { - ($name:ident($($arg:ident: $typ:ty),*)) => { + ($name:ident($($arg:ident: $typ:ty),* $(,)?)) => { unsafe extern "system" fn $name(_this: *mut c_void, $($arg: $typ),*) -> HRESULT { E_NOTIMPL } }; } -stub!(audio_initialize(_mode: AUDCLNT_SHAREMODE, _flags: u32, _duration: i64, _period: i64, _format: *const WAVEFORMATEX, _session: *const GUID)); +stub!( + audio_initialize( + _mode: AUDCLNT_SHAREMODE, + _flags: u32, + _duration: i64, + _period: i64, + _format: *const WAVEFORMATEX, + _session: *const GUID, + ) +); stub!(audio_i64(_value: *mut i64)); -stub!(audio_format_supported(_mode: AUDCLNT_SHAREMODE, _format: *const WAVEFORMATEX, _closest: *mut *mut WAVEFORMATEX)); +stub!( + audio_format_supported( + _mode: AUDCLNT_SHAREMODE, + _format: *const WAVEFORMATEX, + _closest: *mut *mut WAVEFORMATEX, + ) +); stub!(audio_mix_format(_format: *mut *mut WAVEFORMATEX)); stub!(audio_period(_default: *mut i64, _minimum: *mut i64)); stub!(audio_noargs()); diff --git a/tools/dynwinrt-codegen/README.md b/tools/dynwinrt-codegen/README.md index e436347b..5b881789 100644 --- a/tools/dynwinrt-codegen/README.md +++ b/tools/dynwinrt-codegen/README.md @@ -144,6 +144,16 @@ ESM consumers, and need no TypeScript compilation step. Python output uses snake_case names and includes type information by default. Documentation from sibling XML files is included when available. +WinRT JavaScript runtime classes retain the explicit instance-method aliases +declared by their interfaces, alongside their existing class overload names. +For example, `StorageFile.copyAsync(...)` remains unchanged, and the class also +exposes `IStorageFile`'s `copyOverloadDefaultOptions(...)` alias with the same +native signature. These are real JavaScript methods, not declaration-only +members, so a `StorageFile` can be passed directly to `FileIO.readTextAsync(...)` +and a `StorageFolder` to `StorageFile.copyAsync(...)` in strict TypeScript. +Standalone interface aliases and explicit `.as(IStorageFile)` views remain +supported. Existing public class members take precedence when names collide. + Classic COM generation is available only with `--lang js`. It is isolated in a `com` subpackage and fails closed when metadata does not provide enough ABI, layout, ownership, or cleanup information. See the 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 28290229..1263c17f 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/project/mod.rs @@ -1020,7 +1020,7 @@ pub fn project_class( } // Merge overload names in default interface members - merge_overload_names(&mut members); + let mut interface_aliases = merge_overload_names(&mut members); // IClosable → close() (import already registered pre-emptively above) if let Some(interface_name) = iclosable_name { @@ -1204,7 +1204,7 @@ pub fn project_class( } // Merge overload names within the required interface members before flatten - merge_overload_names(&mut ri_members); + interface_aliases.extend(merge_overload_names(&mut ri_members)); // Flatten: copy members onto the main class. // Always flatten, even when the interface is shared/imported — the @@ -1253,7 +1253,26 @@ pub fn project_class( // Merge overloaded method names: rename `foo2`, `foo3` to `foo` when `foo` exists. // Must happen after flatten so required-interface methods are included. - merge_overload_names(&mut members); + interface_aliases.extend(merge_overload_names(&mut members)); + + // Append only after every merge, so numeric aliases are not renamed again. + // Existing public members and their dispatchers must remain unchanged. + let merged_method_names = members + .iter() + .filter_map(|member| match member { + ProjectedMember::Method(method) => Some(method.name.clone()), + _ => None, + }) + .collect::>(); + let mut alias_signatures = HashSet::new(); + for alias in interface_aliases { + if !main_member_names.contains(&alias.name) + && !merged_method_names.contains(&alias.name) + && alias_signatures.insert(method_signature(&alias)) + { + members.push(ProjectedMember::Method(alias)); + } + } // Check if _unwrap is used let needs_unwrap = check_needs_unwrap(&members, &required_ifaces); @@ -2172,7 +2191,18 @@ fn rewrite_delegate_args_in_expr(expr: &str, params: &[ProjectedParam]) -> Strin /// and a base-name sibling exists, to use the base name. /// E.g. `generateResponseAsync2` becomes `generateResponseAsync` when /// `generateResponseAsync` already exists in the same member list. -fn merge_overload_names(members: &mut [ProjectedMember]) { +/// Return the original instance methods as interface-compatible aliases. +fn merge_overload_names(members: &mut [ProjectedMember]) -> Vec { + let interface_names = members + .iter() + .map(|member| match member { + ProjectedMember::Method(method) if !method.is_static && !method.js_only => { + Some(method.name.clone()) + } + _ => None, + }) + .collect::>(); + // First pass: apply explicit overload_of for member in members.iter_mut() { if let ProjectedMember::Method(method) = member { @@ -2208,4 +2238,22 @@ fn merge_overload_names(members: &mut [ProjectedMember]) { } } } + + members + .iter() + .zip(interface_names) + .filter_map(|(member, interface_name)| { + let ProjectedMember::Method(method) = member else { + return None; + }; + let interface_name = interface_name?; + if method.name == interface_name { + return None; + } + let mut alias = method.clone(); + alias.name = interface_name; + alias.overload_of = None; + Some(alias) + }) + .collect() } diff --git a/tools/dynwinrt-codegen/tests/delegate_overload_test.rs b/tools/dynwinrt-codegen/tests/delegate_overload_test.rs index b1e60623..95aa3d98 100644 --- a/tools/dynwinrt-codegen/tests/delegate_overload_test.rs +++ b/tools/dynwinrt-codegen/tests/delegate_overload_test.rs @@ -84,10 +84,11 @@ fn delegate_overloads_emit_local_delegate_values() { assert_eq!( js.matches("const _callback_d = DynWinRtDelegate.create(") .count(), - 2, - "Each tryEnqueue overload must declare its callback delegate:\n{js}" + 3, + "Each tryEnqueue overload and its interface alias must declare its callback delegate:\n{js}" ); assert!(js.contains("_tryEnqueue_1(callback)")); assert!(js.contains("_tryEnqueue_2(priority, callback)")); + assert!(js.contains("tryEnqueueWithPriority(priority, callback)")); assert!(js.contains("(callback == null ? DynWinRtValue.nullValue() : _callback_d)")); } diff --git a/tools/dynwinrt-codegen/tests/javascript_interface_input_test.rs b/tools/dynwinrt-codegen/tests/javascript_interface_input_test.rs new file mode 100644 index 00000000..51bdb549 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/javascript_interface_input_test.rs @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::process::Command; + +use dynwinrt_codegen::codegen::projected::ProjectedMember; +use dynwinrt_codegen::codegen::{project, render_dts, render_js}; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::TypeMeta; + +fn method(name: &str, raw_name: &str, slot: usize, inputs: &[&str]) -> MethodMeta { + MethodMeta { + name: name.into(), + raw_name: raw_name.into(), + vtable_index: slot, + params: inputs + .iter() + .map(|name| ParamMeta { + name: (*name).into(), + typ: TypeMeta::I32, + direction: ParamDirection::In, + }) + .collect(), + return_type: Some(TypeMeta::I32), + ..Default::default() + } +} + +fn reader() -> ClassMeta { + ClassMeta { + name: "Reader".into(), + namespace: "Tests".into(), + full_name: "Tests.Reader".into(), + default_interface: Some(InterfaceMeta { + name: "IReader".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + methods: vec![ + method("ReadShort", "Read", 6, &["value"]), + method("Read", "Read", 7, &["value", "mode"]), + method("Fetch", "Fetch", 8, &[]), + ], + ..Default::default() + }), + required_interfaces: vec![InterfaceMeta { + name: "IExtra".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + methods: vec![ + method("WriteShort", "Write", 6, &["value"]), + method("Fetch2", "Fetch2", 7, &["value"]), + ], + ..Default::default() + }], + static_interfaces: vec![InterfaceMeta { + name: "IReaderStatics".into(), + iid: "33333333-3333-3333-3333-333333333333".into(), + methods: vec![method("MakeShort", "Make", 6, &["value"])], + ..Default::default() + }], + ..Default::default() + } +} + +fn project_reader( + class: &ClassMeta, + imported: bool, +) -> dynwinrt_codegen::codegen::projected::ProjectedFile { + let shared = if imported { + HashSet::from([class.required_interfaces[0].iid.clone()]) + } else { + HashSet::new() + }; + project::project_class( + &Default::default(), + class, + &HashSet::from(["Reader".into(), "IReader".into(), "IExtra".into()]), + &HashSet::new(), + &shared, + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ) +} + +#[test] +fn class_keeps_interface_names_without_changing_existing_overloads() { + for imported in [false, true] { + let class = reader(); + let projected = project_reader(&class, imported); + let members = &projected.classes[0].members; + for (alias, canonical, slot, interface, object) in [ + ("readShort", "read", 6, "_IReader", "this._obj"), + ( + "writeShort", + "write", + 6, + "_IExtra", + "this._obj.cast(IID_IExtra)", + ), + ( + "fetch2", + "fetch", + 7, + "_IExtra", + "this._obj.cast(IID_IExtra)", + ), + ] { + let find = |name: &str| { + members + .iter() + .find_map(|member| match member { + ProjectedMember::Method(method) + if method.name == name + && method + .invoke_expr + .starts_with(&format!("{interface}.method({slot})")) => + { + Some(method) + } + _ => None, + }) + .expect("projected interface method") + }; + let alias_method = find(alias); + let canonical_method = find(canonical); + assert_eq!(alias_method.invoke_expr, canonical_method.invoke_expr); + assert_eq!( + alias_method.sync_return_expr, + canonical_method.sync_return_expr + ); + assert_eq!(alias_method.return_type, canonical_method.return_type); + assert_eq!(alias_method.overload_of, None); + assert!(alias_method.invoke_expr.contains(object)); + } + + let declarations = render_dts::render(&projected); + let class_declarations = declarations + .split_once("export declare class Reader {\n") + .unwrap() + .1 + .split_once("\n}") + .unwrap() + .0; + let signatures = class_declarations + .lines() + .filter(|line| { + ["read", "write", "fetch"] + .iter() + .any(|prefix| line.trim_start().starts_with(prefix)) + }) + .collect::>() + .join("\n"); + assert_eq!( + signatures, + [ + " read(value: number): number;", + " read(value: number, mode: number): number;", + " fetch(): number;", + " write(value: number): number;", + " fetch(value: number): number;", + " readShort(value: number): number;", + " writeShort(value: number): number;", + " fetch2(value: number): number;", + ] + .join("\n"), + "instance declaration snapshot" + ); + assert!(!declarations.contains("makeShort(")); + assert!(declarations.contains("static make(value: number): number;")); + } +} + +#[test] +fn compatibility_alias_does_not_replace_an_existing_public_member() { + let mut class = reader(); + class.required_interfaces[0].methods.push(method( + "ReadShort", + "ReadShort", + 8, + &["value", "mode"], + )); + let projected = project_reader(&class, false); + let methods = projected.classes[0] + .members + .iter() + .filter_map(|member| match member { + ProjectedMember::Method(method) if method.name == "readShort" => Some(method), + _ => None, + }) + .collect::>(); + assert_eq!(methods.len(), 1); + assert!(methods[0].invoke_expr.starts_with("_IExtra.method(8)")); + + let mut class = reader(); + class + .default_interface + .as_mut() + .unwrap() + .methods + .push(MethodMeta { + is_property_getter: true, + ..method("get_ReadShort", "get_ReadShort", 9, &[]) + }); + let projected = project_reader(&class, false); + assert!(!projected.classes[0].members.iter().any(|member| { + matches!(member, ProjectedMember::Method(method) if method.name == "readShort") + })); + assert!(projected.classes[0].members.iter().any(|member| { + matches!(member, ProjectedMember::Property(property) if property.name == "readShort") + })); +} + +#[test] +fn interface_aliases_execute_the_original_slots_and_qi_views() { + let class = reader(); + let output = std::env::temp_dir().join(format!( + "dynwinrt-javascript-interface-input-{}", + std::process::id() + )); + fs::create_dir_all(&output).unwrap(); + fs::write( + output.join("Reader.js"), + render_js::render(&project_reader(&class, false)), + ) + .unwrap(); + for interface in class + .default_interface + .iter() + .chain(&class.required_interfaces) + { + let projected = project::project_interface( + &Default::default(), + interface, + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ); + fs::write( + output.join(format!("{}.js", interface.name)), + render_js::render(&projected), + ) + .unwrap(); + } + fs::write( + output.join("lifetime.js"), + "exports.trackProjectedValue = value => value;\n", + ) + .unwrap(); + let runtime = output + .join("node_modules") + .join("@microsoft") + .join("dynwinrt"); + fs::create_dir_all(&runtime).unwrap(); + fs::write( + runtime.join("index.js"), + r#" +const assert = require('node:assert/strict'); +const calls = []; +class DynWinRtMethodSig { addIn() { return this; } addOut() { return this; } } +class DynWinRtType { + static i32() { return new this(); } + static registerInterface(name, iid) { + return { + addMethod() { return this; }, + method(slot) { + return { invoke(obj, args) { + assert.equal(obj.view, iid, 'the call must use the declaring interface'); + calls.push({ iid, slot, args: args.map(arg => arg.value) }); + return { toNumber: () => slot }; + } }; + }, + }; + } +} +class DynWinRtValue { static i32(value) { return { value }; } } +class WinGuid { static parse(iid) { return iid; } } +module.exports = { DynWinRtType, DynWinRtMethodSig, DynWinRtValue, WinGuid, calls }; +"#, + ) + .unwrap(); + let result = Command::new("node") + .args([ + "-e", + r#" +const assert = require('node:assert/strict'); +const { Reader } = require('./Reader.js'); +const { IReader } = require('./IReader.js'); +const { IExtra } = require('./IExtra.js'); +const { calls } = require('@microsoft/dynwinrt'); +const readerIid = '11111111-1111-1111-1111-111111111111'; +const extraIid = '22222222-2222-2222-2222-222222222222'; +const obj = { view: readerIid, cast(iid) { return { view: iid }; } }; +const reader = Object.assign(Object.create(Reader.prototype), { _obj: obj }); +assert.equal(reader.readShort(10), 6); +assert.equal(reader.read(10), 6); +assert.equal(reader.read(10, 20), 7); +assert.equal(reader.writeShort(30), 6); +assert.equal(reader.write(30), 6); +assert.equal(reader.fetch2(40), 7); +assert.equal(reader.fetch(40), 7); +assert.equal(reader.fetch(), 8); +assert.equal(IReader.from(obj).readShort(50), 6); +assert.equal(IExtra.from(obj).writeShort(60), 6); +assert.equal(IExtra.from(obj).fetch2(70), 7); +assert.deepEqual(calls, [ + { iid: readerIid, slot: 6, args: [10] }, + { iid: readerIid, slot: 6, args: [10] }, + { iid: readerIid, slot: 7, args: [10, 20] }, + { iid: extraIid, slot: 6, args: [30] }, + { iid: extraIid, slot: 6, args: [30] }, + { iid: extraIid, slot: 7, args: [40] }, + { iid: extraIid, slot: 7, args: [40] }, + { iid: readerIid, slot: 8, args: [] }, + { iid: readerIid, slot: 6, args: [50] }, + { iid: extraIid, slot: 6, args: [60] }, + { iid: extraIid, slot: 7, args: [70] }, +]); +"#, + ]) + .current_dir(&output) + .output() + .expect("execute projected aliases with node"); + fs::remove_dir_all(&output).unwrap(); + assert!( + result.status.success(), + "alias execution failed:\n{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); +}