diff --git a/moli-renderer-v8/src/context_bootstrap/canvas.rs b/moli-renderer-v8/src/context_bootstrap/canvas.rs
index 2105e55202..3aeebbee45 100644
--- a/moli-renderer-v8/src/context_bootstrap/canvas.rs
+++ b/moli-renderer-v8/src/context_bootstrap/canvas.rs
@@ -197,6 +197,8 @@ mod state;
mod transform;
mod webgl;
+pub(crate) use transform::install_canvas_transform_bindings;
+
pub(crate) use backing_store::{
attach_canvas_like_context_object, canvas_like_to_data_url,
reset_html_canvas_backing_store_for_dimension_assignment,
diff --git a/moli-renderer-v8/src/context_bootstrap/canvas/transform.rs b/moli-renderer-v8/src/context_bootstrap/canvas/transform.rs
index 3c433bad99..587fbc336a 100644
--- a/moli-renderer-v8/src/context_bootstrap/canvas/transform.rs
+++ b/moli-renderer-v8/src/context_bootstrap/canvas/transform.rs
@@ -1,6 +1,66 @@
//! WebIDL conversion and DOMMatrix2DInit alias validation for Canvas transforms.
-use crate::{util::throw_type_error, webidl};
+use crate::{util::throw_type_error, web_api_interfaces, webidl};
+use moli_geometry::DomMatrixComponents;
+use moli_webapi_declare::WebApiFunctionTemplate;
+
+#[derive(WebApiFunctionTemplate)]
+#[webapi(interface = web_api_interfaces::CanvasRenderingContext2D, enumerable, receiver)]
+struct CanvasTransformPrototypeDeclaration {
+ #[webapi(method = "getTransform", length = 0, callback = get_transform_callback)]
+ get_transform: (),
+}
+
+#[derive(WebApiFunctionTemplate)]
+#[webapi(interface = web_api_interfaces::OffscreenCanvasRenderingContext2D, enumerable, receiver)]
+struct OffscreenCanvasTransformPrototypeDeclaration {
+ #[webapi(method = "getTransform", length = 0, callback = get_transform_callback)]
+ get_transform: (),
+}
+
+pub(crate) fn install_canvas_transform_bindings<'s>(
+ scope: &mut v8::PinScope<'s, '_, ()>,
+ prototype: v8::Local<'s, v8::ObjectTemplate>,
+ interface: &str,
+) {
+ match interface {
+ "CanvasRenderingContext2D" => {
+ CanvasTransformPrototypeDeclaration::initialize_prototype_template(scope, prototype);
+ }
+ "OffscreenCanvasRenderingContext2D" => {
+ OffscreenCanvasTransformPrototypeDeclaration::initialize_prototype_template(
+ scope, prototype,
+ );
+ }
+ _ => unreachable!("only 2D canvas contexts have transform bindings"),
+ }
+}
+
+fn get_transform_callback<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ args: v8::FunctionCallbackArguments<'s>,
+ mut rv: v8::ReturnValue<'_, v8::Value>,
+) {
+ let context = moli_webapi_declare::web_api_object_target(scope, args.this())
+ .expect("validated canvas receiver has native identity");
+ let [a, b, c, d, e, f] = super::state::canvas_path_state(scope, context)
+ .borrow()
+ .transform()
+ .coefficients;
+ let matrix = super::super::geometry_runtime::build_dom_matrix_object(
+ scope,
+ DomMatrixComponents {
+ m11: a,
+ m12: b,
+ m21: c,
+ m22: d,
+ m41: e,
+ m42: f,
+ ..DomMatrixComponents::identity()
+ },
+ );
+ rv.set(matrix.into());
+}
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "DOMMatrix2DInit")]
diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs
index ec525e4614..bfbd97a502 100644
--- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs
+++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs
@@ -21,6 +21,10 @@ pub(in crate::context_bootstrap) const STORAGE_INTERFACE_NAMES: &[&str] = &[
pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = &[
"DOMRectReadOnly",
"DOMRect",
+ "DOMPointReadOnly",
+ "DOMPoint",
+ "DOMMatrixReadOnly",
+ "DOMMatrix",
"EventTarget",
"Event",
"CustomEvent",
diff --git a/moli-renderer-v8/src/context_bootstrap/geometry_runtime.rs b/moli-renderer-v8/src/context_bootstrap/geometry_runtime.rs
index 756b540523..3e8edbeaf8 100644
--- a/moli-renderer-v8/src/context_bootstrap/geometry_runtime.rs
+++ b/moli-renderer-v8/src/context_bootstrap/geometry_runtime.rs
@@ -109,9 +109,6 @@ macro_rules! dom_matrix_object_declaration {
}
}
- fn identity() -> Self {
- Self::from_components(DomMatrixComponents::identity())
- }
}
};
}
@@ -566,7 +563,14 @@ pub(in crate::context_bootstrap) fn build_dom_point_object<'s>(
pub(in crate::context_bootstrap) fn build_dom_matrix_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
- DomMatrixObjectDeclaration::identity()
+ build_dom_matrix_object(scope, DomMatrixComponents::identity())
+}
+
+pub(in crate::context_bootstrap) fn build_dom_matrix_object<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ components: DomMatrixComponents,
+) -> v8::Local<'s, v8::Object> {
+ DomMatrixObjectDeclaration::from_components(components)
.bind(scope)
.expect("DOMMatrix declaration should bind")
}
@@ -574,7 +578,7 @@ pub(in crate::context_bootstrap) fn build_dom_matrix_identity_object<'s>(
fn build_dom_matrix_readonly_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
- DomMatrixReadOnlyObjectDeclaration::identity()
+ DomMatrixReadOnlyObjectDeclaration::from_components(DomMatrixComponents::identity())
.bind(scope)
.expect("DOMMatrixReadOnly declaration should bind")
}
@@ -1904,7 +1908,7 @@ fn initialize_dom_matrix_identity_object<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) {
- DomMatrixObjectDeclaration::identity()
+ DomMatrixObjectDeclaration::from_components(DomMatrixComponents::identity())
.initialize(scope, object)
.expect("DOMMatrix declaration should initialize object");
}
diff --git a/moli-renderer-v8/src/context_bootstrap/media_file_template.rs b/moli-renderer-v8/src/context_bootstrap/media_file_template.rs
index b8c60b9c4a..8c4a9c39cf 100644
--- a/moli-renderer-v8/src/context_bootstrap/media_file_template.rs
+++ b/moli-renderer-v8/src/context_bootstrap/media_file_template.rs
@@ -32,8 +32,9 @@ use super::canvas::{
canvas_context_stroke_style_setter_callback, canvas_context_stroke_text_callback,
canvas_context_transform_callback, canvas_context_translate_callback,
canvas_gradient_add_color_stop_callback, install_canvas_template_bindings,
- offscreen_canvas_convert_to_blob_callback, offscreen_canvas_get_context_callback,
- webgl_boolean_callback, webgl_check_framebuffer_status_callback, webgl_create_buffer_callback,
+ install_canvas_transform_bindings, offscreen_canvas_convert_to_blob_callback,
+ offscreen_canvas_get_context_callback, webgl_boolean_callback,
+ webgl_check_framebuffer_status_callback, webgl_create_buffer_callback,
webgl_create_framebuffer_callback, webgl_create_program_callback,
webgl_create_renderbuffer_callback, webgl_create_shader_callback,
webgl_get_attrib_location_callback, webgl_get_context_attributes_callback,
@@ -688,6 +689,7 @@ pub(super) fn install_media_file_template_bindings<'s>(
CanvasRenderingContext2dTemplateDeclaration::initialize_prototype_template(
scope, proto,
);
+ install_canvas_transform_bindings(scope, proto, spec_name);
}
"WebGLRenderingContext" => {
let proto = template.prototype_template(scope);
diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.js b/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.js
new file mode 100644
index 0000000000..caca900a65
--- /dev/null
+++ b/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.js
@@ -0,0 +1,115 @@
+(function canvasContract(owner, callee, kind, tag) {
+ const checks = [];
+ const check = (name, run) => {
+ try { checks.push({name: 'canvas/' + tag + '/' + name, passed: run() === true}); }
+ catch (error) { checks.push({name: 'canvas/' + tag + '/' + name, passed: false, error: String(error)}); }
+ };
+ const interfaceName = kind === 'element' ? 'CanvasRenderingContext2D' : 'OffscreenCanvasRenderingContext2D';
+ const makeCanvas = () => kind === 'element' ? owner.document.createElement('canvas') : new owner.OffscreenCanvas(96, 96);
+ const canvas = makeCanvas(); canvas.width = canvas.height = 96;
+ const ctx = canvas.getContext('2d');
+ const prototype = callee[interfaceName].prototype;
+ const method = prototype.getTransform;
+ const Matrix = callee.DOMMatrix;
+ const matrixPrototype = Matrix.prototype;
+ const get = (receiver = ctx, ...args) => {
+ if (typeof method !== 'function') throw new Error('getTransform missing');
+ return method.call(receiver, ...args);
+ };
+ const components = matrix => ['a', 'b', 'c', 'd', 'e', 'f'].map(name => matrix[name]);
+ const equal = (actual, expected) => actual.length === expected.length && actual.every((value, index) =>
+ value === expected[index] || Math.abs(value - expected[index]) <= 1e-12 * Math.max(1, Math.abs(expected[index])));
+ const matches = expected => equal(components(get()), expected);
+ const identity = [1, 0, 0, 1, 0, 0];
+ const throwsTypeError = run => { try { run(); return false; } catch (error) { return error instanceof callee.TypeError; } };
+
+ check('own descriptor', () => {
+ const d = Object.getOwnPropertyDescriptor(prototype, 'getTransform');
+ return !!d && d.value === method && d.enumerable && d.writable && d.configurable;
+ });
+ check('function metadata', () => typeof method === 'function' && method.length === 0 && method.name === 'getTransform' && method instanceof callee.Function);
+ check('initial identity', () => matches(identity));
+ check('DOMMatrix prototype in callee realm', () => Object.getPrototypeOf(get()) === matrixPrototype);
+ check('mutable and readonly brands', () => get() instanceof Matrix && get() instanceof callee.DOMMatrixReadOnly);
+ check('initial 2D and identity flags', () => get().is2D && get().isIdentity);
+ check('fresh objects', () => get() !== get());
+ check('no own expando components', () => !Object.hasOwn(get(), 'a') && !Object.hasOwn(get(), 'm11'));
+ check('Float64 mapping and realm', () => {
+ const array = get().toFloat64Array();
+ return array instanceof callee.Float64Array && equal(Array.from(array), [1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]);
+ });
+ check('Float32 mapping and realm', () => get().toFloat32Array() instanceof callee.Float32Array);
+ check('JSON mapping', () => get().toJSON().m44 === 1 && get().toJSON().is2D === true);
+ check('ignored extra arguments', () => {
+ let traps = 0;
+ const extra = new Proxy({}, {get() { traps++; throw new Error('argument converted'); }});
+ return equal(components(get(ctx, extra)), identity) && traps === 0;
+ });
+
+ for (const [name, receiver] of [
+ ['null', null], ['undefined', undefined], ['number', 0], ['string', ''], ['boolean', false],
+ ['plain', {}], ['prototype', prototype], ['forged prototype', Object.create(prototype)],
+ ['inherited real object', Object.create(ctx)], ['author Proxy', new Proxy(ctx, {})]
+ ]) check('receiver ' + name, () => throwsTypeError(() => {
+ if (typeof method !== 'function') throw new Error('getTransform missing');
+ method.call(receiver);
+ }));
+ const revoked = Proxy.revocable(ctx, {}); revoked.revoke();
+ check('revoked Proxy receiver', () => throwsTypeError(() => get(revoked.proxy)));
+ check('receiver validation without author traps', () => {
+ let traps = 0;
+ const fake = new Proxy(ctx, {get() { traps++; throw new Error('receiver trap'); }, getPrototypeOf() { traps++; return prototype; }});
+ return throwsTypeError(() => get(fake)) && traps === 0;
+ });
+ if (owner.document) check('other context interface rejected', () => {
+ const other = kind === 'element' ? new owner.OffscreenCanvas(96,96) : owner.document.createElement('canvas');
+ return throwsTypeError(() => get(other.getContext('2d')));
+ });
+
+ check('six scalar coefficients', () => { ctx.setTransform(1, 2, 3, 4, 5, 6); return matches([1,2,3,4,5,6]); });
+ check('six scalar 4D mapping', () => equal(Array.from(get().toFloat64Array()), [1,2,0,0,3,4,0,0,0,0,1,0,5,6,0,1]));
+ check('nonidentity flag', () => !get().isIdentity && get().is2D);
+ check('snapshot mutation leaves context unchanged', () => { const m = get(); m.a = 8; m.e = 30; return matches([1,2,3,4,5,6]); });
+ check('snapshot mutator leaves context unchanged', () => { get().translateSelf(20,30).scaleSelf(2); return matches([1,2,3,4,5,6]); });
+ check('context mutation leaves snapshot unchanged', () => { const m = get(); ctx.setTransform(2,0,0,3,10,20); return equal(components(m),[1,2,3,4,5,6]) && matches([2,0,0,3,10,20]); });
+ check('postmultiply translation', () => { ctx.translate(4,5); return matches([2,0,0,3,18,35]); });
+ check('postmultiply scale', () => { ctx.scale(4,5); return matches([8,0,0,15,18,35]); });
+ check('postmultiply rotation', () => { ctx.rotate(Math.PI/2); return matches([0,15,-8,0,18,35]); });
+ check('reset transform', () => { ctx.resetTransform(); return matches(identity); });
+ check('noncommutative multiplication', () => { ctx.setTransform(1,2,3,4,5,6); ctx.transform(7,8,9,10,11,12); return matches([31,46,39,58,52,76]); });
+ check('fractional double state', () => { const a=[1.125,0.0625,-0.03125,2.25,5.125,-7.75]; ctx.setTransform(...a); return matches(a); });
+ check('singular matrix', () => { ctx.setTransform(0,0,0,0,5,6); return matches([0,0,0,0,5,6]); });
+
+ const names = ['a','b','c','d','e','f'];
+ const aliases = ['m11','m12','m21','m22','m41','m42'];
+ for (let index=0; index<6; index++) {
+ check('dictionary component ' + names[index], () => { ctx.setTransform({[names[index]]:2}); const a=identity.slice(); a[index]=2; return matches(a); });
+ check('dictionary alias ' + aliases[index], () => { ctx.setTransform({[aliases[index]]:3}); const a=identity.slice(); a[index]=3; return matches(a); });
+ check('inconsistent aliases atomic ' + names[index], () => { ctx.setTransform(1,2,3,4,5,6); let failed=false; try { ctx.setTransform({[names[index]]:1,[aliases[index]]:2}); } catch (e) { failed=e instanceof owner.TypeError; } return failed && matches([1,2,3,4,5,6]); });
+ }
+ for (const [label,value] of [['empty',{}],['undefined',undefined],['null',null]])
+ check('dictionary identity ' + label, () => { ctx.translate(20,30); ctx.setTransform(value); return matches(identity); });
+ check('no argument identity', () => { ctx.translate(20,30); ctx.setTransform(); return matches(identity); });
+ check('dictionary from snapshot', () => { ctx.setTransform(1,2,3,4,5,6); const m=get(); ctx.resetTransform(); ctx.setTransform(m); return matches([1,2,3,4,5,6]); });
+ for (const [label,value] of [['NaN',NaN],['positive Infinity',Infinity],['negative Infinity',-Infinity]]) {
+ check('nonfinite scalar ignored ' + label, () => { ctx.setTransform(1,2,3,4,5,6); ctx.setTransform(value,0,0,1,0,0); return matches([1,2,3,4,5,6]); });
+ check('nonfinite dictionary ignored ' + label, () => { ctx.setTransform(1,2,3,4,5,6); ctx.setTransform({a:value,m11:value}); return matches([1,2,3,4,5,6]); });
+ }
+ check('getter exception preserves state and identity', () => {
+ ctx.setTransform(1,2,3,4,5,6); const sentinel={}; let same=false;
+ try { ctx.setTransform({a:8,get e(){throw sentinel;}}); } catch(e) {same=e===sentinel;}
+ return same && matches([1,2,3,4,5,6]);
+ });
+ check('beginPath preserves transform', () => { ctx.beginPath(); return matches([1,2,3,4,5,6]); });
+ check('fresh independent context', () => matches([1,2,3,4,5,6]) && equal(components(get(makeCanvas().getContext('2d'))),identity));
+ check('repeated getContext preserves state', () => canvas.getContext('2d') === ctx && matches([1,2,3,4,5,6]));
+ check('width reset', () => { canvas.width = canvas.width; return matches(identity); });
+ check('height reset', () => { ctx.translate(20,30); canvas.height = canvas.height; return matches(identity); });
+ check('snapshot survives bitmap reset', () => { ctx.setTransform(1,2,3,4,5,6); const m=get(); canvas.width=canvas.width+1; return equal(components(m),[1,2,3,4,5,6]) && matches(identity); });
+ check('global constructor spoofing', () => {
+ const original=callee.DOMMatrix; let calls=0;
+ try { callee.DOMMatrix=function(){calls++; throw new Error('author constructor');}; return Object.getPrototypeOf(get()) === matrixPrototype && calls === 0; }
+ finally {callee.DOMMatrix=original;}
+ });
+ return checks;
+})
diff --git a/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.rs b/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.rs
new file mode 100644
index 0000000000..514e699627
--- /dev/null
+++ b/moli-renderer-v8/src/script_vm/tests/canvas_transform_snapshots.rs
@@ -0,0 +1,92 @@
+use super::*;
+
+#[test]
+fn canvas_transform_snapshots_copy_native_state_with_brands_and_realms() {
+ let mut vm = new_storage_page_task_executor_test_vm("https://canvas-transform.test/");
+ vm.eval("document.body.innerHTML = ''")
+ .unwrap();
+ let source = format!(
+ r#"(() => {{
+ const probe = {};
+ const realms = [globalThis, document.getElementById('child').contentWindow];
+ const checks = [];
+ for (let owner = 0; owner < 2; owner++) for (let callee = 0; callee < 2; callee++)
+ for (const kind of ['element', 'offscreen'])
+ checks.push(...probe(realms[owner], realms[callee], kind, 'window-' + owner + '-' + callee + '-' + kind));
+ globalThis.__canvasTransformChecks = checks;
+ return checks.length;
+ }})()"#,
+ include_str!("canvas_transform_snapshots.js"),
+ );
+ assert_eq!(vm.eval(&source).unwrap(), "600");
+ assert_eq!(
+ vm.eval("JSON.stringify(__canvasTransformChecks.filter(row => !row.passed))")
+ .unwrap(),
+ "[]"
+ );
+}
+
+#[test]
+fn canvas_transform_snapshots_accept_registered_native_proxies_and_share_paint_state() {
+ let mut vm = new_storage_page_task_executor_test_vm("https://canvas-transform-native.test/");
+ vm.eval(
+ r#"document.body.innerHTML = '';
+ globalThis.nativeCanvasContexts = [document.createElement('canvas'), new OffscreenCanvas(96,96)]
+ .map(canvas => canvas.getContext('2d'));
+ for (const ctx of nativeCanvasContexts) ctx.setTransform(1,0,0,1,20,0);"#,
+ )
+ .unwrap();
+ let context_ptr = &vm.page_default_context as *const _;
+ vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
+ let global = scope.get_current_context().global(scope);
+ let key = crate::util::v8str(scope, "nativeCanvasContexts");
+ let value = global.get(scope, key.into()).unwrap();
+ let contexts = v8::Local::::try_from(value).unwrap();
+ let proxies = v8::Array::new(scope, 0);
+ for index in 0..contexts.length() {
+ let value = contexts.get_index(scope, index).unwrap();
+ let context = v8::Local::::try_from(value).unwrap();
+ let handler = crate::util::new_null_prototype_object(scope);
+ let proxy = v8::Proxy::new(scope, context, handler).unwrap();
+ moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
+ assert_eq!(proxies.set_index(scope, index, proxy.into()), Some(true));
+ }
+ let key = crate::util::v8str(scope, "nativeCanvasContextProxies");
+ assert_eq!(
+ global.create_data_property(scope, key.into(), proxies.into()),
+ Some(true)
+ );
+ Ok(())
+ })
+ .unwrap();
+ assert_eq!(
+ vm.eval(
+ r#"(() => {
+ const realms = [globalThis, document.getElementById('child').contentWindow];
+ for (let index=0;index<2;index++) {
+ const ctx=nativeCanvasContexts[index], proxy=nativeCanvasContextProxies[index];
+ const name=index===0?'CanvasRenderingContext2D':'OffscreenCanvasRenderingContext2D';
+ for (const realm of realms) {
+ const get=realm[name].prototype.getTransform;
+ const matrix=get.call(proxy);
+ if(matrix.e!==20 || !(matrix instanceof realm.DOMMatrix)) throw Error('native proxy snapshot');
+ matrix.e=80;
+ if(get.call(ctx).e!==20) throw Error('snapshot mutated paint state');
+ let rejected=false;
+ try {get.call(new Proxy(proxy,{}));} catch(error) {rejected=error instanceof realm.TypeError;}
+ if(!rejected) throw Error('author proxy accepted');
+ }
+ ctx.beginPath(); ctx.rect(0,0,10,10); ctx.fill();
+ if(ctx.getImageData(25,5,1,1).data[3]!==255 || ctx.getImageData(5,5,1,1).data[3]!==0)
+ throw Error('snapshot transform differs from native painting');
+ ctx.__moliCanvasPathState=0;
+ Object.setPrototypeOf(ctx,null); Object.freeze(ctx);
+ if(globalThis[name].prototype.getTransform.call(ctx).e!==20) throw Error('native identity lost');
+ }
+ return true;
+ })()"#,
+ )
+ .unwrap(),
+ "true"
+ );
+}
diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs
index 1ecca2f9ad..aaaaee84a3 100644
--- a/moli-renderer-v8/src/script_vm/tests/mod.rs
+++ b/moli-renderer-v8/src/script_vm/tests/mod.rs
@@ -2135,6 +2135,7 @@ mod browser_api;
mod cache_interfaces;
mod canvas_arguments;
mod canvas_paths;
+mod canvas_transform_snapshots;
mod canvas_webgl;
mod child_dynamic_inline_scripts;
mod device_events;
diff --git a/moli-renderer-v8/src/worker/thread/tests/canvas_transform_snapshots.rs b/moli-renderer-v8/src/worker/thread/tests/canvas_transform_snapshots.rs
new file mode 100644
index 0000000000..8acf4d135c
--- /dev/null
+++ b/moli-renderer-v8/src/worker/thread/tests/canvas_transform_snapshots.rs
@@ -0,0 +1,22 @@
+use super::*;
+
+#[tokio::test]
+async fn worker_offscreen_canvas_transform_snapshots_preserve_native_state_and_identity() {
+ ensure_v8();
+ let source = format!(
+ r#"const probe = {};
+ const checks = probe(globalThis, globalThis, 'offscreen', 'worker');
+ const point = new DOMMatrix().translate(3, 4).transformPoint(new DOMPoint(1, 2));
+ if (!(point instanceof DOMPoint) || point.x !== 4 || point.y !== 6)
+ throw Error('worker transform result types');
+ postMessage({{total: checks.length, failures: checks.filter(row => !row.passed)}});
+ close();"#,
+ include_str!("../../../script_vm/tests/canvas_transform_snapshots.js"),
+ );
+ let mut worker = spawn_worker(source, "test://canvas_transform_snapshots".into());
+ let message = timeout(TIMEOUT, worker.recv())
+ .await
+ .expect("timed out")
+ .expect("channel closed");
+ assert_eq!(expect_post_json(message), r#"{"total":74,"failures":[]}"#);
+}
diff --git a/moli-renderer-v8/src/worker/thread/tests/mod.rs b/moli-renderer-v8/src/worker/thread/tests/mod.rs
index 4305cc88d8..f6d2fe628f 100644
--- a/moli-renderer-v8/src/worker/thread/tests/mod.rs
+++ b/moli-renderer-v8/src/worker/thread/tests/mod.rs
@@ -1534,6 +1534,9 @@ mod strict_script_mime;
mod network;
mod postmessage;
+
+mod canvas_transform_snapshots;
+
mod tls;
mod xhr_failure;