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/tests/e2e/e2e_specs.json b/tests/e2e/e2e_specs.json index fed846f2..9f1f2205 100644 --- a/tests/e2e/e2e_specs.json +++ b/tests/e2e/e2e_specs.json @@ -518,11 +518,22 @@ "instantiate": { "kind": "none" }, "checks": [ { "kind": "async_memory_roundtrip", "member": "roundtrip", "write_value": 42 }, + { "kind": "async_progress_promise", "member": "write_async", "langs": ["ts"] }, { "kind": "data_stream_scalar_roundtrip", "member": "roundtrip", "langs": ["py"] }, { "kind": "data_stream_constructor_roundtrip", "member": "__init__", "langs": ["py"] }, { "kind": "can_cast_non_object", "member": "_dynwinrt_can_cast", "langs": ["py"] } ] }, + { + "id": "async_progress_http_cancellation", + "namespace": "Windows.Web.Http", + "class": "HttpClient", + "langs": ["ts"], + "instantiate": { "kind": "none" }, + "checks": [ + { "kind": "async_progress_cancel_error", "member": "get_string_async" } + ] + }, { "id": "nested_struct_runtime", "namespace": "Windows.Graphics.DirectX.Direct3D11", diff --git a/tests/e2e/e2e_specs.schema.json b/tests/e2e/e2e_specs.schema.json index 6e683d9b..8d167aaa 100644 --- a/tests/e2e/e2e_specs.schema.json +++ b/tests/e2e/e2e_specs.schema.json @@ -77,6 +77,8 @@ "constructor_raises_type_error", "cross_class_chain", "async_memory_roundtrip", + "async_progress_promise", + "async_progress_cancel_error", "async_cancellation", "property_set_equals", "vector_view_access", diff --git a/tests/e2e/runners/ts_progress_promise_child.mjs b/tests/e2e/runners/ts_progress_promise_child.mjs new file mode 100644 index 00000000..11adb937 --- /dev/null +++ b/tests/e2e/runners/ts_progress_promise_child.mjs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { createRequire } from 'node:module' +import { resolve } from 'node:path' +import { setImmediate } from 'node:timers/promises' + +const [name, generatedDir, runtimePath] = process.argv.slice(2) +if (!name || !generatedDir || !runtimePath) { + throw new Error('Usage: ts_progress_promise_child.mjs ') +} +const require = createRequire(import.meta.url) +const g = require(resolve(generatedDir, 'index.js')) +const runtime = require(resolve(runtimePath)) +runtime.roInitialize(1) + +async function streamPromises() { + const owned = [] + const own = (value) => { + owned.push(value) + return value + } + const stream = own(new g.InMemoryRandomAccessStream()) + const bytes = Buffer.alloc(4 * 1024 * 1024, 0x5a) + const buffer = own(g.Buffer.fromBuffer(bytes)) + try { + const progress = [] + const op = stream.writeAsync(buffer) + const promise = op.progress((value) => progress.push(value)).toPromise() + assert.deepEqual( + await Promise.all([op, promise, op.toPromise(), op.then((value) => value)]), + Array(4).fill(bytes.length), + ) + assert.equal(promise, op) + assert.equal(op.toPromise(), promise) + op.cancel() + assert.equal(await op.toPromise(), bytes.length) + await setImmediate() + // Stock memory writes can finish before progress registration. + assert.ok(progress.every((value) => Number.isInteger(value) && value >= 0 && value <= bytes.length)) + + stream.seek(0n) + const destination = own(g.Buffer.fromBuffer(Buffer.alloc(bytes.length))) + const read = stream.readAsync(destination, bytes.length, g.InputStreamOptions.None) + const readPromise = read.toPromise() + const [result, sameResult] = await Promise.all([read, readPromise, read.toPromise()]) + own(result) + assert.equal(result, sameResult, 'convert the native buffer into one projected wrapper') + assert.equal(readPromise, read) + assert.equal(await read.toPromise(), result) + assert.deepEqual(result.toBuffer(), bytes) + + stream.seek(0n) + assert.equal(await stream.writeAsync(buffer), bytes.length, 'ordinary await remains supported') + stream.close() + assert.throws(() => stream.writeAsync(buffer), /0x80000013|closed/i) + const aborted = new AbortController() + const reason = new Error('already aborted') + aborted.abort(reason) + const preAborted = stream.writeAsync(buffer, aborted.signal) + const rejected = preAborted.progress(() => assert.fail('unexpected progress')).toPromise() + assert.equal(rejected, preAborted) + await assert.rejects(rejected, (error) => error === reason) + await assert.rejects(preAborted.toPromise(), (error) => error === reason) + preAborted.cancel() + } finally { + for (const value of owned.reverse()) g.releaseProjected(value) + } + console.log(`progress-stream-ok arch=${process.arch} bytes=${bytes.length}`) +} + +async function httpCancellation() { + const waiting = new Map() + const payload = 'dynwinrt-progress-'.repeat(16_384) + const server = createServer((request, response) => { + if (request.url === '/error') { + response.writeHead(404, { 'Content-Length': 0, Connection: 'close' }) + response.end() + } else if (request.url === '/progress') { + response.writeHead(200, { 'Content-Length': Buffer.byteLength(payload), Connection: 'close' }) + let offset = 0 + const send = () => { + if (response.destroyed) return + if (offset === payload.length) { + response.end() + return + } + const end = Math.min(offset + 8192, payload.length) + response.write(payload.slice(offset, end)) + offset = end + setTimeout(send, 5) + } + send() + } else { + const accepted = waiting.get(request.url) + assert.ok(accepted, `unexpected localhost request ${request.url}`) + accepted() + // Hold the response open so cancellation cannot race a successful completion. + } + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const client = new g.HttpClient() + const uris = [] + const uri = (path) => { + const value = new g.Uri(`http://127.0.0.1:${server.address().port}${path}`) + uris.push(value) + return value + } + try { + const progress = [] + const op = client.getStringAsync(uri('/progress')).progress((value) => progress.push(value)) + const promise = op.toPromise() + assert.deepEqual(await Promise.all([op, promise, op.toPromise()]), Array(3).fill(payload)) + assert.equal(promise, op) + await setImmediate() + assert.ok( + progress.some( + (value) => value.bytesReceived > 0n && value.totalBytesToReceive === BigInt(Buffer.byteLength(payload)), + ), + ) + assert.ok(progress.every((value) => typeof value.stage === 'number' && typeof value.retries === 'number')) + op.cancel() + assert.equal(await op.toPromise(), payload) + + const headers = client.getAsync(uri('/progress'), g.HttpCompletionOption.ResponseHeadersRead) + const [response, sameResponse] = await Promise.all([headers, headers.toPromise(), headers.toPromise()]) + assert.equal(response, sameResponse, 'overload consumers share one projected response') + try { + const content = response.content + try { + const body = content.readAsStringAsync() + const bodyPromise = body.progress((value) => assert.equal(typeof value, 'bigint')).toPromise() + assert.equal(bodyPromise, body) + assert.deepEqual(await Promise.all([body, bodyPromise, body.toPromise()]), Array(3).fill(payload)) + await setImmediate() + } finally { + g.releaseProjected(content) + } + } finally { + response.close() + g.releaseProjected(response) + } + + const download = client.getBufferAsync(uri('/progress')) + const [buffer, sameBuffer] = await Promise.all([download, download.toPromise(), download.toPromise()]) + try { + assert.equal(buffer, sameBuffer, 'buffer consumers share one projected result') + assert.equal(buffer.toBuffer().toString('utf8'), payload) + } finally { + g.releaseProjected(buffer) + } + + for (const mode of ['cancel', 'abort']) { + const path = `/${mode}` + const accepted = new Promise((resolve) => waiting.set(path, resolve)) + const controller = new AbortController() + const reason = new Error('aborted during native request') + const op = client.getStringAsync(uri(path), controller.signal) + const consumers = Promise.allSettled([op, op.toPromise(), op.toPromise()]) + await accepted + if (mode === 'cancel') op.cancel() + else controller.abort(reason) + const results = await consumers + assert.ok(results.every((result) => result.status === 'rejected')) + assert.equal(results[0].reason, results[1].reason) + assert.equal(results[1].reason, results[2].reason) + if (mode === 'abort') assert.equal(results[0].reason, reason) + // GetResults on a canceled stock operation can also report E_ILLEGAL_METHOD_CALL. + else assert.match(results[0].reason.message, /0x80004004|0x800704c7|0x8000000e/i) + } + + const failed = client.getStringAsync(uri('/error')) + const errors = await Promise.allSettled([failed, failed.toPromise(), failed.toPromise()]) + assert.ok(errors.every((result) => result.status === 'rejected')) + assert.equal(errors[0].reason, errors[1].reason) + assert.equal(errors[1].reason, errors[2].reason) + assert.match(errors[0].reason.message, /0x80190194|404/i) + } finally { + client.close() + g.releaseProjected(client) + for (const value of uris.reverse()) g.releaseProjected(value) + server.closeAllConnections() + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } + console.log(`progress-cancel-error-ok arch=${process.arch}`) +} + +if (name === 'async_progress_promise') await streamPromises() +else if (name === 'async_progress_cancel_error') await httpCancellation() +else throw new Error(`Unknown progress Promise regression: ${name}`) diff --git a/tests/e2e/runners/ts_runner.ts b/tests/e2e/runners/ts_runner.ts index 8d173384..4976a427 100644 --- a/tests/e2e/runners/ts_runner.ts +++ b/tests/e2e/runners/ts_runner.ts @@ -231,7 +231,12 @@ async function runIssueRegression( timedOut: boolean; }> { const childPath = fileURLToPath( - new URL("./ts_issue_regression_child.mjs", import.meta.url), + new URL( + name === "async_progress_promise" || name === "async_progress_cancel_error" + ? "./ts_progress_promise_child.mjs" + : "./ts_issue_regression_child.mjs", + import.meta.url, + ), ); const child = spawn( process.execPath, @@ -959,7 +964,9 @@ async function runCheck( if (chainOk) cr.pass = true; } else if ( kind === "device_information_async_collection" || - kind === "bitmap_encoder_async_create" + kind === "bitmap_encoder_async_create" || + kind === "async_progress_promise" || + kind === "async_progress_cancel_error" ) { const child = await runIssueRegression(kind, generatedDir, runtimePath); if (child.timedOut) { diff --git a/tools/dynwinrt-codegen/npm/README.md b/tools/dynwinrt-codegen/npm/README.md index 06a8d1af..0a732976 100644 --- a/tools/dynwinrt-codegen/npm/README.md +++ b/tools/dynwinrt-codegen/npm/README.md @@ -73,7 +73,8 @@ For each WinRT class, the codegen emits: - **A JavaScript-backed `IElementFactory.create()` helper** for WinUI ItemsRepeater realization and recycling - **Promise-based async operations**, with `.progress(cb)` on operations that - expose WinRT progress + expose WinRT progress. Their `.toPromise()` returns the same projected Promise + on every call, equivalent to awaiting the operation directly - **Generic collections** (`IVector`, `IMap`, `IIterable`) - **Creatable observable vectors** that expose both `IObservableVector` events and `IVector` mutation helpers diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs index e6edbf3d..f883a25f 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/declarations.rs @@ -48,7 +48,7 @@ pub fn render(file: &ProjectedFile) -> String { " /** Register a callback for progress updates. Returns `this` for chaining. */\n", ); body.push_str(" progress(cb: (value: P) => void): WinRTAsyncWithProgress;\n"); - body.push_str(" /** Get the underlying Promise (equivalent to awaiting directly). */\n"); + body.push_str(" /** Get the same underlying Promise on every call (equivalent to awaiting directly). */\n"); body.push_str(" toPromise(): Promise;\n"); body.push_str(" /** Cancel the underlying WinRT async operation. */\n"); body.push_str(" cancel(): void;\n"); diff --git a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/helpers.rs b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/helpers.rs index 200295c7..2371733b 100644 --- a/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/helpers.rs +++ b/tools/dynwinrt-codegen/src/codegen/winrt/javascript/render/javascript/helpers.rs @@ -47,7 +47,7 @@ pub(super) fn emit_with_progress_body( out.push_str(" _rej.catch(() => {});\n"); out.push_str(" return Object.assign(_rej, {\n"); out.push_str(" progress(_cb) { return this; },\n"); - out.push_str(" toPromise() { const _p = Promise.reject(signal.reason); _p.catch(() => {}); return _p; },\n"); + out.push_str(" toPromise() { return _rej; },\n"); out.push_str(" cancel() {},\n"); out.push_str(" });\n"); out.push_str(" }\n"); @@ -74,7 +74,7 @@ pub(super) fn emit_with_progress_body( } else { out.push_str(" progress(cb) { _op.onProgress(cb); return this; },\n"); } - out.push_str(" toPromise() { return _wrap(_op.toPromise()); },\n"); + out.push_str(" toPromise() { return _promise; },\n"); out.push_str(" cancel() { try { _op.cancel(); } catch (_ce) { /* cancel after completion is a no-op per WinRT spec */ } },\n"); out.push_str(" });\n"); } diff --git a/tools/dynwinrt-codegen/tests/fixtures/javascript_progress_promise.cjs b/tools/dynwinrt-codegen/tests/fixtures/javascript_progress_promise.cjs new file mode 100644 index 00000000..e7d808a7 --- /dev/null +++ b/tools/dynwinrt-codegen/tests/fixtures/javascript_progress_promise.cjs @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +const assert = require('node:assert/strict'); +const vm = require('node:vm'); + +let native; +let invocations = 0; +let invokedSlot; +const signature = { + addIn() { + return this; + }, + addOut() { + return this; + }, +}; +const iface = { + addMethod() { + return this; + }, + method(slot) { + return { + invoke() { + invocations++; + invokedSlot = slot; + return native; + }, + }; + }, +}; +const runtime = { + WinGuid: { parse: (value) => value }, + DynWinRtType: { + registerInterface() { + return iface; + }, + u32() {}, + object() {}, + iAsyncActionWithProgress() {}, + iAsyncOperationWithProgress() {}, + }, + DynWinRtMethodSig: function () { + return signature; + }, + DynWinRtValue: { u32: (value) => value }, +}; +const generated = {}; +vm.runInNewContext(generatedSource, { + exports: generated, + Promise, + AbortSignal, + require(name) { + if (name === '@microsoft/dynwinrt') return runtime; + assert.equal(name, './lifetime.js'); + return {}; + }, +}); +const probe = Object.create(generated.ProgressProbe.prototype); +probe._obj = { + cast() { + return this; + }, +}; + +function nativeOperation() { + let resolve, reject, callback; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + const state = { + registrations: 0, + results: 0, + progress: 0, + cancels: 0, + error: new Error('native cancellation'), + toPromise() { + assert.equal(++this.registrations, 1, 'native completion registered twice'); + return promise; + }, + onProgress(cb) { + callback = cb; + }, + report(value) { + callback({ + toNumber() { + state.progress++; + return value; + }, + }); + }, + complete() { + resolve({ + toNumber() { + state.results++; + return 42; + }, + }); + }, + fail(error) { + reject(error); + }, + cancel() { + this.cancels++; + reject(this.error); + }, + }; + return state; +} + +function trackedSignal() { + const controller = new AbortController(); + const signal = controller.signal; + let added = 0, + removed = 0; + const add = signal.addEventListener.bind(signal); + const remove = signal.removeEventListener.bind(signal); + signal.addEventListener = (...args) => { + added++; + add(...args); + }; + signal.removeEventListener = (...args) => { + removed++; + remove(...args); + }; + return { controller, signal, counts: () => [added, removed] }; +} + +async function main() { + const cases = [ + ['runOperation', [], 6, false], + ['runAction', [], 7, true], + ['runOverloadedOperation', [], 8, false], + ['runOverloadedOperation', [7], 9, false], + ['runOverloadedAction', [], 10, true], + ['runOverloadedAction', [7], 11, true], + ['runCrossOperation', [], 12, false], + ['runCrossOperation', [7], 6, false], + ['runCrossAction', [], 13, true], + ['runCrossAction', [7], 7, true], + ]; + for (const [name, args, slot, action] of cases) { + const start = (signal) => probe[name](...args, signal); + native = nativeOperation(); + const observed = trackedSignal(); + const before = invocations; + const op = start(observed.signal); + assert.equal(invocations, before + 1); + assert.equal(invokedSlot, slot); + assert.equal(native.registrations, 1, 'creation must start completion exactly once'); + const progress = []; + const chained = op.progress((value) => progress.push(value)); + assert.equal(chained, op); + const promise = chained.toPromise(); + assert.equal(promise, op); + assert.equal(op.toPromise(), promise); + const detachedToPromise = op.toPromise; + assert.equal(detachedToPromise(), promise); + const together = Promise.all([op, promise, op.then((value) => value), op.toPromise()]); + native.report(9); + native.complete(); + assert.deepEqual(await together, Array(4).fill(action ? undefined : 42)); + assert.equal(await op, action ? undefined : 42); + assert.equal(await op.toPromise(), action ? undefined : 42); + assert.equal(native.registrations, 1); + assert.equal(native.results, action ? 0 : 1, 'project the final result only once'); + assert.equal(native.progress, 1); + assert.deepEqual(progress, [9]); + assert.deepEqual(observed.counts(), [1, 1], `${name}: remove the abort listener once`); + op.cancel(); + assert.equal(native.cancels, 1); + assert.equal(await promise, action ? undefined : 42); + + for (const failure of ['native', 'cancel', 'progress-cancel', 'abort']) { + native = nativeOperation(); + const observed = trackedSignal(); + const reason = new Error(failure); + const op = start(observed.signal); + const consumers = Promise.allSettled([op, op.toPromise(), op.toPromise(), op.then((value) => value)]); + if (failure === 'native') native.fail(reason); + if (failure === 'cancel') op.cancel(); + if (failure === 'progress-cancel') { + op.progress(() => op.cancel()); + native.report(1); + } + if (failure === 'abort') observed.controller.abort(reason); + const expected = failure.includes('cancel') ? native.error : reason; + for (const result of await consumers) { + assert.equal(result.status, 'rejected'); + assert.equal(result.reason, expected); + } + assert.equal(native.registrations, 1); + assert.equal(native.results, 0); + assert.equal(native.cancels, failure === 'native' ? 0 : 1); + assert.deepEqual(observed.counts(), [1, 1]); + } + + const aborted = trackedSignal(); + const reason = new Error('already aborted'); + aborted.controller.abort(reason); + const beforeAbort = invocations; + const opAborted = start(aborted.signal); + assert.equal(opAborted.toPromise(), opAborted); + assert.equal( + opAborted.progress(() => assert.fail('unexpected progress')), + opAborted, + ); + opAborted.cancel(); + for (const result of await Promise.allSettled([opAborted, opAborted.toPromise(), opAborted.toPromise()])) { + assert.equal(result.status, 'rejected'); + assert.equal(result.reason, reason); + } + assert.equal(invocations, beforeAbort, 'pre-aborted signals must not invoke native code'); + assert.deepEqual(aborted.counts(), [0, 0]); + } +} + +const timeout = setTimeout(() => { + throw new Error('progress Promise regression timed out'); +}, 10_000); +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => clearTimeout(timeout)); diff --git a/tools/dynwinrt-codegen/tests/javascript_progress_promise_test.rs b/tools/dynwinrt-codegen/tests/javascript_progress_promise_test.rs new file mode 100644 index 00000000..40e7664d --- /dev/null +++ b/tools/dynwinrt-codegen/tests/javascript_progress_promise_test.rs @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +use dynwinrt_codegen::codegen::{project, projected::ProjectedFile, render_dts, render_js}; +use dynwinrt_codegen::meta::{ClassMeta, InterfaceMeta, MethodMeta, ParamDirection, ParamMeta}; +use dynwinrt_codegen::types::TypeMeta; + +fn method(name: &str, slot: usize, action: bool, parameter: bool) -> MethodMeta { + MethodMeta { + name: name.into(), + raw_name: name.into(), + vtable_index: slot, + params: if parameter { + vec![ParamMeta { + name: "value".into(), + typ: TypeMeta::U32, + direction: ParamDirection::In, + }] + } else { + Vec::new() + }, + return_type: Some(if action { + TypeMeta::AsyncActionWithProgress(Box::new(TypeMeta::U32)) + } else { + TypeMeta::AsyncOperationWithProgress(Box::new(TypeMeta::U32), Box::new(TypeMeta::U32)) + }), + ..Default::default() + } +} + +fn progress_probe() -> ProjectedFile { + let class = ClassMeta { + name: "ProgressProbe".into(), + namespace: "Tests".into(), + full_name: "Tests.ProgressProbe".into(), + default_interface: Some(InterfaceMeta { + name: "IProgressProbe".into(), + iid: "11111111-1111-1111-1111-111111111111".into(), + methods: vec![ + method("RunOperation", 6, false, false), + method("RunAction", 7, true, false), + method("RunOverloadedOperation", 8, false, false), + method("RunOverloadedOperation", 9, false, true), + method("RunOverloadedAction", 10, true, false), + method("RunOverloadedAction", 11, true, true), + method("RunCrossOperation", 12, false, false), + method("RunCrossAction", 13, true, false), + ], + ..Default::default() + }), + required_interfaces: vec![InterfaceMeta { + name: "IProgressProbe2".into(), + iid: "22222222-2222-2222-2222-222222222222".into(), + methods: vec![ + method("RunCrossOperation", 6, false, true), + method("RunCrossAction", 7, true, true), + ], + ..Default::default() + }], + ..Default::default() + }; + project::project_class( + &Default::default(), + &class, + &HashSet::from(["ProgressProbe".into()]), + &HashSet::new(), + &HashSet::new(), + &HashMap::new(), + &HashMap::new(), + &HashMap::new(), + ) +} + +#[test] +fn generated_progress_promises_register_native_completion_once() { + let projected = progress_probe(); + let source = format!( + "const generatedSource = {};\n{}", + serde_json::to_string(&render_js::render(&projected)).unwrap(), + include_str!("fixtures/javascript_progress_promise.cjs"), + ); + let mut child = Command::new("node") + .arg("-") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Node is required for generated progress Promise execution tests"); + child + .stdin + .take() + .unwrap() + .write_all(source.as_bytes()) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} + +#[test] +fn generated_progress_promise_declarations_pass_strict_tsc() { + let tsc = std::env::var_os("DYNWINRT_TSC") + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join(r"..\..\bindings\js\node_modules\typescript\bin\tsc") + }); + if !tsc.is_file() { + assert_ne!( + std::env::var("DYNWINRT_REQUIRE_TSC").as_deref(), + Ok("1"), + "the generated progress Promise declarations require TypeScript" + ); + eprintln!("Skipping: repository TypeScript compiler is unavailable"); + return; + } + let directory = std::env::temp_dir().join(format!( + "dynwinrt-progress-promise-types-{}", + std::process::id() + )); + fs::create_dir(&directory).unwrap(); + fs::write( + directory.join("ProgressProbe.d.ts"), + render_dts::render(&progress_probe()), + ) + .unwrap(); + let runtime = directory + .join("node_modules") + .join("@microsoft") + .join("dynwinrt"); + fs::create_dir_all(&runtime).unwrap(); + fs::write( + runtime.join("package.json"), + r#"{"name":"@microsoft/dynwinrt","types":"index.d.ts"}"#, + ) + .unwrap(); + fs::write( + runtime.join("index.d.ts"), + "export declare class WinGuid {}\nexport declare class DynWinRtValue {}\nexport declare class DynWinRtType {}\n", + ) + .unwrap(); + fs::write( + directory.join("consumer.ts"), + r#"import { ProgressProbe } from './ProgressProbe.js'; +declare const probe: ProgressProbe; +declare const signal: AbortSignal; + +async function consume() { + const operation = probe.runOperation(signal); + const number: number = await operation; + const promise: Promise = operation.toPromise(); + const chained: typeof operation = operation.progress(value => { const n: number = value; }); + const together: number[] = await Promise.all([operation, promise, chained.toPromise()]); + const action: Promise = probe.runAction(signal).progress(value => { const n: number = value; }).toPromise(); + const nothing: void = await action; + const short: Promise = probe.runOverloadedOperation(signal).toPromise(); + const long: Promise = probe.runOverloadedOperation(7, signal).toPromise(); + const shortAction: Promise = probe.runOverloadedAction(signal).toPromise(); + const longAction: Promise = probe.runOverloadedAction(7, signal).toPromise(); + const cross: Promise = probe.runCrossOperation(7, signal).toPromise(); + const crossAction: Promise = probe.runCrossAction(7, signal).toPromise(); + operation.cancel(); + // @ts-expect-error Progress is projected to a number, not a raw native value. + operation.progress((value: string) => {}); + // @ts-expect-error Action-with-progress resolves to void. + const invalid: Promise = probe.runAction().toPromise(); +} +"#, + ) + .unwrap(); + let output = Command::new("node") + .arg(tsc) + .args([ + "--noEmit", + "--strict", + "--target", + "ES2022", + "--module", + "Node16", + "--moduleResolution", + "Node16", + "consumer.ts", + ]) + .current_dir(&directory) + .output() + .expect("run TypeScript on generated progress Promise declarations"); + fs::remove_dir_all(directory).unwrap(); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +}