From 5e651272d4d763b727336b552c3eec6db2bfe756 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 5 Oct 2025 23:24:53 +0200 Subject: [PATCH 1/3] web: Require es2022 for selfhosted This makes it possible to use top-level await expressions. --- web/packages/selfhosted/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/selfhosted/tsconfig.json b/web/packages/selfhosted/tsconfig.json index 84307e98a7ed..95e0a6d91de8 100644 --- a/web/packages/selfhosted/tsconfig.json +++ b/web/packages/selfhosted/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@tsconfig/strictest/tsconfig.json", "compilerOptions": { "composite": true, - "module": "es2020", + "module": "es2022", "moduleResolution": "node", "target": "es2021", "rootDir": ".", From 92b7bf3e4f4c8ae05e5e622af414d61991b85869 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 5 Oct 2025 23:29:26 +0200 Subject: [PATCH 2/3] web: Add test-specific methods to control ticking --- .../core/src/internal/player/impl_v1.ts | 4 ++++ .../core/src/internal/player/inner.tsx | 16 +++++++++++++++ web/packages/core/src/public/player/v1.ts | 2 ++ web/src/builder.rs | 7 +++++++ web/src/lib.rs | 20 +++++++++++++------ 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/web/packages/core/src/internal/player/impl_v1.ts b/web/packages/core/src/internal/player/impl_v1.ts index 3dce7aa9a13c..b112cabbfbc0 100644 --- a/web/packages/core/src/internal/player/impl_v1.ts +++ b/web/packages/core/src/internal/player/impl_v1.ts @@ -41,6 +41,10 @@ export class PlayerV1Impl implements PlayerV1 { this.#inner.play(); } + tick(timestamp: number): void { + this.#inner.tick(timestamp); + } + get isPlaying(): boolean { return this.#inner.isPlaying; } diff --git a/web/packages/core/src/internal/player/inner.tsx b/web/packages/core/src/internal/player/inner.tsx index a5848340ac55..fca04f8ef3eb 100644 --- a/web/packages/core/src/internal/player/inner.tsx +++ b/web/packages/core/src/internal/player/inner.tsx @@ -190,6 +190,10 @@ export class InnerPlayer { // The effective config loaded upon `.load()`. public loadedConfig?: URLLoadOptions | DataLoadOptions; + // Whether content should tick automatically or + // require manual calls to tick(). + public tickAutomatically: boolean = true; + private swfUrl?: URL; private instance: RuffleHandle | null; private newZipWriter: (() => ZipWriter) | null; @@ -652,6 +656,7 @@ export class InnerPlayer { this.newZipWriter = zipWriterClass; configureBuilder(builder, this.loadedConfig || {}); builder.setVolume(this.volumeSettings.get_volume()); + builder.setTickAutomatically(this.tickAutomatically); if (this.loadedConfig?.fontSources) { for (const url of this.loadedConfig.fontSources) { @@ -935,6 +940,11 @@ export class InnerPlayer { this.loadedConfig.backgroundColor; } + this.tickAutomatically = + "__tickAutomatically" in options + ? options["__tickAutomatically"] === true + : true; + await this.ensureFreshInstance(); if ("url" in options) { @@ -972,6 +982,12 @@ export class InnerPlayer { } } + tick(timestamp: number): void { + if (this.instance) { + this.instance.tick_pub(timestamp); + } + } + /** * Whether this player is currently playing. * diff --git a/web/packages/core/src/public/player/v1.ts b/web/packages/core/src/public/player/v1.ts index c94a6362d941..e49ff1486222 100644 --- a/web/packages/core/src/public/player/v1.ts +++ b/web/packages/core/src/public/player/v1.ts @@ -189,4 +189,6 @@ export interface PlayerV1 { * @returns Any value returned by the callback. */ callExternalInterface(name: string, ...args: unknown[]): unknown; + + tick(timestamp: number): void; } diff --git a/web/src/builder.rs b/web/src/builder.rs index 998f46233ed5..6392139f087a 100644 --- a/web/src/builder.rs +++ b/web/src/builder.rs @@ -66,6 +66,7 @@ pub struct RuffleInstanceBuilder { pub(crate) gamepad_button_mapping: HashMap, pub(crate) url_rewrite_rules: Vec<(RegExp, String)>, pub(crate) scrolling_behavior: ScrollingBehavior, + pub(crate) tick_automatically: bool, } impl Default for RuffleInstanceBuilder { @@ -105,6 +106,7 @@ impl Default for RuffleInstanceBuilder { gamepad_button_mapping: HashMap::new(), url_rewrite_rules: vec![], scrolling_behavior: ScrollingBehavior::Smart, + tick_automatically: true, } } } @@ -348,6 +350,11 @@ impl RuffleInstanceBuilder { }; } + #[wasm_bindgen(js_name = "setTickAutomatically")] + pub fn set_tick_automatically(&mut self, tick_automatically: bool) { + self.tick_automatically = tick_automatically; + } + // TODO: This should be split into two methods that either load url or load data // Right now, that's done immediately afterwards in TS pub async fn build(&self, parent: HtmlElement, js_player: JavascriptPlayer) -> Promise { diff --git a/web/src/lib.rs b/web/src/lib.rs index 03f4a7fbf9c6..28ac2e5cce3c 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -476,6 +476,10 @@ impl RuffleHandle { pub fn is_wasm_simd_used() -> bool { cfg!(target_feature = "simd128") } + + pub fn tick_pub(&self, timestamp:f64) { + self.tick(timestamp); + } } impl RuffleHandle { @@ -551,11 +555,13 @@ impl RuffleHandle { let shadow_host = Self::get_shadow_host(&parent); Self::set_up_focus_management(ruffle, shadow_host.unwrap_or(parent))?; - // Create the animation frame closure. ruffle.with_instance_mut(|instance| { - instance.animation_handler = Some(Closure::new(move |timestamp| { - ruffle.tick(timestamp); - })); + // Create the animation frame closure. + if config.tick_automatically { + instance.animation_handler = Some(Closure::new(move |timestamp| { + ruffle.tick(timestamp); + })); + } // Create mouse move handler. instance.mouse_move_callback = Some(JsCallback::register( @@ -852,8 +858,10 @@ impl RuffleHandle { )); })?; - // Set initial timestamp and do initial tick to start animation loop. - ruffle.tick(0.0); + if config.tick_automatically { + // Set initial timestamp and do initial tick to start animation loop. + ruffle.tick(0.0); + } Ok(ruffle) } From 7b63475084d1fccda5d0cdd23ccf7580bc6224a2 Mon Sep 17 00:00:00 2001 From: Kamil Jarosz Date: Sun, 5 Oct 2025 23:41:00 +0200 Subject: [PATCH 3/3] web: Add possibility to run SWF tests on web This patch adds possibility to run SWF tests from //tests/tests/swfs on web. Currently web-specific renderers like webgl and canvas are largely being untested. This will make it possible in the future to test them properly and make sure they work the same way as wgpu. --- web/package-lock.json | 529 ++++++++++++++++++ web/packages/selfhosted/package.json | 2 + .../selfhosted/test/swf_tests/index.html | 41 ++ .../selfhosted/test/swf_tests/swf_tests.json5 | 9 + .../selfhosted/test/swf_tests/swf_tests.ts | 403 +++++++++++++ web/packages/selfhosted/test/utils.ts | 10 + web/packages/selfhosted/wdio.conf.ts | 2 + 7 files changed, 996 insertions(+) create mode 100644 web/packages/selfhosted/test/swf_tests/index.html create mode 100644 web/packages/selfhosted/test/swf_tests/swf_tests.json5 create mode 100644 web/packages/selfhosted/test/swf_tests/swf_tests.ts diff --git a/web/package-lock.json b/web/package-lock.json index 35bc2a58ad83..dbb60f0e3819 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -554,6 +554,17 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/@emnapi/runtime": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", + "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@epic-web/invariant": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", @@ -1257,6 +1268,456 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.5.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.2.0.tgz", @@ -6678,6 +7139,16 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -13697,6 +14168,49 @@ "node": ">=8" } }, + "node_modules/sharp": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -13875,6 +14389,19 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -16999,6 +17526,8 @@ "@types/chai": "^5.2.2", "@types/chai-html": "^3.0.0", "json5": "^2.2.3", + "sharp": "^0.34.4", + "smol-toml": "^1.4.2", "ts-node": "^10.9.2", "webpack": "^5.102.0", "webpack-cli": "^6.0.1", diff --git a/web/packages/selfhosted/package.json b/web/packages/selfhosted/package.json index 05dfb88d95d9..27ce10116232 100644 --- a/web/packages/selfhosted/package.json +++ b/web/packages/selfhosted/package.json @@ -25,6 +25,8 @@ "@types/chai": "^5.2.2", "@types/chai-html": "^3.0.0", "json5": "^2.2.3", + "sharp": "^0.34.4", + "smol-toml": "^1.4.2", "ts-node": "^10.9.2", "webpack": "^5.102.0", "webpack-cli": "^6.0.1", diff --git a/web/packages/selfhosted/test/swf_tests/index.html b/web/packages/selfhosted/test/swf_tests/index.html new file mode 100644 index 000000000000..cc50045cc8ed --- /dev/null +++ b/web/packages/selfhosted/test/swf_tests/index.html @@ -0,0 +1,41 @@ + + + + + + + + +
+ + + + + diff --git a/web/packages/selfhosted/test/swf_tests/swf_tests.json5 b/web/packages/selfhosted/test/swf_tests/swf_tests.json5 new file mode 100644 index 000000000000..92612ad107d1 --- /dev/null +++ b/web/packages/selfhosted/test/swf_tests/swf_tests.json5 @@ -0,0 +1,9 @@ +{ + // Tests are loaded from the //tests/tests/swfs directory. + // This file contains web-specific configuration of those tests. + // Please order tests alphabetically. + "tests": { + "visual/edittext/edittext_background_basic": {}, + "visual/edittext/edittext_border_basic": {}, + }, +} diff --git a/web/packages/selfhosted/test/swf_tests/swf_tests.ts b/web/packages/selfhosted/test/swf_tests/swf_tests.ts new file mode 100644 index 000000000000..2afef157e8a9 --- /dev/null +++ b/web/packages/selfhosted/test/swf_tests/swf_tests.ts @@ -0,0 +1,403 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { use } from "chai"; +import chaiHtml from "chai-html"; +import { promises as fs } from "fs"; +import * as path from "path"; +import json5 from "json5"; +import * as toml from "smol-toml"; +import { + getAllTraceOutput, + hideHardwareAccelerationModal, + openTest, + setupAndPlay, + throwIfError, + waitForPlayerToLoad, + waitForRuffle, +} from "../utils"; +import { Player } from "ruffle-core"; +import sharp from "sharp"; +import { MovieMetadata } from "ruffle-core/dist/public/player"; + +const TESTS_DIR = "../../../tests/tests/swfs"; +const TESTS_DESCRIPTOR = "test/swf_tests/swf_tests.json5"; + +use(chaiHtml); + +async function readPng(filePath: string): Promise { + const image = sharp(filePath); + const { data, info } = await image + .raw() + .ensureAlpha() // ensures RGBA + .toBuffer({ resolveWithObject: true }); + + return { + width: info.width, + height: info.height, + data: Array.from(data), + }; +} + +function deconstruct(object: any, prop: string): any | null { + if (!(prop in object)) { + return null; + } + const val = object[prop]; + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete object[prop]; + return val; +} + +function assertDeconstructedFully(object: any): any { + if (Object.keys(object).length > 0) { + throw Error( + "Expected object to be fully deconstructed, but it was not. " + + "It usually means that some feature used by a SWF test is not " + + "implemented by this runner. Left fields: " + + Object.keys(object), + ); + } +} + +type Trigger = string | number; + +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +interface TestDescriptor {} + +interface ImageComparison { + name: string; + filename: string; + checks: ImageComparisonCheck[]; +} + +interface ImageComparisonCheck { + tolerance: number; + outliers: number; +} + +interface Image { + width: number; + height: number; + + // RGBA data + // Note: wdio does not support serializing Uint8ClampedArray + data: Array; +} + +class SwfTest { + name: string; + descriptor: TestDescriptor; + testDir: string; + + loadSucceeded: boolean = false; + + testToml: any; + outputTxt?: string; + inputJson: Array = []; + inputJsonPos: number = 0; + + browser?: WebdriverIO.Browser; + player: ChainablePromiseElement; + + metadata?: MovieMetadata; + frameRate: number = 0; + frameTime: number = 0; + + numTicks: number = 0; + iteration: number = 0; + + sampleCount?: number; + imageComparisons: Map = new Map(); + images: Map = new Map(); + + constructor(name: string, descriptor: TestDescriptor) { + this.name = name; + this.descriptor = descriptor; + this.testDir = path.join(TESTS_DIR, this.name); + } + + async configure() { + const testTomlPath = path.join(this.testDir, "test.toml"); + const inputJsonPath = path.join(this.testDir, "input.json"); + const outputTxtPath = path.join(this.testDir, "output.txt"); + + this.testToml = toml.parse(await fs.readFile(testTomlPath, "utf8")); + this.outputTxt = await fs.readFile(outputTxtPath, "utf8"); + try { + this.inputJson = json5.parse( + await fs.readFile(inputJsonPath, "utf8"), + ); + } catch { + // no input.json + } + + await this.readTestToml(); + } + + async readTestToml() { + const testToml = JSON.parse(JSON.stringify(this.testToml)); + if (!("num_ticks" in testToml)) { + throw Error("Only 'num_ticks' is supported for now."); + } + + this.numTicks = deconstruct(testToml, "num_ticks"); + + if ("image_comparisons" in testToml) { + await this.readTestTomlImageComparisons( + deconstruct(testToml, "image_comparisons"), + ); + } + + if ("player_options" in testToml) { + await this.readTestTomlPlayerOptions( + deconstruct(testToml, "player_options"), + ); + } + + assertDeconstructedFully(testToml); + } + + async readTestTomlImageComparisons(imageComparisons: any) { + for (const name of Object.keys(imageComparisons)) { + const imageComparisonToml = deconstruct(imageComparisons, name); + const trigger = + deconstruct(imageComparisonToml, "trigger") ?? "last_frame"; + + const checks: ImageComparisonCheck[] = []; + checks.push({ + outliers: 0, + tolerance: 0, + }); + + const imageComparison: ImageComparison = { + name, + filename: `${name}.expected.png`, + checks, + }; + + assertDeconstructedFully(imageComparisonToml); + this.imageComparisons.set(trigger, imageComparison); + } + + assertDeconstructedFully(imageComparisons); + } + + async readTestTomlPlayerOptions(playerOptions: any) { + if ("with_renderer" in playerOptions) { + const withRenderer = deconstruct(playerOptions, "with_renderer"); + deconstruct(withRenderer, "optional"); // ignore + + this.sampleCount = deconstruct(withRenderer, "sample_count"); + assertDeconstructedFully(withRenderer); + } + + assertDeconstructedFully(playerOptions); + } + + async load(browser: WebdriverIO.Browser, player: ChainablePromiseElement) { + this.browser = browser; + this.player = player; + + this.metadata = await this.browser!.execute((playerElement) => { + const player = playerElement as Player.PlayerElement; + return player.ruffle().metadata!; + }, this.player); + this.frameRate = this.metadata.frameRate; + this.frameTime = 1000.0 / this.frameRate; + this.loadSucceeded = true; + } + + async runInputForTick() { + while (this.inputJsonPos < this.inputJson.length) { + const event = JSON.parse( + JSON.stringify(this.inputJson[this.inputJsonPos++]), + ); + const eventType = deconstruct(event, "type"); + + switch (eventType) { + case "Wait": + break; + default: + throw new Error(`Unsupported event "${eventType}"`); + } + assertDeconstructedFully(event); + } + } + + async captureImage(trigger: Trigger) { + const canvas = await this.player.shadow$("canvas"); + + const image: Image | undefined = await this.browser!.execute(function ( + canvasElement, + ): Image | undefined { + const canvas = canvasElement as HTMLCanvasElement; + const ctx2d = canvas.getContext("2d"); + if (ctx2d) { + const imageData = ctx2d.getImageData( + 0, + 0, + canvas.width, + canvas.height, + ); + return { + width: canvas.width, + height: canvas.height, + data: Array.from(imageData.data), + }; + } + + const ctxWebgl = + canvas.getContext("webgl") ?? canvas.getContext("webgl2"); + if (ctxWebgl) { + const width = canvas.width; + const height = canvas.height; + const data = new Uint8ClampedArray(width * height * 4); + ctxWebgl.readPixels( + 0, + 0, + width, + height, + ctxWebgl.RGBA, + ctxWebgl.UNSIGNED_BYTE, + data, + ); + + return { + width: canvas.width, + height: canvas.height, + data: Array.from(data), + }; + } + + return undefined; + }, canvas); + + this.images.set(trigger, image); + } + + async tick(timestamp: number) { + await this.browser!.execute( + (playerElement, timestamp) => { + const player = playerElement as Player.PlayerElement; + player.ruffle().tick(timestamp as unknown as number); + }, + this.player, + timestamp, + ); + + if (this.imageComparisons.has(this.iteration)) { + await this.captureImage(this.iteration); + } + + if (this.iteration === this.numTicks) { + await this.captureImage("last_frame"); + } + + this.iteration += 1; + } + + async getActualOutput() { + return (await getAllTraceOutput(this.browser!, this.player)) + .map((line) => line + "\n") + .join(""); + } + + async assertOutput() { + expect(await this.getActualOutput()).toEqual(this.outputTxt); + } + + async assertImage(trigger: Trigger) { + const comparison: ImageComparison = this.imageComparisons.get(trigger)!; + + const expectedImagePath = path.join(this.testDir, comparison.filename); + const expectedImage = await readPng(expectedImagePath); + const actualImage = this.images.get(trigger)!; + + expect([actualImage.width, actualImage.height]).toEqual([ + expectedImage.width, + expectedImage.height, + ]); + // just to make sure + expect(actualImage.data.length).toEqual(expectedImage.data.length); + + // TODO checks + expect(actualImage.data).toEqual(expectedImage.data); + } +} + +async function loadTestsDescriptor(): Promise { + return json5.parse(await fs.readFile(TESTS_DESCRIPTOR, "utf8")); +} + +async function loadTest(name: string, testDescriptor: TestDescriptor) { + const test = new SwfTest(name, testDescriptor); + await test.configure(); + + describe(name, () => { + it("load test", async function () { + const quality = { + 1: "low", + 2: "medium", + 4: "high", + 8: "8x8", + 16: "16x16", + }[test.sampleCount ?? 1]!; + + const swfUrl = `http://localhost:4567/swf_tests/${name}/test.swf`; + await openTest( + browser, + "swf_tests", + `index.html?swf=${encodeURI(swfUrl)}` + + `&config_quality=${encodeURI(quality)}` + + `&name=${encodeURI(name)}`, + ); + await waitForRuffle(browser); + await throwIfError(browser); + + const player = await browser.$(""); + await waitForPlayerToLoad(browser, player); + await setupAndPlay(browser, player); + await hideHardwareAccelerationModal(browser, player); + + await test.load(browser, player); + }); + + it("run test", async function () { + if (!test.loadSucceeded) { + this.skip(); + } + + let timestamp = 0; + await test.tick(timestamp); + + for (let i = 0; i < test.numTicks; i++) { + timestamp += test.frameTime; + await test.runInputForTick(); + await test.tick(timestamp); + } + }); + + it("verify output.txt", async function () { + if (!test.loadSucceeded) { + this.skip(); + } + + await test.assertOutput(); + }); + + for (const [trigger, comparison] of test.imageComparisons) { + it(`verify ${comparison.filename}`, async function () { + if (!test.loadSucceeded) { + this.skip(); + } + + await test.assertImage(trigger); + }); + } + }); +} + +const testsDescriptor = await loadTestsDescriptor(); +for (const key in testsDescriptor["tests"]) { + await loadTest(key, testsDescriptor.tests[key] as TestDescriptor); +} diff --git a/web/packages/selfhosted/test/utils.ts b/web/packages/selfhosted/test/utils.ts index 85577e27b3e5..1d586ac5ff2f 100644 --- a/web/packages/selfhosted/test/utils.ts +++ b/web/packages/selfhosted/test/utils.ts @@ -146,6 +146,16 @@ export async function getTraceOutput( ); } +export async function getAllTraceOutput( + browser: WebdriverIO.Browser, + player: ChainablePromiseElement, +): Promise { + return await browser.execute((playerElement) => { + const player = playerElement as Player.PlayerElement; + return player.__ruffle_log__; + }, player); +} + export async function expectTraceOutput( browser: WebdriverIO.Browser, player: ChainablePromiseElement, diff --git a/web/packages/selfhosted/wdio.conf.ts b/web/packages/selfhosted/wdio.conf.ts index 30699fc03b4a..edca1ebca45f 100644 --- a/web/packages/selfhosted/wdio.conf.ts +++ b/web/packages/selfhosted/wdio.conf.ts @@ -198,6 +198,7 @@ services.push([ { mount: "/dist", path: "./dist" }, { mount: "/test_assets", path: "./test_assets" }, { mount: "/test", path: "./test" }, + { mount: "/swf_tests", path: "../../../tests/tests/swfs" }, ], port: 4567, }, @@ -219,6 +220,7 @@ export const config: WebdriverIO.Config = { "./test/polyfill/**/test.ts", "./test/js_api/*.ts", "./test/integration_tests/**/test.ts", + "./test/swf_tests/swf_tests.ts", ], maxInstances: maxInstances, capabilities,