diff --git a/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js b/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js new file mode 100644 index 0000000000..3384a22e54 --- /dev/null +++ b/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js @@ -0,0 +1,166 @@ +globalThis.versionChangeChecks = []; +async function versionChangeProbe(prefix = 'version-change') { + const checks = versionChangeChecks; + const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)}); + const same = (label, actual, expected) => check(label, JSON.stringify(actual) === JSON.stringify(expected), JSON.stringify(actual)); + const throws = (label, action, Realm) => { + let caught; + try { action(); } catch (error) { caught = error; } + check(label, caught !== undefined && Object.getPrototypeOf(caught) === Realm.TypeError.prototype, caught); + }; + const exercise = async (label, Realm, GetterRealm) => { + const Ctor = Realm.IDBVersionChangeEvent; + const getters = {}; + for (const name of ['oldVersion', 'newVersion']) { + const descriptor = Object.getOwnPropertyDescriptor(GetterRealm.IDBVersionChangeEvent.prototype, name); + getters[name] = descriptor?.get; + check(label + ': ' + name + ' descriptor', typeof descriptor?.get === 'function' && + descriptor.set === undefined && descriptor.enumerable && descriptor.configurable); + check(label + ': ' + name + ' getter metadata', descriptor?.get?.name === 'get ' + name && descriptor.get.length === 0); + } + const get = (name, receiver) => typeof getters[name] === 'function' ? getters[name].call(receiver) : undefined; + check(label + ': constructor length', Ctor.length === 1, Ctor.length); + throws(label + ': new is required', () => Ctor('type'), Realm); + throws(label + ': type is required', () => new Ctor(), Realm); + throws(label + ': symbol type is rejected', () => new Ctor(Symbol()), Realm); + for (const invalid of [1, 'init', Symbol(), 1n]) { + throws(label + ': primitive dictionary is rejected', () => new Ctor('type', invalid), Realm); + } + for (const init of [undefined, null, {}]) { + const event = new Ctor('default', init); + same(label + ': default versions', [event.oldVersion, event.newVersion], [0, null]); + same(label + ': default flags', [event.bubbles, event.cancelable, event.composed, event.isTrusted], [false, false, false, false]); + } + const order = []; + const init = {}; + for (const [name, value] of [['bubbles', true], ['cancelable', true], ['composed', true], + ['newVersion', {valueOf() { order.push('convert newVersion'); return 9; }}], + ['oldVersion', {valueOf() { order.push('convert oldVersion'); return 7; }}]]) { + Object.defineProperty(init, name, {get() { order.push(name); return value; }}); + } + const event = new Ctor({toString() { order.push('type'); return 'flags'; }}, Object.create(init)); + same(label + ': conversion order', order, + ['type', 'bubbles', 'cancelable', 'composed', 'newVersion', 'convert newVersion', 'oldVersion', 'convert oldVersion']); + for (const name of ['bubbles', 'cancelable', 'composed']) { + check(label + ': EventInit ' + name, event[name] === true, event[name]); + } + const target = new EventTarget(); + target.addEventListener('flags', value => value.preventDefault()); + check(label + ': EventInit dispatch cancellation', target.dispatchEvent(event) === false); + check(label + ': EventInit defaultPrevented', event.defaultPrevented === true); + for (const [name, expected] of [['oldVersion', 7], ['newVersion', 9]]) { + check(label + ': ' + name + ' inherited', !Object.hasOwn(event, name)); + check(label + ': ' + name + ' getter value', get(name, event) === expected, get(name, event)); + check(label + ': ' + name + ' readonly', Reflect.set(event, name, 99) === false); + check(label + ': ' + name + ' unchanged', event[name] === expected, event[name]); + Object.defineProperty(event, name, {configurable: true, get() { throw new Error('author shadow'); }}); + check(label + ': ' + name + ' native value under author shadow', get(name, event) === expected, get(name, event)); + delete event[name]; + check(label + ': ' + name + ' restored getter', event[name] === expected, event[name]); + } + const revoked = Proxy.revocable(event, {}); revoked.revoke(); + let traps = 0; + const trapped = new Proxy(event, {get() { ++traps; throw new Error('trap'); }, getPrototypeOf() { ++traps; throw new Error('trap'); }}); + for (const receiver of [null, undefined, 1, {}, Ctor.prototype, Object.create(Ctor.prototype), + Object.create(event), new Realm.Event('base'), new Proxy(event, {}), revoked.proxy, trapped]) { + for (const name of ['oldVersion', 'newVersion']) { + throws(label + ': ' + name + ' invalid receiver', () => get(name, receiver), GetterRealm); + } + } + check(label + ': no Proxy traps', traps === 0, traps); + Object.setPrototypeOf(event, null); + same(label + ': native identity survives prototype removal', [get('oldVersion', event), get('newVersion', event)], [7, 9]); + class Subclass extends Ctor {} + const sub = new Subclass('sub', {oldVersion: 3, newVersion: 4}); + check(label + ': subclass prototype is preserved', Object.getPrototypeOf(sub) === Subclass.prototype); + same(label + ': subclass native identity', [get('oldVersion', sub), get('newVersion', sub)], [3, 4]); + + const members = ['bubbles', 'cancelable', 'composed', 'newVersion', 'oldVersion']; + for (let stop = 0; stop < members.length; ++stop) { + const reads = []; + const marker = new Error('dictionary getter'); + const options = new Proxy({}, {get(_target, key) { + reads.push(key); + if (key === members[stop]) throw marker; + return undefined; + }}); + let caught; + try { new Ctor('throws', options); } catch (error) { caught = error; } + check(label + ': getter exception identity ' + members[stop], caught === marker, caught); + same(label + ': stop conversion at ' + members[stop], reads, members.slice(0, stop + 1)); + } + const conversions = [[undefined, 0, null], [null, 0, null], [NaN, 0, 0], [Infinity, 0, 0], + [-Infinity, 0, 0], [1.9, 1, 1], [-1, 2 ** 64, 2 ** 64], [2 ** 64, 0, 0], ['12', 12, 12]]; + for (const [value, oldVersion, newVersion] of conversions) { + const converted = new Ctor('number', {oldVersion: value, newVersion: value}); + same(label + ': numeric conversion ' + String(value), [converted.oldVersion, converted.newVersion], [oldVersion, newVersion]); + } + for (const name of ['oldVersion', 'newVersion']) { + for (const value of [Symbol(), 1n]) { + throws(label + ': reject invalid ' + name, () => new Ctor('invalid', {[name]: value}), Realm); + } + } + const marker = new Error('type conversion'); + let dictionaryReads = 0; + let caught; + try { + new Ctor({toString() { throw marker; }}, new Proxy({}, {get() { ++dictionaryReads; }})); + } catch (error) { caught = error; } + check(label + ': type exception identity', caught === marker); + check(label + ': type exception stops dictionary conversion', dictionaryReads === 0, dictionaryReads); + + const nativeEvents = []; + const seen = []; + let inspectionError; + const inspectNative = (name, value, oldVersion, newVersion) => { + try { + seen.push(name); + same(label + ': native ' + name + ' versions', [value.oldVersion, value.newVersion], [oldVersion, newVersion]); + same(label + ': native ' + name + ' flags', [value.bubbles, value.cancelable, value.composed], [false, false, false]); + check(label + ': native ' + name + ' prototype', Object.getPrototypeOf(value) === Ctor.prototype); + check(label + ': native ' + name + ' inherited attributes', !Object.hasOwn(value, 'oldVersion') && !Object.hasOwn(value, 'newVersion')); + same(label + ': native ' + name + ' borrowed getters', [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]); + check(label + ': native ' + name + ' readonly oldVersion', Reflect.set(value, 'oldVersion', 99) === false); + check(label + ': native ' + name + ' readonly newVersion', Reflect.set(value, 'newVersion', 99) === false); + nativeEvents.push([name, value, oldVersion, newVersion]); + } catch (error) { inspectionError ??= error; } + }; + const requestResult = request => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const factory = Realm.indexedDB; + const name = prefix + '-' + label; + const savedCtor = Realm.IDBVersionChangeEvent; + Realm.IDBVersionChangeEvent = function() { throw new Error('author constructor'); }; + try { + const first = factory.open(name, 1); + first.onupgradeneeded = value => inspectNative('create', value, 0, 1); + const oldDb = await requestResult(first); + oldDb.onversionchange = value => inspectNative('versionchange', value, 1, 2); + const second = factory.open(name, 2); + second.onblocked = value => { inspectNative('blocked', value, 1, 2); oldDb.close(); }; + second.onupgradeneeded = value => inspectNative('upgrade', value, 1, 2); + const newDb = await requestResult(second); + newDb.onversionchange = value => { inspectNative('delete notification', value, 2, null); newDb.close(); }; + await requestResult(factory.deleteDatabase(name)); + } finally { Realm.IDBVersionChangeEvent = savedCtor; } + if (inspectionError) throw inspectionError; + same(label + ': native event order', seen, + ['create', 'versionchange', 'blocked', 'upgrade', 'delete notification']); + for (const [name, value, oldVersion, newVersion] of nativeEvents) { + same(label + ': retained native ' + name, [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]); + } + }; + await exercise('local', globalThis, globalThis); + if (typeof document !== 'undefined') { + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.srcdoc = ''; document.documentElement.appendChild(frame); await loaded; + try { + await exercise('foreign-getter', globalThis, frame.contentWindow); + await exercise('foreign-event', frame.contentWindow, globalThis); + } finally { frame.remove(); } + } + return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks}; +} diff --git a/moli-core/tests/web_apis/indexed_db_transaction.rs b/moli-core/tests/web_apis/indexed_db_transaction.rs index 379b1dda3f..e6689b72c7 100644 --- a/moli-core/tests/web_apis/indexed_db_transaction.rs +++ b/moli-core/tests/web_apis/indexed_db_transaction.rs @@ -1,6 +1,28 @@ use super::pipe_disturbed::run_probe; use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn indexed_db_version_change_events_preserve_native_attributes_and_dictionary_semantics() +-> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let fixture = include_str!("fixtures/indexeddb-version-change.js"); + for target in ["window", "child", "worker"] { + let source = format!( + "{fixture}\nversionChangeProbe('version-change-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: versionChangeChecks}}));" + ); + let result = run_probe(&browser, &server, target, &source).await?; + assert_eq!(result["state"], "pass", "{target}: {result}"); + assert_eq!( + result["checks"].as_array().unwrap().len(), + if target == "worker" { 128 } else { 384 }, + "{target}: {result}" + ); + } + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn indexed_db_factory_checks_native_receivers_and_rejects_promises_in_callee_realm() -> Result<()> { diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index cec46ed2a4..769f2e0a3a 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -938,7 +938,7 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( web_api_interfaces::IDBVersionChangeEvent, crate::context_bootstrap::indexed_db::idb_version_change_event_constructor_callback )) - .length(2) + .length(1) .build(scope) } }; diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs index d0bb35658a..a4faf83350 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs @@ -1,15 +1,62 @@ use super::super::super::events; use super::*; +use crate::web_api_interfaces; use crate::webidl; -use moli_webapi_declare::WebApiObject; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const OLD_VERSION_SLOT: &str = "__moli_idb_event_old_version"; +const NEW_VERSION_SLOT: &str = "__moli_idb_event_new_version"; #[derive(WebApiObject)] -#[webapi(plain, data_properties, enumerable)] +#[webapi(fragment)] struct IdbVersionChangeEventFieldsDeclaration<'scope> { + #[webapi(slot = OLD_VERSION_SLOT)] old_version: u64, + #[webapi(slot = NEW_VERSION_SLOT)] new_version: v8::Local<'scope, v8::Value>, } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::IDBVersionChangeEvent, enumerable, receiver)] +struct IdbVersionChangeEventPrototypeDeclaration { + #[webapi(accessor_property, getter = old_version_getter)] + old_version: (), + #[webapi(accessor_property, getter = new_version_getter)] + new_version: (), +} + +pub(in crate::context_bootstrap::indexed_db) fn install_version_change_event_template_bindings< + 's, +>( + scope: &mut v8::PinScope<'s, '_, ()>, + prototype: v8::Local<'s, v8::ObjectTemplate>, + name: &str, +) { + if name == "IDBVersionChangeEvent" { + IdbVersionChangeEventPrototypeDeclaration::initialize_prototype_template(scope, prototype); + } +} + +fn old_version_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = events::event_private_value(scope, args.this(), OLD_VERSION_SLOT) { + rv.set(value); + } +} + +fn new_version_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = events::event_private_value(scope, args.this(), NEW_VERSION_SLOT) { + rv.set(value); + } +} + #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "IDBVersionChangeEvent")] struct IdbVersionChangeEventConstructorArgs { @@ -22,10 +69,18 @@ struct IdbVersionChangeEventConstructorArgs { #[derive(Default, webidl::WebIdlDictionary)] #[webidl(prefix = "IDBVersionChangeEventInit")] struct IdbVersionChangeEventInit { - #[webidl(default = 0)] - old_version: u64, + // WebIDL reads inherited EventInit members first, then this dictionary's + // members in lexicographic order. The derive preserves declaration order. + #[webidl(default = false)] + bubbles: bool, + #[webidl(default = false)] + cancelable: bool, + #[webidl(default = false)] + composed: bool, #[webidl(nullable)] new_version: Option, + #[webidl(default = 0)] + old_version: u64, } pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callback<'s>( @@ -46,7 +101,19 @@ pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callbac }; let wrapper = args.this(); let event = events::new_event_state(scope); - events::initialize_event_object(scope, event, &parsed.event_type, false, false); + events::initialize_event_object( + scope, + event, + &parsed.event_type, + parsed.init.bubbles, + parsed.init.cancelable, + ); + events::define_event_property( + scope, + event, + "composed", + v8::Boolean::new(scope, parsed.init.composed).into(), + ); IdbVersionChangeEventFieldsDeclaration::new( parsed.init.old_version, version_change_nullable_version_value(scope, parsed.init.new_version), diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs index fa077153e0..13f45a7a21 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs @@ -11,6 +11,11 @@ pub(in crate::context_bootstrap) fn install_indexed_db_template_bindings<'s>( interface_name: &str, ) { let prototype = template.prototype_template(scope); + super::super::event_target::install_version_change_event_template_bindings( + scope, + prototype, + interface_name, + ); install_dom_string_list_template_bindings(scope, prototype, interface_name); factory_request::install_factory_and_request_template_bindings( scope,