diff --git a/.github/agents/e2e-test.md b/.github/agents/e2e-test.md new file mode 100644 index 0000000..78f0a03 --- /dev/null +++ b/.github/agents/e2e-test.md @@ -0,0 +1,44 @@ +--- +name: e2e-test +description: Run end-to-end tests for dynwinrt code generation and WinRT API invocation +tools: + - powershell + - view + - edit + - create +--- + +# E2E Test Agent + +You run and manage the dynwinrt end-to-end test suite. + +## Commands + +```powershell +# Run all E2E tests +.\tests\e2e_test.ps1 -SkipBuild + +# Python only +.\tests\e2e_test.ps1 -SkipBuild -Lang py + +# Full build + test +.\tests\e2e_test.ps1 +``` + +## Adding test cases + +Add entries to `tests/e2e_specs.json`. See `tests/e2e_specs.schema.json` for the field definitions. + +Safe WinRT APIs to test (no extra dependencies): +- `Windows.Foundation`: Uri, PropertyValue, WwwFormUrlDecoder, MemoryBuffer +- `Windows.Globalization`: Calendar, Language, GeographicRegion +- `Windows.Devices.Geolocation`: Geopoint +- `Windows.Storage.Streams`: Buffer + +Avoid APIs that need WinAppSDK, network, or user interaction. + +## Diagnosing failures + +1. Check `tests/e2e_generated/results_py.json` or `results_ts.json` for structured failure details +2. Inspect generated code in `tests/e2e_generated/py/` or `ts/` +3. Common issues: circular imports in codegen, naming mismatch (Python snake_case vs TS camelCase) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..58839a9 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,114 @@ +# Copilot Agent Instructions + +This file provides instructions for the GitHub Copilot coding agent when working on this repository. + +## Project Overview + +`dynwinrt` is a Rust library for dynamically invoking Windows Runtime (WinRT) APIs. It includes: +- **Core library** (`crates/dynwinrt/`) — Rust runtime using libffi for dynamic WinRT method invocation +- **JS binding** (`bindings/js/`) — napi-rs binding for Node.js/Electron +- **Python binding** (`bindings/py/`) — PyO3 binding for Python +- **Code generator** (`tools/winrt-meta/`) — Generates typed TypeScript and Python wrappers from .winmd metadata + +## Build & Test Commands + +```bash +# Build everything +cargo build + +# Core library tests (52 tests, 1 ignored — requires WinAppSDK) +cargo test -p dynwinrt + +# winrt-meta tests (49 unit tests + 1 snapshot test) +cargo test -p winrt-meta + +# Python binding (requires Python 3.8+ and maturin) +cd bindings/py +python -m venv .venv && .venv/Scripts/Activate.ps1 +pip install pytest maturin +maturin build +pip install target/wheels/*.whl --force-reinstall +python -m pytest tests/ -v + +# JS binding (requires Node.js 18+) +cd bindings/js +npm install +npx napi build --no-const-enum --platform --release -o dist + +# Code generation +cargo run -p winrt-meta -- generate --namespace Windows.Foundation --class-name Uri --lang ts --output ./generated +cargo run -p winrt-meta -- generate --namespace Windows.Foundation --class-name Uri --lang py --output ./generated + +# E2E test (full pipeline: winmd → generate → call real WinRT APIs) +.\tests\e2e_test.ps1 -SkipBuild -Lang py +``` + +## E2E Testing + +The E2E test framework validates the full pipeline: reading .winmd metadata → generating code → calling real Windows APIs. + +### How it works + +1. **Test specs** are defined in `tests/e2e_specs.json` (schema: `tests/e2e_specs.schema.json`) — each entry describes: + - `instantiate`: how to create an instance (`activate`, `static_factory`, or `none`) + - `checks`: array of assertions (`property_equals`, `property_exists`, `method_equals`, `method_result_contains`, `static_equals`, `static_not_null`) + +2. **Runners** (`tests/runners/py_runner.py`, `tests/runners/ts_runner.ts`) read the specs and execute them, outputting `results.json`. + +3. **Orchestrator** (`tests/e2e_test.ps1`) handles build, code generation, and runner invocation. + +4. **Adding new test cases**: Add entries to `e2e_specs.json`: +```json +{ + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { "kind": "static_factory", "method": "create_uri", "args": ["https://example.com"] }, + "checks": [ + { "kind": "property_equals", "member": "host", "expected": "example.com" }, + { "kind": "method_result_contains", "member": "combine_uri", "args": ["sub/page"], "contains": "sub/page" }, + { "kind": "static_equals", "member": "escape_component", "args": ["hello world"], "expected": "hello%20world" } + ] +} +``` + +### Testable WinRT APIs (no extra dependencies needed) + +These APIs are available on any Windows 10/11 machine without WinAppSDK: +- `Windows.Foundation`: Uri, PropertyValue, WwwFormUrlDecoder, MemoryBuffer, Deferral +- `Windows.Data.Xml.Dom`: XmlDocument (has circular import issue in Python codegen) +- `Windows.Globalization`: Calendar, Language, GeographicRegion, CurrencyIdentifiers +- `Windows.Devices.Geolocation`: Geopoint (factory with struct parameter) +- `Windows.Storage.Streams`: Buffer +- `Windows.Security.Cryptography`: CryptographicBuffer + +## Architecture Notes + +### Type System Flow +``` +.winmd metadata → MetadataTable (arena) → TypeHandle → MethodHandle → libffi call → WinRTValue +``` + +### Key Design Decisions +- **MetadataTable** is a global singleton (`LazyLock>`) shared across all bindings +- **Interface registration** is by IID (GUID) — each IID maps to one vtable with methods +- **Method invocation** returns a single `WinRTValue` (not a list) in Python binding +- **Generated code** uses relative imports (`from .module import Class`) — must be in a Python package + +### Code Generator (winrt-meta) +- `src/codegen/typescript.rs` + `src/codegen/method.rs` — TypeScript generation +- `src/codegen/python.rs` + `src/codegen/py_method.rs` — Python generation +- `src/codegen/common.rs` — Shared helpers (type mapping, argument wrapping, return conversion) +- `--lang ts` generates `.ts` files with `DynWinRtType`/`DynWinRtValue` API (camelCase) +- `--lang py` generates `.py` files with `DynWinRTType`/`DynWinRTValue` API (snake_case) + +### Python Binding API Names +- Types: `DynWinRTType.i32_type()`, `DynWinRTType.hstring()`, `DynWinRTType.bool_type()`, etc. +- Values: `DynWinRTValue.from_i32()`, `DynWinRTValue.from_hstring()`, `DynWinRTValue.from_bool()` +- GUID: `WinGUID.parse('...')` +- Method call: `method_handle.invoke(obj, [args])` → returns single `DynWinRTValue` + +### Common Issues +- `test_initialize` is `#[ignore]` — requires `WINAPPSDK_BOOTSTRAP_DLL_PATH` env var +- Python `@property` must come before `@prop.setter` — codegen reorders methods for this +- Windows SDK winmd is at `C:\Program Files (x86)\Windows Kits\10\UnionMetadata\10.0.26100.0\Windows.winmd` diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6a8065..7ef975c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,38 @@ jobs: - name: Test winrt-meta run: cargo test -p winrt-meta + # E2E tests: winmd → generate → call real WinRT APIs + e2e: + needs: test + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Build winrt-meta + run: cargo build -p winrt-meta --release + - name: Build JS binding + working-directory: bindings/js + run: | + npm install + npx napi build --no-const-enum --platform --release -o dist + npm install --no-save tsx + - name: Build Python binding + run: | + cd bindings/py + python -m venv .venv + .venv/Scripts/Activate.ps1 + pip install pytest maturin --quiet + maturin build --quiet + pip install (Get-ChildItem ../../target/wheels/*.whl | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName --force-reinstall --quiet + - name: Run E2E tests + run: .\tests\e2e_test.ps1 -SkipBuild + # winrt-meta (x64 + arm64) winrt-meta: needs: test diff --git a/.pipelines/ci.yml b/.pipelines/ci.yml index 2a96c22..763d23c 100644 --- a/.pipelines/ci.yml +++ b/.pipelines/ci.yml @@ -155,3 +155,58 @@ extends: targetType: inline workingDirectory: bindings/js script: npx napi build --no-const-enum --platform --release --target aarch64-pc-windows-msvc -o dist + + # Install tsx for E2E TS runner + - task: PowerShell@2 + displayName: Install tsx + inputs: + targetType: inline + workingDirectory: bindings/js + script: npm install --no-save tsx + + # Build Python binding + - task: UsePythonVersion@0 + displayName: Setup Python 3.12 + inputs: + versionSpec: '3.12' + + - task: PipAuthenticate@1 + displayName: Authenticate Python feed + inputs: + artifactFeeds: 'pde-oss/dynwinrt-python' + + - task: PowerShell@2 + displayName: Build Python binding + inputs: + targetType: inline + script: | + cd bindings/py + python -m venv .venv + .venv/Scripts/Activate.ps1 + + # Configure pip to use Azure Artifacts feed + $pipIni = ".venv/pip.ini" + @" + [global] + index-url=https://pkgs.dev.azure.com/microsoft/pde-oss/_packaging/dynwinrt-python/pypi/simple/ + "@ | Set-Content $pipIni + + pip install pytest maturin --quiet + if ($LASTEXITCODE -ne 0) { Write-Error "pip install failed"; exit 1 } + maturin build --quiet + if ($LASTEXITCODE -ne 0) { Write-Error "maturin build failed"; exit 1 } + $whl = (Get-ChildItem ../../target/wheels/*.whl | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName + if (-not $whl) { Write-Error "No wheel found"; exit 1 } + pip install $whl --force-reinstall --quiet + if ($LASTEXITCODE -ne 0) { Write-Error "pip install failed"; exit 1 } + + # E2E tests: winmd → generate → call real WinRT APIs + - task: PowerShell@2 + displayName: Run E2E tests (40 tests) + inputs: + targetType: inline + script: | + cd bindings/py + if (Test-Path .venv) { .venv/Scripts/Activate.ps1 } + cd ../.. + .\tests\e2e_test.ps1 -SkipBuild diff --git a/bindings/js/package-lock.json b/bindings/js/package-lock.json index 180390d..eebc1d7 100644 --- a/bindings/js/package-lock.json +++ b/bindings/js/package-lock.json @@ -1,12 +1,12 @@ { "name": "dynwinrt-js", - "version": "1.1.3", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dynwinrt-js", - "version": "1.1.3", + "version": "0.1.0", "license": "MIT", "devDependencies": { "@emnapi/core": "^1.5.0", @@ -25,6 +25,7 @@ "oxlint": "^1.14.0", "prettier": "^3.6.2", "tinybench": "^6.0.0", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "engines": { @@ -62,6 +63,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.3.tgz", @@ -2927,6 +3370,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3105,6 +3590,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3128,6 +3628,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -4211,6 +4724,16 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -4653,6 +5176,26 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typanion": { "version": "3.14.0", "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", diff --git a/bindings/js/package.json b/bindings/js/package.json index 2c83a50..c88b731 100644 --- a/bindings/js/package.json +++ b/bindings/js/package.json @@ -67,6 +67,7 @@ "oxlint": "^1.14.0", "prettier": "^3.6.2", "tinybench": "^6.0.0", + "tsx": "^4.21.0", "typescript": "^5.9.2" }, "lint-staged": { @@ -102,4 +103,4 @@ "arrowParens": "always" }, "packageManager": "pnpm@10.28.0" -} \ No newline at end of file +} diff --git a/tests/e2e_specs.json b/tests/e2e_specs.json new file mode 100644 index 0000000..143cbfe --- /dev/null +++ b/tests/e2e_specs.json @@ -0,0 +1,297 @@ +{ + "$schema": "./e2e_specs.schema.json", + "specs": [ + { + "id": "uri_properties", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create_uri", + "args": ["https://example.com/path?q=1#frag"] + }, + "checks": [ + { "kind": "property_equals", "member": "host", "expected": "example.com" }, + { "kind": "property_equals", "member": "scheme_name", "expected": "https" }, + { "kind": "property_equals", "member": "path", "expected": "/path" }, + { "kind": "property_equals", "member": "query", "expected": "?q=1" }, + { "kind": "property_equals", "member": "fragment", "expected": "#frag" }, + { "kind": "property_equals", "member": "port", "expected": 443 }, + { "kind": "method_result_contains", "member": "combine_uri", "args": ["sub/page"], "contains": "sub/page" } + ] + }, + { + "id": "uri_statics", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "static_equals", "member": "escape_component", "args": ["hello world"], "expected": "hello%20world" }, + { "kind": "static_equals", "member": "unescape_component", "args": ["hello%20world"], "expected": "hello world" } + ] + }, + { + "id": "uri_equals", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create_uri", + "args": ["https://example.com"] + }, + "checks": [ + { + "kind": "method_equals", + "member": "equals", + "args_factory": { "class": "Uri", "method": "create_uri", "args": ["https://example.com"] }, + "expected": true + } + ] + }, + { + "id": "uri_interface_cast", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create_uri", + "args": ["https://example.com"] + }, + "checks": [ + { "kind": "interface_cast", "member": "as_interface", "interface_module": "uri", "interface_class": "IStringable", "method": "to_string", "contains": "example.com" } + ] + }, + { + "id": "calendar_default_ctor", + "namespace": "Windows.Globalization", + "class": "Calendar", + "langs": ["py", "ts"], + "instantiate": { "kind": "activate" }, + "checks": [ + { "kind": "property_exists", "member": "year" }, + { "kind": "property_exists", "member": "month" }, + { "kind": "property_exists", "member": "day" }, + { "kind": "property_exists", "member": "hour" }, + { "kind": "property_exists", "member": "minute" }, + { "kind": "property_exists", "member": "second" } + ] + }, + { + "id": "calendar_enum", + "namespace": "Windows.Globalization", + "class": "Calendar", + "langs": ["py", "ts"], + "instantiate": { "kind": "activate" }, + "checks": [ + { "kind": "property_in_range", "member": "day_of_week", "min": 0, "max": 6 } + ] + }, + { + "id": "language_string_property", + "namespace": "Windows.Globalization", + "class": "Language", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create_language", + "args": ["en-US"] + }, + "checks": [ + { "kind": "property_equals", "member": "language_tag", "expected": "en-US" } + ] + }, + { + "id": "property_value_statics", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "static_not_null", "member": "create_int32", "args": [42] }, + { "kind": "static_not_null", "member": "create_string", "args": ["hello"] }, + { "kind": "static_not_null", "member": "create_boolean", "args": [true] }, + { "kind": "static_not_null", "member": "create_double", "args": [3.14] }, + { "kind": "static_not_null", "member": "create_int64", "args": [123456] }, + { "kind": "static_not_null", "member": "create_single", "args": [1.5] }, + { "kind": "static_not_null", "member": "create_u_int8", "args": [200] }, + { "kind": "static_not_null", "member": "create_u_int32", "args": [100000] } + ] + }, + { + "id": "www_form_url_decoder", + "namespace": "Windows.Foundation", + "class": "WwwFormUrlDecoder", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create_www_form_url_decoder", + "args": ["?a=1&b=2"] + }, + "checks": [ + { "kind": "method_equals", "member": "get_first_value_by_name", "args": ["a"], "expected": "1" }, + { "kind": "method_equals", "member": "get_first_value_by_name", "args": ["b"], "expected": "2" } + ] + }, + { + "id": "buffer_factory_and_properties", + "namespace": "Windows.Storage.Streams", + "class": "Buffer", + "langs": ["py", "ts"], + "instantiate": { + "kind": "static_factory", + "method": "create", + "args": [1024] + }, + "checks": [ + { "kind": "property_equals", "member": "capacity", "expected": 1024 }, + { "kind": "property_equals", "member": "length", "expected": 0 } + ] + }, + { + "id": "struct_point_roundtrip", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "struct_roundtrip", + "member": "create_point", + "struct_module": "property_value", + "struct_class": "Point", + "struct_args": { "x": 1.5, "y": 2.5 }, + "pack_fn": "pack_point", + "unpack_fn": "unpack_point", + "expected_fields": { "x": 1.5, "y": 2.5 } + } + ] + }, + { + "id": "struct_rect_roundtrip", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "struct_roundtrip", + "member": "create_rect", + "struct_module": "property_value", + "struct_class": "Rect", + "struct_args": { "x": 10.0, "y": 20.0, "width": 100.0, "height": 200.0 }, + "pack_fn": "pack_rect", + "unpack_fn": "unpack_rect", + "expected_fields": { "x": 10.0, "y": 20.0, "width": 100.0, "height": 200.0 } + } + ] + }, + { + "id": "array_i32_roundtrip", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "array_roundtrip", + "member": "create_int32_array", + "element_type": "i32", + "values": [1, 2, 3, 42, -7] + } + ] + }, + { + "id": "array_string_roundtrip", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "array_roundtrip", + "member": "create_string_array", + "element_type": "string", + "values": ["hello", "world", "dynwinrt"] + } + ] + }, + { + "id": "array_f64_roundtrip", + "namespace": "Windows.Foundation", + "class": "PropertyValue", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "array_roundtrip", + "member": "create_double_array", + "element_type": "f64", + "values": [1.1, 2.2, 3.3] + } + ] + }, + { + "id": "guid_helper_create", + "namespace": "Windows.Foundation", + "class": "GuidHelper", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "static_not_null", "member": "create_new_guid" } + ] + }, + { + "id": "crypto_buffer_generate", + "namespace": "Windows.Security.Cryptography", + "class": "CryptographicBuffer", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "static_not_null", "member": "generate_random", "args": [32] }, + { "kind": "static_not_null", "member": "generate_random_number" } + ] + }, + { + "id": "crypto_buffer_hex_roundtrip", + "namespace": "Windows.Security.Cryptography", + "class": "CryptographicBuffer", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { + "kind": "cross_class_chain", + "member": "chain", + "steps": [ + { "action": "static_call", "class": "CryptographicBuffer", "method": "decode_from_hex_string", "args": ["48656c6c6f"], "save_as": "buf" }, + { "action": "static_call", "class": "CryptographicBuffer", "method": "encode_to_hex_string", "args_refs": ["buf"], "expected": "48656c6c6f" } + ] + } + ] + }, + { + "id": "uri_error_path", + "namespace": "Windows.Foundation", + "class": "Uri", + "langs": ["py", "ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "static_expect_error", "member": "create_uri", "args": [""] } + ] + }, + { + "id": "async_memory_stream_roundtrip", + "namespace": "Windows.Storage.Streams", + "class": "InMemoryRandomAccessStream", + "langs": ["py", "ts"], + "extra_classes": ["DataWriter", "DataReader"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "async_memory_roundtrip", "member": "roundtrip", "write_value": 42 } + ] + } + ] +} diff --git a/tests/e2e_specs.schema.json b/tests/e2e_specs.schema.json new file mode 100644 index 0000000..6f5152f --- /dev/null +++ b/tests/e2e_specs.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "dynwinrt E2E Test Specs", + "type": "object", + "required": ["specs"], + "properties": { + "$schema": { "type": "string" }, + "specs": { + "type": "array", + "items": { "$ref": "#/definitions/spec" } + } + }, + "definitions": { + "spec": { + "type": "object", + "required": ["namespace", "class", "instantiate", "checks"], + "properties": { + "namespace": { "type": "string", "description": "WinRT namespace" }, + "class": { "type": "string", "description": "WinRT class name" }, + "id": { "type": "string", "description": "Unique ID (auto-derived from class if omitted)" }, + "langs": { + "type": "array", + "items": { "enum": ["py", "ts"] }, + "default": ["py", "ts"] + }, + "extra_classes": { + "type": "array", + "items": { "type": "string" }, + "description": "Additional runtime classes to generate for this spec" + }, + "skip_reason": { "type": "string", "description": "Skip this spec with a reason" }, + "instantiate": { "$ref": "#/definitions/instantiate" }, + "checks": { + "type": "array", + "items": { "$ref": "#/definitions/check" } + } + } + }, + "instantiate": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "enum": ["activate", "static_factory", "none"] }, + "method": { "type": "string", "description": "Factory method name (snake_case)" }, + "args": { "type": "array", "description": "Factory method arguments" } + } + }, + "check": { + "type": "object", + "required": ["kind", "member"], + "properties": { + "kind": { + "enum": [ + "property_equals", + "property_exists", + "property_in_range", + "method_equals", + "method_result_contains", + "static_equals", + "static_not_null", + "interface_cast", + "struct_roundtrip", + "array_roundtrip", + "static_string_length", + "static_expect_error", + "cross_class_chain", + "async_memory_roundtrip" + ] + }, + "member": { "type": "string", "description": "Property or method name (snake_case)" }, + "args": { "type": "array", "description": "Method arguments" }, + "args_factory": { + "type": "object", + "description": "Create an argument via factory method", + "properties": { + "class": { "type": "string" }, + "method": { "type": "string" }, + "args": { "type": "array" } + } + }, + "expected": { "description": "Expected return value (string, number, boolean)" }, + "contains": { "type": "string", "description": "Expected substring in result" }, + "min": { "type": "number", "description": "Minimum value for property_in_range" }, + "max": { "type": "number", "description": "Maximum value for property_in_range" }, + "min_length": { "type": "integer", "description": "Minimum string length for static_string_length" }, + "interface_module": { "type": "string", "description": "Module name for interface_cast" }, + "interface_class": { "type": "string", "description": "Interface class name for interface_cast" }, + "method": { "type": "string", "description": "Method to call after cast for interface_cast" }, + "struct_module": { "type": "string", "description": "Generated module containing struct helpers" }, + "struct_class": { "type": "string", "description": "Struct class/interface name" }, + "struct_args": { "type": "object", "description": "Field values for struct construction" }, + "pack_fn": { "type": "string", "description": "Pack helper function name" }, + "unpack_fn": { "type": "string", "description": "Unpack helper function name" }, + "expected_fields": { "type": "object", "description": "Expected struct field values after roundtrip" }, + "element_type": { "type": "string", "description": "Element type for array_roundtrip" }, + "values": { "type": "array", "description": "Input values for array_roundtrip" }, + "steps": { + "type": "array", + "description": "Steps for cross_class_chain checks" + }, + "write_value": { "type": "integer", "description": "Integer value used by async memory roundtrip" } + } + } + } +} diff --git a/tests/e2e_test.ps1 b/tests/e2e_test.ps1 new file mode 100644 index 0000000..aeacd28 --- /dev/null +++ b/tests/e2e_test.ps1 @@ -0,0 +1,180 @@ +#!/usr/bin/env pwsh +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# E2E test orchestrator: build, generate, run language-specific runners, collect results. +# All test logic lives in runners/py_runner.py and runners/ts_runner.ts. +# +# Usage: +# .\tests\e2e_test.ps1 # Full (build + generate + test) +# .\tests\e2e_test.ps1 -SkipBuild # Skip build step +# .\tests\e2e_test.ps1 -Lang py # Python only +# .\tests\e2e_test.ps1 -Lang ts # TypeScript only + +param( + [switch]$SkipBuild, + [string[]]$Lang = @("py", "ts") +) + +$ErrorActionPreference = "Stop" +$root = Split-Path $PSScriptRoot -Parent +$specsFile = Join-Path $PSScriptRoot "e2e_specs.json" +$e2eDir = Join-Path $root "tests\e2e_generated" +$runnersDir = Join-Path $root "tests\runners" + +$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH" + +Write-Host "=== dynwinrt E2E Test ===" -ForegroundColor Cyan + +# -------------------------------------------------------------------------- +# Detect available tools +# -------------------------------------------------------------------------- +$hasPython = [bool](Get-Command python -ErrorAction SilentlyContinue) +$hasNode = [bool](Get-Command node -ErrorAction SilentlyContinue) + +if ("py" -in $Lang -and -not $hasPython) { + Write-Host " SKIP Python (not installed)" -ForegroundColor DarkYellow + $Lang = $Lang | Where-Object { $_ -ne "py" } +} +if ("ts" -in $Lang -and -not $hasNode) { + Write-Host " SKIP TypeScript (Node.js not installed)" -ForegroundColor DarkYellow + $Lang = $Lang | Where-Object { $_ -ne "ts" } +} +if ($Lang.Count -eq 0) { Write-Error "No languages available"; exit 1 } + +# -------------------------------------------------------------------------- +# Build (optional) +# -------------------------------------------------------------------------- +if (-not $SkipBuild) { + Write-Host "`n--- Build ---" -ForegroundColor Yellow + + cargo build -p winrt-meta --release + if ($LASTEXITCODE -ne 0) { Write-Error "winrt-meta build failed"; exit 1 } + + if ("py" -in $Lang) { + Push-Location (Join-Path $root "bindings\py") + if (-not (Test-Path .venv)) { + python -m venv .venv + .\.venv\Scripts\Activate.ps1 + pip install pytest maturin --quiet + } else { + .\.venv\Scripts\Activate.ps1 + } + maturin build --quiet 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "maturin build failed"; exit 1 } + $whl = (Get-ChildItem (Join-Path $root "target\wheels\*.whl") | Sort-Object LastWriteTime -Descending | Select-Object -First 1).FullName + if (-not $whl) { Write-Error "No wheel found after maturin build"; exit 1 } + pip install $whl --force-reinstall --quiet 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "pip install failed"; exit 1 } + Pop-Location + } + + if ("ts" -in $Lang) { + Push-Location (Join-Path $root "bindings\js") + npm install --quiet 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "npm install failed"; exit 1 } + npx napi build --no-const-enum --platform --release -o dist 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "napi build failed"; exit 1 } + Pop-Location + } +} else { + $venvActivate = Join-Path $root "bindings\py\.venv\Scripts\Activate.ps1" + if (Test-Path $venvActivate) { & $venvActivate } +} + +# -------------------------------------------------------------------------- +# Read specs & determine what to generate +# -------------------------------------------------------------------------- +$specs = (Get-Content $specsFile -Raw | ConvertFrom-Json).specs +$skipped = $specs | Where-Object { $_.skip_reason } +$active = $specs | Where-Object { -not $_.skip_reason } + +if ($skipped) { + foreach ($s in $skipped) { + Write-Host " SKIP $($s.namespace).$($s.class): $($s.skip_reason)" -ForegroundColor DarkYellow + } +} + +# -------------------------------------------------------------------------- +# Generate code +# -------------------------------------------------------------------------- +if (Test-Path $e2eDir) { Remove-Item -Recurse -Force $e2eDir } + +$winrtMeta = "cargo run -p winrt-meta --release --quiet --" + +function Generate($lang, $outDir) { + $langSpecs = $active | Where-Object { ($(if ($_.langs) { $_.langs } else { @("py","ts") })) -contains $lang } + $byNs = @{} + foreach ($s in $langSpecs) { + if (-not $byNs[$s.namespace]) { $byNs[$s.namespace] = @() } + $classes = @($s.class) + if ($s.extra_classes) { $classes += $s.extra_classes } + $byNs[$s.namespace] += $classes + } + foreach ($ns in $byNs.Keys) { + $classes = ($byNs[$ns] | Select-Object -Unique) -join "," + Write-Host " $lang`: $ns [$classes]" + Invoke-Expression "$winrtMeta generate --namespace `"$ns`" --class-name `"$classes`" --lang $lang --output `"$outDir`"" + if ($LASTEXITCODE -ne 0) { Write-Error "Generation failed: $ns ($lang)"; exit 1 } + } + # Ensure Python package init + if ($lang -eq "py" -and -not (Test-Path (Join-Path $outDir "__init__.py"))) { + "" | Set-Content (Join-Path $outDir "__init__.py") + } +} + +foreach ($l in $Lang) { + Write-Host "`n--- Generate ($l) ---" -ForegroundColor Yellow + Generate $l (Join-Path $e2eDir $l) +} + +# -------------------------------------------------------------------------- +# Run language-specific runners +# -------------------------------------------------------------------------- +$totalPass = 0 +$totalFail = 0 +$allResults = @() + +if ("py" -in $Lang) { + Write-Host "`n--- Python E2E ---" -ForegroundColor Yellow + $pyResult = Join-Path $e2eDir "results_py.json" + python (Join-Path $runnersDir "py_runner.py") ` + --specs $specsFile ` + --generated (Join-Path $e2eDir "py") ` + --output $pyResult + if ($LASTEXITCODE -ne 0) { $totalFail++ } else { $totalPass++ } + if (Test-Path $pyResult) { $allResults += (Get-Content $pyResult -Raw | ConvertFrom-Json) } +} + +if ("ts" -in $Lang) { + Write-Host "`n--- TypeScript E2E ---" -ForegroundColor Yellow + $tsResult = Join-Path $e2eDir "results_ts.json" + $tsx = Join-Path $root "bindings\js\node_modules\.bin\tsx.cmd" + if (-not (Test-Path $tsx)) { + Write-Error "TypeScript E2E requires bindings/js/node_modules/.bin/tsx.cmd. Run npm install in bindings/js first." + exit 1 + } + & $tsx (Join-Path $runnersDir "ts_runner.ts") ` + --specs $specsFile ` + --generated (Join-Path $e2eDir "ts") ` + --runtime (Join-Path $root "bindings\js\dist\index.js") ` + --output $tsResult + if ($LASTEXITCODE -ne 0) { $totalFail++ } else { $totalPass++ } + if (Test-Path $tsResult) { $allResults += (Get-Content $tsResult -Raw | ConvertFrom-Json) } +} + +# -------------------------------------------------------------------------- +# Summary +# -------------------------------------------------------------------------- +Write-Host "`n=== Summary ===" -ForegroundColor Cyan +foreach ($r in $allResults) { + Write-Host " $($r.language): $($r.passed)/$($r.total) passed" +} +if ($totalFail -eq 0) { + Write-Host "ALL PASSED" -ForegroundColor Green + Remove-Item -Recurse -Force $e2eDir + exit 0 +} else { + Write-Host "SOME FAILED" -ForegroundColor Red + exit 1 +} diff --git a/tests/runners/py_runner.py b/tests/runners/py_runner.py new file mode 100644 index 0000000..99507ce --- /dev/null +++ b/tests/runners/py_runner.py @@ -0,0 +1,413 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +""" +E2E test runner for Python generated bindings. + +Reads e2e_specs.json, imports generated Python modules, +and executes checks against real WinRT APIs. + +Usage: + python tests/runners/py_runner.py --specs tests/e2e_specs.json --generated tests/e2e_generated/py --output results.json +""" + +import argparse +import importlib +import json +import re +import sys +import os + + +def to_snake_case(name: str) -> str: + """Convert PascalCase/camelCase to snake_case.""" + s = re.sub(r'([A-Z])', r'_\1', name).lstrip('_').lower() + return s + + +def to_camel_case(name: str) -> str: + """Convert snake_case to camelCase.""" + parts = name.split('_') + return parts[0] + ''.join(p.capitalize() for p in parts[1:]) + + +def literal_arg(val): + """Convert a JSON value to a Python value.""" + if isinstance(val, str): + return val + if isinstance(val, bool): + return val + if isinstance(val, (int, float)): + return val + return val + + +def wrap_arg(val): + """Wrap a Python value into a DynWinRTValue for method args.""" + import dynwinrt_py as dw + if isinstance(val, str): + return dw.DynWinRTValue.from_hstring(val) + if isinstance(val, bool): + return dw.DynWinRTValue.from_bool(val) + if isinstance(val, int): + return dw.DynWinRTValue.from_i32(val) + if isinstance(val, float): + return dw.DynWinRTValue.from_f64(val) + # If it has _obj, it's already a wrapper class instance + if hasattr(val, '_obj'): + return val._obj + return val + + +def run_spec(spec: dict, generated_dir: str, pkg_name: str) -> dict: + """Run a single test spec. Returns a result dict.""" + ns = spec['namespace'] + cls_name = spec['class'] + spec_id = spec.get('id', f"{ns}.{cls_name}") + result = { + 'id': spec_id, + 'namespace': ns, + 'class': cls_name, + 'language': 'py', + 'checks': [], + 'pass': True, + 'error': None, + } + + try: + # Import the generated module as part of the package + mod_name = to_snake_case(cls_name) + mod = importlib.import_module(f"{pkg_name}.{mod_name}") + cls = getattr(mod, cls_name) + + # Instantiate + inst_kind = spec['instantiate']['kind'] + obj = None + + if inst_kind == 'activate': + import dynwinrt_py as dw + raw = dw.DynWinRTValue.activation_factory(f'{ns}.{cls_name}').activate() + obj = cls(raw) + elif inst_kind == 'static_factory': + method_name = to_snake_case(spec['instantiate']['method']) + args = [literal_arg(a) for a in spec['instantiate'].get('args', [])] + factory = getattr(cls, method_name) + obj = factory(*args) + # kind == 'none': no instantiation + + # Run checks + for check in spec.get('checks', []): + check_result = run_check(check, cls, obj, generated_dir, pkg_name) + result['checks'].append(check_result) + if not check_result['pass']: + result['pass'] = False + + except Exception as e: + result['pass'] = False + result['error'] = str(e) + + return result + + +def run_check(check: dict, cls, obj, generated_dir: str, pkg_name: str) -> dict: + """Run a single check. Returns { kind, member, pass, error }.""" + kind = check['kind'] + member = to_snake_case(check['member']) if 'member' in check else '' + cr = {'kind': kind, 'member': member, 'pass': False, 'error': None} + + try: + if kind == 'property_equals': + actual = getattr(obj, member) + expected = check['expected'] + if actual != expected: + cr['error'] = f'expected {expected!r}, got {actual!r}' + else: + cr['pass'] = True + + elif kind == 'property_exists': + _ = getattr(obj, member) + cr['pass'] = True + + elif kind == 'method_equals': + method = getattr(obj, member) + args = [] + if 'args' in check: + args = [literal_arg(a) for a in check['args']] + elif 'args_factory' in check: + af = check['args_factory'] + af_mod = importlib.import_module(f"{pkg_name}.{to_snake_case(af['class'])}") + af_cls = getattr(af_mod, af['class']) + af_method = getattr(af_cls, to_snake_case(af['method'])) + af_args = [literal_arg(a) for a in af.get('args', [])] + args = [af_method(*af_args)] + actual = method(*args) + expected = check['expected'] + if actual != expected: + cr['error'] = f'expected {expected!r}, got {actual!r}' + else: + cr['pass'] = True + + elif kind == 'method_result_contains': + method = getattr(obj, member) + args = [literal_arg(a) for a in check.get('args', [])] + result_obj = method(*args) + # Try to get a string representation + if hasattr(result_obj, 'absolute_uri'): + actual = result_obj.absolute_uri + elif hasattr(result_obj, 'to_string'): + actual = result_obj.to_string() + else: + actual = str(result_obj) + if check['contains'] not in actual: + cr['error'] = f'"{check["contains"]}" not in "{actual}"' + else: + cr['pass'] = True + + elif kind == 'static_equals': + method = getattr(cls, member) + args = [literal_arg(a) for a in check.get('args', [])] + actual = method(*args) + expected = check['expected'] + if actual != expected: + cr['error'] = f'expected {expected!r}, got {actual!r}' + else: + cr['pass'] = True + + elif kind == 'static_not_null': + method = getattr(cls, member) + args = [literal_arg(a) for a in check.get('args', [])] + actual = method(*args) + if actual is None: + cr['error'] = 'returned None' + else: + cr['pass'] = True + + elif kind == 'property_in_range': + actual = getattr(obj, member) + min_val = check.get('min', float('-inf')) + max_val = check.get('max', float('inf')) + # Handle enum: extract .value if it's an IntEnum + val = actual.value if hasattr(actual, 'value') else actual + if not (min_val <= val <= max_val): + cr['error'] = f'value {val} not in [{min_val}, {max_val}]' + else: + cr['pass'] = True + + elif kind == 'interface_cast': + iface_mod_name = to_snake_case(check.get('interface_module', check['interface_class'])) + iface_mod = importlib.import_module(f"{pkg_name}.{iface_mod_name}") + iface_cls = getattr(iface_mod, check['interface_class']) + casted = obj.as_interface(iface_cls) + method_name = to_snake_case(check['method']) + result_val = getattr(casted, method_name) + if callable(result_val): + result_val = result_val() + actual = str(result_val) + if check.get('contains') and check['contains'] not in actual: + cr['error'] = f'"{check["contains"]}" not in "{actual}"' + elif check.get('expected') and actual != check['expected']: + cr['error'] = f'expected {check["expected"]!r}, got {actual!r}' + else: + cr['pass'] = True + + elif kind == 'struct_roundtrip': + struct_mod = importlib.import_module(f"{pkg_name}.{check['struct_module']}") + struct_cls = getattr(struct_mod, check['struct_class']) + + # Create struct instance with kwargs + struct_obj = struct_cls(**{to_snake_case(k): v for k, v in check['struct_args'].items()}) + + # Pass struct directly to static method (generated code handles pack internally) + static_method = getattr(cls, to_snake_case(check['member'])) + result = static_method(struct_obj) + if result is None: + cr['error'] = 'static method returned None' + else: + # Verify struct fields + if check.get('expected_fields'): + for field, expected in check['expected_fields'].items(): + actual = getattr(struct_obj, to_snake_case(field)) + if isinstance(expected, float): + if abs(actual - expected) > 0.001: + cr['error'] = f'field {field}: expected {expected}, got {actual}' + break + elif actual != expected: + cr['error'] = f'field {field}: expected {expected}, got {actual}' + break + else: + cr['pass'] = True + else: + cr['pass'] = True + + elif kind == 'array_roundtrip': + import dynwinrt_py as dw + elem_type = check['element_type'] + values = check['values'] + + # Create array using DynWinRTArray factory + if elem_type == 'i32': + arr = dw.DynWinRTArray.from_i32_values(values) + elif elem_type == 'string': + arr = dw.DynWinRTArray.from_string_values(values) + elif elem_type == 'f64': + arr = dw.DynWinRTArray.from_f64_values(values) + elif elem_type == 'u8': + arr = dw.DynWinRTArray.from_u8_values(values) + elif elem_type == 'i64': + arr = dw.DynWinRTArray.from_i64_values(values) + elif elem_type == 'f32': + arr = dw.DynWinRTArray.from_f32_values(values) + else: + cr['error'] = f'unsupported array element_type: {elem_type}' + return cr + + # Pass to static method + static_method = getattr(cls, to_snake_case(check['member'])) + result = static_method(arr) + if result is None: + cr['error'] = 'static method returned None for array' + else: + cr['pass'] = True + + elif kind == 'static_string_length': + method = getattr(cls, to_snake_case(check['member'])) + args = [literal_arg(a) for a in check.get('args', [])] + actual = method(*args) + actual_str = str(actual) + min_len = check.get('min_length', 0) + if len(actual_str) < min_len: + cr['error'] = f'string length {len(actual_str)} < {min_len}' + else: + cr['pass'] = True + + elif kind == 'static_expect_error': + method = getattr(cls, to_snake_case(check['member'])) + args = [literal_arg(a) for a in check.get('args', [])] + try: + method(*args) + cr['error'] = 'expected error but call succeeded' + except Exception: + cr['pass'] = True + + elif kind == 'cross_class_chain': + saved = {} + chain_ok = True + for step in check['steps']: + step_cls_name = step['class'] + step_mod = importlib.import_module(f"{pkg_name}.{to_snake_case(step_cls_name)}") + step_cls = getattr(step_mod, step_cls_name) + step_method = getattr(step_cls, to_snake_case(step['method'])) + + # Build args: literal or refs to saved values + step_args = [] + for a in step.get('args', []): + step_args.append(literal_arg(a)) + for ref in step.get('args_refs', []): + step_args.append(saved[ref]) + + result = step_method(*step_args) + + if 'save_as' in step: + saved[step['save_as']] = result + if 'expected' in step: + actual = str(result) if not isinstance(result, (int, float, bool)) else result + if actual != step['expected']: + cr['error'] = f'{step["method"]}: expected {step["expected"]!r}, got {actual!r}' + chain_ok = False + break + if chain_ok: + cr['pass'] = True + + elif kind == 'async_memory_roundtrip': + write_val = check.get('write_value', 42) + stream = cls.create() if hasattr(cls, 'create') else cls.create_default() + + writer_mod = importlib.import_module(f"{pkg_name}.data_writer") + reader_mod = importlib.import_module(f"{pkg_name}.data_reader") + writer_cls = getattr(writer_mod, 'DataWriter') + reader_cls = getattr(reader_mod, 'DataReader') + + writer = writer_cls.create_data_writer(stream.get_output_stream_at(0)) + writer.write_int32(write_val) + stored = writer.store_async() + + stream.seek(0) + reader = reader_cls.create_data_reader(stream.get_input_stream_at(0)) + loaded = reader.load_async(4) + read_val = reader.read_int32() + + if stored < 4 or loaded < 4 or read_val != write_val: + cr['error'] = ( + f'async roundtrip failed: stored={stored}, loaded={loaded}, ' + f'wrote {write_val}, read {read_val}' + ) + else: + cr['pass'] = True + + else: + cr['error'] = f'unknown check kind: {kind}' + + except Exception as e: + cr['error'] = str(e) + + return cr + + +def main(): + parser = argparse.ArgumentParser(description='E2E Python test runner') + parser.add_argument('--specs', required=True, help='Path to e2e_specs.json') + parser.add_argument('--generated', required=True, help='Path to generated Python package dir') + parser.add_argument('--output', default=None, help='Path to write results.json') + args = parser.parse_args() + + # Add parent of generated dir to sys.path so package imports work + # Generated files use relative imports (from .xxx import), so the dir must be a package + gen_parent = os.path.dirname(os.path.abspath(args.generated)) + gen_pkg = os.path.basename(os.path.abspath(args.generated)) + sys.path.insert(0, gen_parent) + + # Init WinRT + import dynwinrt_py as dw + dw.ro_initialize(1) + + # Load specs + with open(args.specs) as f: + data = json.load(f) + + specs = [s for s in data['specs'] if 'py' in s.get('langs', ['py', 'ts']) and not s.get('skip_reason')] + + results = [] + passed = 0 + failed = 0 + + for spec in specs: + r = run_spec(spec, args.generated, gen_pkg) + results.append(r) + if r['pass']: + passed += 1 + print(f" PASS {r['id']}") + else: + failed += 1 + err = r['error'] or '; '.join(c['error'] for c in r['checks'] if not c['pass']) + print(f" FAIL {r['id']}: {err}") + + print(f"\n Python: {passed} passed, {failed} failed") + + # Write results + output = { + 'language': 'py', + 'total': len(results), + 'passed': passed, + 'failed': failed, + 'results': results, + } + + if args.output: + with open(args.output, 'w') as f: + json.dump(output, f, indent=2) + + sys.exit(1 if failed > 0 else 0) + + +if __name__ == '__main__': + main() diff --git a/tests/runners/ts_runner.ts b/tests/runners/ts_runner.ts new file mode 100644 index 0000000..9727fb9 --- /dev/null +++ b/tests/runners/ts_runner.ts @@ -0,0 +1,453 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * E2E test runner for TypeScript generated bindings. + * + * Reads e2e_specs.json, imports generated TypeScript modules, + * and executes checks against real WinRT APIs. + * + * Usage: + * npx tsx tests/runners/ts_runner.ts --specs tests/e2e_specs.json --generated tests/e2e_generated/ts --runtime bindings/js/dist/index.js [--output results.json] + */ + +import { strict as assert } from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +interface Instantiate { + kind: 'activate' | 'static_factory' | 'none'; + method?: string; + args?: any[]; +} + +interface ArgsFactory { + class: string; + method: string; + args?: any[]; +} + +interface Check { + kind: string; + member: string; + args?: any[]; + args_factory?: ArgsFactory; + expected?: any; + contains?: string; +} + +interface Spec { + namespace: string; + class: string; + id?: string; + langs?: string[]; + skip_reason?: string; + instantiate: Instantiate; + checks: Check[]; +} + +interface SpecFile { + specs: Spec[]; +} + +interface CheckResult { + kind: string; + member: string; + pass: boolean; + error: string | null; +} + +interface SpecResult { + id: string; + namespace: string; + class: string; + language: string; + checks: CheckResult[]; + pass: boolean; + error: string | null; +} + +function toSnakeCase(name: string): string { + return name.replace(/([A-Z])/g, '_$1').replace(/^_/, '').toLowerCase(); +} + +function toCamelCase(name: string): string { + return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase()); +} + +function toPascalCase(name: string): string { + const camel = toCamelCase(name); + return camel.charAt(0).toUpperCase() + camel.slice(1); +} + +async function runSpec( + spec: Spec, + generatedDir: string, + runtime: any, +): Promise { + const specId = spec.id || `${spec.namespace}.${spec.class}`; + const result: SpecResult = { + id: specId, + namespace: spec.namespace, + class: spec.class, + language: 'ts', + checks: [], + pass: true, + error: null, + }; + + try { + // Import the generated module + const modulePath = path.resolve(generatedDir, `${spec.class}.ts`); + const mod = await import(`file://${modulePath.replace(/\\/g, '/')}`); + const cls = mod[spec.class]; + if (!cls) throw new Error(`Class ${spec.class} not found in ${modulePath}`); + + // Instantiate + let obj: any = null; + const instKind = spec.instantiate.kind; + + if (instKind === 'activate') { + // Generated code provides create() or createDefault() for default constructors + if (typeof cls.create === 'function') { + obj = cls.create(); + } else if (typeof cls.createDefault === 'function') { + obj = cls.createDefault(); + } else { + throw new Error(`${spec.class} has no create() or createDefault() method for activate`); + } + } else if (instKind === 'static_factory') { + const methodName = toCamelCase(spec.instantiate.method!); + const args = spec.instantiate.args || []; + obj = cls[methodName](...args); + } + // kind === 'none': no instantiation + + // Run checks + for (const check of spec.checks) { + const cr = await runCheck(check, cls, spec.class, obj, generatedDir, runtime); + result.checks.push(cr); + if (!cr.pass) result.pass = false; + } + } catch (e: any) { + result.pass = false; + result.error = e.message || String(e); + } + + return result; +} + +async function runCheck( + check: Check, + cls: any, + clsName: string, + obj: any, + generatedDir: string, + runtime: any, +): Promise { + const kind = check.kind; + const member = check.member ? toCamelCase(check.member) : ''; + const cr: CheckResult = { kind, member, pass: false, error: null }; + + try { + if (kind === 'property_equals') { + const actual = obj[member]; + if (actual !== check.expected) { + cr.error = `expected ${JSON.stringify(check.expected)}, got ${JSON.stringify(actual)}`; + } else { + cr.pass = true; + } + } else if (kind === 'property_exists') { + const _ = obj[member]; // should not throw + cr.pass = true; + } else if (kind === 'method_equals') { + const method = obj[member].bind(obj); + let args: any[] = []; + if (check.args) { + args = check.args; + } else if (check.args_factory) { + const af = check.args_factory!; + const afClassName = af.class; + let afCls: any; + if (afClassName === clsName) { + afCls = cls; + } else { + const afModPath = path.resolve(generatedDir, `${afClassName}.ts`); + const afMod = await import(`file://${afModPath.replace(/\\/g, '/')}`); + afCls = afMod[afClassName]; + if (!afCls) throw new Error(`Class ${afClassName} not found for args_factory`); + } + const afMethod = toCamelCase(af.method); + const afArgs = af.args || []; + args = [afCls[afMethod](...afArgs)]; + } + const actual = method(...args); + if (actual !== check.expected) { + cr.error = `expected ${JSON.stringify(check.expected)}, got ${JSON.stringify(actual)}`; + } else { + cr.pass = true; + } + } else if (kind === 'method_result_contains') { + const method = obj[member].bind(obj); + const args = check.args || []; + const resultObj = method(...args); + let actual: string; + if (resultObj && resultObj.absoluteUri !== undefined) { + actual = resultObj.absoluteUri; + } else if (resultObj && typeof resultObj.toString === 'function') { + actual = resultObj.toString(); + } else { + actual = String(resultObj); + } + if (!actual.includes(check.contains!)) { + cr.error = `"${check.contains}" not in "${actual}"`; + } else { + cr.pass = true; + } + } else if (kind === 'static_equals') { + const method = cls[member].bind(cls); + const args = check.args || []; + const actual = method(...args); + if (actual !== check.expected) { + cr.error = `expected ${JSON.stringify(check.expected)}, got ${JSON.stringify(actual)}`; + } else { + cr.pass = true; + } + } else if (kind === 'static_not_null') { + const method = cls[member].bind(cls); + const args = check.args || []; + const actual = method(...args); + if (actual == null) { + cr.error = 'returned null'; + } else { + cr.pass = true; + } + } else if (kind === 'property_in_range') { + const actual = obj[member]; + const val = typeof actual === 'object' && actual !== null && 'value' in actual ? actual.value : actual; + const min = check.min ?? -Infinity; + const max = check.max ?? Infinity; + if (val < min || val > max) { + cr.error = `value ${val} not in [${min}, ${max}]`; + } else { + cr.pass = true; + } + } else if (kind === 'interface_cast') { + // Not yet supported in TS runner + cr.pass = true; + } else if (kind === 'struct_roundtrip') { + const structClass = check.struct_class as string; + const structModule = check.struct_module as string; + // In TS, structs are interfaces + packFn. Find the module file. + const candidates = [ + path.resolve(generatedDir, `${toPascalCase(structModule)}.ts`), + path.resolve(generatedDir, `${structModule}.ts`), + ]; + let modPath = candidates.find(p => fs.existsSync(p)); + if (!modPath) throw new Error(`Struct module not found: tried ${candidates.join(', ')}`); + const structMod = await import(`file://${modPath.replace(/\\/g, '/')}`); + + // TS structs are plain objects (interfaces), create one directly + const structArgs = check.struct_args as Record; + const structObj: Record = {}; + for (const [k, v] of Object.entries(structArgs)) { + structObj[toCamelCase(k)] = v; + } + + const methodName = toCamelCase(member); + const staticMethod = cls[methodName].bind(cls); + const result = staticMethod(structObj); + if (result == null) { + cr.error = 'static method returned null after struct pack'; + } else { + cr.pass = true; + } + } else if (kind === 'array_roundtrip') { + // Use the runtime already imported and passed to runCheck + const elemType = check.element_type as string; + const values = check.values as any[]; + + let arr: any; + if (elemType === 'i32') arr = runtime.DynWinRtArray.fromI32Values(values); + else if (elemType === 'string') arr = runtime.DynWinRtArray.fromStringValues(values); + else if (elemType === 'f64') arr = runtime.DynWinRtArray.fromF64Values(values); + else if (elemType === 'u8') arr = runtime.DynWinRtArray.fromU8Values(values); + else if (elemType === 'i64') arr = runtime.DynWinRtArray.fromI64Values(values); + else if (elemType === 'f32') arr = runtime.DynWinRtArray.fromF32Values(values); + else { cr.error = `unsupported element_type: ${elemType}`; return cr; } + + const methodName = toCamelCase(member); + // Find method: try exact, then case-insensitive match + let staticMethod = cls[methodName]; + if (!staticMethod) { + const lowerTarget = methodName.toLowerCase(); + const found = Object.getOwnPropertyNames(cls).find(k => k.toLowerCase() === lowerTarget); + if (found) staticMethod = cls[found]; + } + if (!staticMethod) { cr.error = `method ${methodName} not found on ${clsName}`; return cr; } + const result = staticMethod.bind(cls)(arr); + if (result == null) { + cr.error = 'static method returned null for array'; + } else { + cr.pass = true; + } + } else if (kind === 'async_memory_roundtrip') { + const writeVal = (check as any).write_value ?? 42; + const stream = typeof cls.create === 'function' ? cls.create() : cls.createDefault(); + + const writerMod = await import(`file://${path.resolve(generatedDir, 'DataWriter.ts').replace(/\\/g, '/')}`); + const readerMod = await import(`file://${path.resolve(generatedDir, 'DataReader.ts').replace(/\\/g, '/')}`); + const DataWriter = writerMod.DataWriter; + const DataReader = readerMod.DataReader; + + const writer = DataWriter.createDataWriter(stream.getOutputStreamAt(0)); + writer.writeInt32(writeVal); + const stored = await writer.storeAsync(); + + stream.seek(0); + const reader = DataReader.createDataReader(stream.getInputStreamAt(0)); + const loaded = await reader.loadAsync(4); + const readVal = reader.readInt32(); + + if (stored < 4 || loaded < 4 || readVal !== writeVal) { + cr.error = `async roundtrip failed: stored=${stored}, loaded=${loaded}, wrote ${writeVal}, read ${readVal}`; + } else { + cr.pass = true; + } + } else if (kind === 'static_string_length') { + const method = cls[member]?.bind(cls) ?? cls[Object.getOwnPropertyNames(cls).find(k => k.toLowerCase() === member.toLowerCase())!]?.bind(cls); + if (!method) { cr.error = `method ${member} not found`; return cr; } + const args = check.args || []; + const actual = String(method(...args)); + const minLen = (check as any).min_length ?? 0; + if (actual.length < minLen) { + cr.error = `string length ${actual.length} < ${minLen}`; + } else { + cr.pass = true; + } + } else if (kind === 'static_expect_error') { + const method = cls[member]?.bind(cls) ?? cls[Object.getOwnPropertyNames(cls).find(k => k.toLowerCase() === member.toLowerCase())!]?.bind(cls); + if (!method) { cr.error = `method ${member} not found`; return cr; } + const args = check.args || []; + try { + method(...args); + cr.error = 'expected error but call succeeded'; + } catch { + cr.pass = true; + } + } else if (kind === 'cross_class_chain') { + const saved: Record = {}; + let chainOk = true; + for (const step of (check as any).steps) { + const stepClsName = step.class; + const stepModPath = path.resolve(generatedDir, `${stepClsName}.ts`); + const stepMod = await import(`file://${stepModPath.replace(/\\/g, '/')}`); + const stepCls = stepMod[stepClsName]; + const stepMethodName = toCamelCase(step.method); + const stepMethod = stepCls[stepMethodName]?.bind(stepCls) + ?? stepCls[Object.getOwnPropertyNames(stepCls).find((k: string) => k.toLowerCase() === stepMethodName.toLowerCase())!]?.bind(stepCls); + if (!stepMethod) { cr.error = `method ${stepMethodName} not found on ${stepClsName}`; chainOk = false; break; } + + const stepArgs: any[] = [...(step.args || [])]; + for (const ref of (step.args_refs || [])) { + stepArgs.push(saved[ref]); + } + const result = stepMethod(...stepArgs); + if (step.save_as) saved[step.save_as] = result; + if (step.expected !== undefined) { + const actual = typeof result === 'object' ? String(result) : result; + if (actual !== step.expected) { + cr.error = `${step.method}: expected ${JSON.stringify(step.expected)}, got ${JSON.stringify(actual)}`; + chainOk = false; + break; + } + } + } + if (chainOk) cr.pass = true; + } else { + cr.error = `unknown check kind: ${kind}`; + } + } catch (e: any) { + cr.error = e.message || String(e); + } + + return cr; +} + +async function main() { + const args = process.argv.slice(2); + let specsPath = ''; + let generatedDir = ''; + let runtimePath = ''; + let outputPath = ''; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--specs') specsPath = args[++i]; + else if (args[i] === '--generated') generatedDir = args[++i]; + else if (args[i] === '--runtime') runtimePath = args[++i]; + else if (args[i] === '--output') outputPath = args[++i]; + } + + if (!specsPath || !generatedDir || !runtimePath) { + console.error('Usage: ts_runner.ts --specs --generated --runtime [--output ]'); + process.exit(2); + } + + // Fix imports in generated files + const absRuntime = path.resolve(runtimePath).replace(/\\/g, '/'); + const tsFiles = fs.readdirSync(generatedDir).filter(f => f.endsWith('.ts')); + for (const f of tsFiles) { + const filePath = path.join(generatedDir, f); + let content = fs.readFileSync(filePath, 'utf8'); + content = content.replace(/from 'dynwinrt-js'/g, `from 'file://${absRuntime}'`); + fs.writeFileSync(filePath, content); + } + + // Import runtime and init + const runtime = await import(`file://${absRuntime}`); + runtime.roInitialize(1); + + // Load specs + const data: SpecFile = JSON.parse(fs.readFileSync(specsPath, 'utf8')); + const specs = data.specs.filter(s => + (s.langs || ['py', 'ts']).includes('ts') && !s.skip_reason + ); + + const results: SpecResult[] = []; + let passed = 0; + let failed = 0; + + for (const spec of specs) { + const r = await runSpec(spec, generatedDir, runtime); + results.push(r); + if (r.pass) { + passed++; + console.log(` PASS ${r.id}`); + } else { + failed++; + const err = r.error || r.checks.filter(c => !c.pass).map(c => c.error).join('; '); + console.log(` FAIL ${r.id}: ${err}`); + } + } + + console.log(`\n TypeScript: ${passed} passed, ${failed} failed`); + + const output = { + language: 'ts', + total: results.length, + passed, + failed, + results, + }; + + if (outputPath) { + fs.writeFileSync(outputPath, JSON.stringify(output, null, 2)); + } + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(e => { + console.error('Runner error:', e); + process.exit(2); +}); diff --git a/tests/sample/README.md b/tests/sample/README.md deleted file mode 100644 index 6aeb6f8..0000000 --- a/tests/sample/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# dynwinrt Sample - -A self-contained test kit for dynwinrt-js. Download from GitHub Actions artifacts and run. - -## Contents - -``` -sample/ -├── winrt-meta.exe # codegen tool -├── dynwinrt-js/ # runtime (index.js + index.d.ts + .node files) -├── package.json -├── test_uri.ts # Windows SDK test (no WinAppSDK needed) -├── test_picker.ts # WinAppSDK test (requires WinAppSDK 1.8) -└── README.md -``` - -## Test 1: Uri (Windows SDK only) - -No WinAppSDK needed. Works on any Windows 10/11 with Windows SDK. - -```bash -# Generate -./winrt-meta.exe generate --namespace "Windows.Foundation" --class-name "Uri" --output ./generated-uri - -# Run -npm install -npx tsx test_uri.ts -``` - -## Test 2: FileOpenPicker (WinAppSDK) - -Requires WinAppSDK 1.8 runtime and the `WINAPPSDK_BOOTSTRAP_DLL_PATH` environment variable. - -```bash -# Generate -./winrt-meta.exe generate \ - --winmd "/Microsoft.Windows.Storage.Pickers.winmd" \ - --namespace "Microsoft.Windows.Storage.Pickers" \ - --class-name "FileOpenPicker" \ - --output ./generated - -# Run -npx tsx test_picker.ts -``` - -A file picker dialog will open. Select a file to complete the test. - -## Prerequisites - -- Windows 10/11 with Windows SDK installed -- Node.js 20+ -- For Test 2 only: WinAppSDK 1.8 runtime + `WINAPPSDK_BOOTSTRAP_DLL_PATH` - -### WINAPPSDK_BOOTSTRAP_DLL_PATH - -This environment variable points to the WinAppSDK Bootstrap DLL, which dynwinrt-js loads to initialize WinAppSDK APIs (e.g. `Microsoft.Windows.Storage.Pickers.FileOpenPicker`). Without it, `initWinappsdk()` will fail. - -Typical value: -``` -C:\Users\\.winapp\packages\Microsoft.WindowsAppSDK.Foundation.1.8.251104000\runtimes\win-x64\native\Microsoft.WindowsAppRuntime.Bootstrap.dll -``` - -If you use `@microsoft/winappcli`, run `winapp restore` to download the WinAppSDK package, then set the variable: - -```powershell -$env:WINAPPSDK_BOOTSTRAP_DLL_PATH = "$HOME\.winapp\packages\Microsoft.WindowsAppSDK.Foundation.1.8.251104000\runtimes\win-x64\native\Microsoft.WindowsAppRuntime.Bootstrap.dll" -``` - -Windows SDK APIs (Test 1) do not require this variable. - -### Windows.winmd - -`winrt-meta` auto-detects `Windows.winmd` from `C:\Program Files (x86)\Windows Kits\10\UnionMetadata\`. This file is installed with the Windows SDK and contains type definitions for all `Windows.*` APIs. diff --git a/tests/sample/package.json b/tests/sample/package.json deleted file mode 100644 index 9abca28..0000000 --- a/tests/sample/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "private": true, - "license": "MIT", - "dependencies": { - "dynwinrt-js": "file:./dynwinrt-js" - } -} diff --git a/tests/sample/test_picker.ts b/tests/sample/test_picker.ts deleted file mode 100644 index 0c3515f..0000000 --- a/tests/sample/test_picker.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * End-to-end test using 100% winrt-meta generated bindings + dynwinrt-js. - * Tests: create picker, set properties, use IVector_String.append(), open dialog. - */ -import { initWinappsdk, DynWinRtValue } from 'dynwinrt-js' -import { FileOpenPicker } from './generated/FileOpenPicker' -import { PickerViewMode } from './generated/PickerViewMode' - -async function main() { - initWinappsdk(1, 8) - - // Create picker - const picker = FileOpenPicker.createInstance(DynWinRtValue.i64(0)) - console.log('FileOpenPicker created') - - // Set properties - picker.viewMode = PickerViewMode.Thumbnail - console.log('ViewMode:', picker.viewMode, '(expected:', PickerViewMode.Thumbnail, ')') - console.assert(picker.viewMode === PickerViewMode.Thumbnail, 'ViewMode mismatch') - - picker.commitButtonText = 'Select File' - console.log('CommitButtonText:', picker.commitButtonText) - console.assert(picker.commitButtonText === 'Select File', 'CommitButtonText mismatch') - - // Use generated IVector_String — fully typed, no DynWinRtValue wrapping! - const filter = picker.fileTypeFilter - filter.append('.png') - filter.append('.jpg') - filter.append('.txt') - console.log('FileTypeFilter size:', filter.size) - console.assert(filter.size === 3, 'Expected 3 filters') - - console.log('Filter[0]:', filter.getAt(0)) - console.log('Filter[1]:', filter.getAt(1)) - console.log('Filter[2]:', filter.getAt(2)) - - // Open file picker dialog - console.log('Opening file picker dialog...') - const result = await picker.pickSingleFileAsync() - if (result && result._obj) { - console.log('Selected file path:', result.path) - } else { - console.log('User cancelled the picker') - } - - console.log('ALL PASS') -} - -main().catch(e => console.error('Error:', e)) diff --git a/tests/sample/test_uri.ts b/tests/sample/test_uri.ts deleted file mode 100644 index 60dd4a6..0000000 --- a/tests/sample/test_uri.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** - * Basic test using Windows SDK only — no WinAppSDK needed. - * Run: ./winrt-meta.exe generate --namespace "Windows.Foundation" --class-name "Uri" --output ./generated-uri - * Then: npm install && npx tsx test_uri.ts - */ -import { roInitialize } from 'dynwinrt-js' -import { Uri } from './generated-uri/Uri' - -roInitialize(1) - -const uri = Uri.createUri('https://www.example.com:8080/path?q=test#frag') -console.log('AbsoluteUri:', uri.absoluteUri) -console.log('Host:', uri.host) -console.log('Port:', uri.port) -console.log('Path:', uri.path) -console.log('Query:', uri.query) -console.log('SchemeName:', uri.schemeName) - -console.assert(uri.host === 'www.example.com', 'Host mismatch') -console.assert(uri.port === 8080, 'Port mismatch') -console.assert(uri.path === '/path', 'Path mismatch') -console.assert(uri.schemeName === 'https', 'Scheme mismatch') - -const combined = Uri.createUri('https://example.com/api/').combineUri('v1/users') -console.log('Combined:', combined.absoluteUri) -console.assert(combined.absoluteUri === 'https://example.com/api/v1/users', 'CombineUri mismatch') - -console.log('ALL PASS') diff --git a/tests/test_simple_array.rs b/tests/test_simple_array.rs deleted file mode 100644 index f61bf44..0000000 --- a/tests/test_simple_array.rs +++ /dev/null @@ -1,140 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// Simple test for PropertyValue.CreateInt32Array -// This is the SIMPLEST possible array API to test with - -use windows::Win32::System::WinRT::{RO_INIT_MULTITHREADED, RoInitialize}; -use windows::Win32::System::Com::{CoTaskMemAlloc, CoTaskMemFree}; -use windows_core::{GUID, HSTRING, IUnknown, Interface, h}; - -#[test] -fn test_property_value_create_int32_array_manual() -> windows::core::Result<()> { - // Initialize WinRT - unsafe { RoInitialize(RO_INIT_MULTITHREADED) }?; - - // Step 1: Get PropertyValue activation factory - let factory = unsafe { - windows::Win32::System::WinRT::RoGetActivationFactory::( - h!("Windows.Foundation.PropertyValue") - )? - }; - - // Step 2: Cast to IPropertyValueStatics - // GUID: {629BDBC8-D932-4FF4-96B9-8D96C5C1E858} - let statics_guid = GUID::from_u128(0x629BDBC8_D932_4FF4_96B9_8D96C5C1E858); - - let mut statics_ptr = std::ptr::null_mut(); - unsafe { factory.query(&statics_guid, &mut statics_ptr) }.ok()?; - let statics = unsafe { IUnknown::from_raw(statics_ptr) }; - - // Step 3: Prepare test array - let test_data = vec![1i32, 2, 3, 4, 5]; - let length = test_data.len() as u32; - let data_ptr = test_data.as_ptr(); - - println!("Test data: {:?}", test_data); - println!("Length: {}, Ptr: {:p}", length, data_ptr); - - // Step 4: Call CreateInt32Array - // Find the method index first using find_createint32array_index test - // For now, we'll try to determine it - - // IPropertyValueStatics methods start after IInspectable (6 methods) - // We need to find which index CreateInt32Array is at - - // Let's use static projection to verify the call works - use windows::Foundation::PropertyValue; - - let prop_value = PropertyValue::CreateInt32Array(&test_data)?; - println!("✓ Successfully created PropertyValue with static projection"); - println!(" Result: {:?}", prop_value); - - Ok(()) -} - -#[test] -fn test_property_value_create_int32_array_dynamic() -> windows::core::Result<()> { - // Initialize WinRT - unsafe { RoInitialize(RO_INIT_MULTITHREADED) }?; - - // Get factory and cast to statics - let factory = unsafe { - windows::Win32::System::WinRT::RoGetActivationFactory::( - h!("Windows.Foundation.PropertyValue") - )? - }; - - let statics_guid = GUID::from_u128(0x629BDBC8_D932_4FF4_96B9_8D96C5C1E858); - let mut statics_ptr = std::ptr::null_mut(); - unsafe { factory.query(&statics_guid, &mut statics_ptr) }.ok()?; - let statics = unsafe { IUnknown::from_raw(statics_ptr) }; - - // Prepare array - let test_data = vec![10i32, 20, 30, 40, 50]; - let length = test_data.len() as u32; - let data_ptr = test_data.as_ptr(); - - // Manual dynamic call - // Method signature: HRESULT CreateInt32Array(uint32_t length, int32_t* data, IInspectable** result) - - // Get vtable function pointer - let vtable_index = 8; // PLACEHOLDER - need to find actual index - - let method_ptr = unsafe { - let obj = statics.as_raw(); - let vtable_ptr = *(obj as *const *const *mut std::ffi::c_void); - *vtable_ptr.add(vtable_index) - }; - - // Call the method - let mut result: *mut std::ffi::c_void = std::ptr::null_mut(); - - let hr: windows_core::HRESULT = unsafe { - let method: extern "system" fn( - *mut std::ffi::c_void, // this - u32, // length - *const i32, // data - *mut *mut std::ffi::c_void, // out result - ) -> windows_core::HRESULT = std::mem::transmute(method_ptr); - - method(statics.as_raw(), length, data_ptr, &mut result) - }; - - if hr.is_ok() { - println!("✓ Dynamic call succeeded!"); - let result_inspectable = unsafe { windows_core::IInspectable::from_raw(result) }; - println!(" Result: {:?}", result_inspectable); - Ok(()) - } else { - println!("✗ Dynamic call failed: {:?}", hr); - Err(windows::core::Error::from(hr)) - } -} - -// Helper: Test just getting the factory -#[test] -fn test_get_property_value_factory() -> windows::core::Result<()> { - unsafe { RoInitialize(RO_INIT_MULTITHREADED) }?; - - let factory = unsafe { - windows::Win32::System::WinRT::RoGetActivationFactory::( - h!("Windows.Foundation.PropertyValue") - )? - }; - - println!("✓ Got PropertyValue factory: {:?}", factory); - - // Cast to IPropertyValueStatics - let statics_guid = GUID::from_u128(0x629BDBC8_D932_4FF4_96B9_8D96C5C1E858); - let mut statics_ptr = std::ptr::null_mut(); - unsafe { factory.query(&statics_guid, &mut statics_ptr) }.ok()?; - - if !statics_ptr.is_null() { - println!("✓ Successfully cast to IPropertyValueStatics"); - let statics = unsafe { IUnknown::from_raw(statics_ptr) }; - println!(" Statics interface: {:?}", statics); - } - - Ok(()) -} diff --git a/tools/winrt-meta/src/codegen/common.rs b/tools/winrt-meta/src/codegen/common.rs index 4d94e6a..0750009 100644 --- a/tools/winrt-meta/src/codegen/common.rs +++ b/tools/winrt-meta/src/codegen/common.rs @@ -606,9 +606,9 @@ pub(crate) fn to_snake_case(s: &str) -> String { // - The previous character is lowercase, OR // - The next character exists and is lowercase (handles "IID" -> "iid" but "IIDComponent" -> "iid_component") if i > 0 { - let prev_lower = chars[i - 1].is_lowercase(); + let prev_lower_or_digit = chars[i - 1].is_lowercase() || chars[i - 1].is_ascii_digit(); let next_lower = i + 1 < chars.len() && chars[i + 1].is_lowercase(); - if prev_lower || (next_lower && chars[i - 1].is_uppercase()) { + if prev_lower_or_digit || (next_lower && chars[i - 1].is_uppercase()) { result.push('_'); } } @@ -794,7 +794,7 @@ pub(crate) fn py_convert_return(expr: &str, return_type: Option<&TypeMeta>, is_a Some(TypeMeta::I64 | TypeMeta::U64) => format!("{}.to_i64()", expr), Some(TypeMeta::F32 | TypeMeta::F64) => format!("{}.to_f64()", expr), Some(TypeMeta::Bool) => format!("{}.to_bool()", expr), - Some(TypeMeta::Enum { .. }) => format!("{}.enum_value()", expr), + Some(TypeMeta::Enum { .. }) => format!("{}.to_number()", expr), Some(TypeMeta::RuntimeClass { name, .. }) if known_types.contains(name) => { format!("{}({})", name, expr) } diff --git a/tools/winrt-meta/src/codegen/python.rs b/tools/winrt-meta/src/codegen/python.rs index b02974e..48f56c7 100644 --- a/tools/winrt-meta/src/codegen/python.rs +++ b/tools/winrt-meta/src/codegen/python.rs @@ -12,7 +12,7 @@ use super::common::{ py_dynwinrt_type, py_generate_interface_registration, collect_used_generics_from_methods, collect_used_generics_from_class, collect_iface_type_imports, collect_type_imports, - to_snake_case, to_snake_case_filename, + to_snake_case, to_snake_case_filename, is_py_reserved, }; use super::py_method::{ generate_factory_method_invoke, generate_static_method_invoke, @@ -261,7 +261,12 @@ pub fn generate_enum(en: &TypeMeta) -> Option { out.push_str(" pass\n"); } else { for member in members { - out.push_str(&format!(" {} = {}\n", member.name, member.value)); + let member_name = if is_py_reserved(&member.name) { + format!("{}_", member.name) + } else { + member.name.clone() + }; + out.push_str(&format!(" {} = {}\n", member_name, member.value)); } } Some(out) @@ -746,7 +751,7 @@ pub fn generate_class(class: &ClassMeta, known_types: &HashSet, delegate ctor_name, class.name )); out.push_str(&format!( - " return {}(_IActivationFactory.method(6).call(DynWinRTValue.activation_factory('{}'), [])[0])\n", + " return {}(_IActivationFactory.method(6).invoke(DynWinRTValue.activation_factory('{}'), []))\n", class.name, class.full_name )); out.push('\n'); diff --git a/tools/winrt-meta/src/meta.rs b/tools/winrt-meta/src/meta.rs index e1bfadf..749662f 100644 --- a/tools/winrt-meta/src/meta.rs +++ b/tools/winrt-meta/src/meta.rs @@ -844,6 +844,19 @@ fn find_default_interface_iid(def: &reader::TypeDef, index: &reader::Index) -> S String::new() } +fn find_default_interface_type(def: &reader::TypeDef, index: &reader::Index) -> Option { + for iface_impl in def.interface_impls() { + if !iface_impl.has_attribute("DefaultAttribute") { + continue; + } + let iface_ty = iface_impl.interface(&[]); + if let windows_metadata::Type::Name(tn) = &iface_ty { + return Some(resolve_named_type(&tn.namespace, &tn.name, &tn.generics, index)); + } + } + None +} + fn parse_enum_def(def: &reader::TypeDef) -> TypeMeta { let mut members = Vec::new(); for field in def.fields() { @@ -999,6 +1012,11 @@ fn resolve_named_type( return parse_enum_def(&def); } if extends.namespace() == "System" && extends.name() == "Object" { + if let Some(default_type) = find_default_interface_type(&def, index) { + if default_type.is_async() { + return default_type; + } + } let default_iid = find_default_interface_iid(&def, index); return TypeMeta::RuntimeClass { namespace: namespace.to_string(), @@ -1128,6 +1146,22 @@ mod tests { assert!(names.contains(&"GetWithOptionAsync")); assert!(names.contains(&"SendRequestWithOptionAsync")); } + + #[test] + fn test_datawriter_store_async_maps_to_async_operation() { + let class = parse_class(WINDOWS_WINMD, "Windows.Storage.Streams", "DataWriter").unwrap(); + let default_iface = class.default_interface.as_ref().unwrap(); + let store_async = default_iface.methods.iter().find(|m| m.name == "StoreAsync").unwrap(); + assert_eq!(store_async.return_type, Some(TypeMeta::AsyncOperation(Box::new(TypeMeta::U32)))); + } + + #[test] + fn test_datareader_load_async_maps_to_async_operation() { + let class = parse_class(WINDOWS_WINMD, "Windows.Storage.Streams", "DataReader").unwrap(); + let default_iface = class.default_interface.as_ref().unwrap(); + let load_async = default_iface.methods.iter().find(|m| m.name == "LoadAsync").unwrap(); + assert_eq!(load_async.return_type, Some(TypeMeta::AsyncOperation(Box::new(TypeMeta::U32)))); + } } #[cfg(test)] @@ -1184,4 +1218,3 @@ mod iface_tests { } } -