feat(payment): implement native payment update event values

This commit is contained in:
ldm0
2026-10-13 14:46:05 +08:00
committed by Donough Liu
parent 7ffb0e86b5
commit 41e665dc53
11 changed files with 319 additions and 0 deletions
@@ -127,6 +127,12 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>(
ConstructorKind::ToolCancelEvent => {
build_value_event_template(scope, ValueEventKind::ToolCancel)
}
ConstructorKind::PaymentRequestUpdateEvent => {
build_value_event_template(scope, ValueEventKind::PaymentRequestUpdate)
}
ConstructorKind::PaymentMethodChangeEvent => {
build_value_event_template(scope, ValueEventKind::PaymentMethodChange)
}
ConstructorKind::UiEvent => {
build_event_subclass_template(scope, EventSubclassKind::UiEvent)
}
@@ -21,6 +21,9 @@ const TOOL_NAME_SLOT: &str = "__moliToolEventName";
const GAMEPAD_SLOT: &str = "__moliValueEventGamepad";
const PICTURE_IN_PICTURE_WINDOW_SLOT: &str = "__moliPictureInPictureEventWindow";
const PAYMENT_METHOD_NAME_SLOT: &str = "__moliPaymentMethodChangeEventMethodName";
const PAYMENT_METHOD_DETAILS_SLOT: &str = "__moliPaymentMethodChangeEventMethodDetails";
#[derive(Clone, Copy)]
pub(in crate::context_bootstrap) enum ValueEventKind {
Animation,
@@ -31,6 +34,8 @@ pub(in crate::context_bootstrap) enum ValueEventKind {
ToolCancel,
Gamepad,
PictureInPicture,
PaymentRequestUpdate,
PaymentMethodChange,
}
impl ValueEventKind {
@@ -44,6 +49,8 @@ impl ValueEventKind {
Self::ToolCancel => "ToolCancelEvent",
Self::Gamepad => "GamepadEvent",
Self::PictureInPicture => "PictureInPictureEvent",
Self::PaymentRequestUpdate => "PaymentRequestUpdateEvent",
Self::PaymentMethodChange => "PaymentMethodChangeEvent",
}
}
@@ -219,6 +226,59 @@ struct PictureInPictureEventInit<'s> {
picture_in_picture_window: v8::Local<'s, v8::Object>,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::PaymentMethodChangeEvent, enumerable, receiver)]
struct PaymentMethodChangeEventPrototypeDeclaration {
#[webapi(accessor_property = "methodName", getter = payload_getter, data = v8str(scope, PAYMENT_METHOD_NAME_SLOT))]
method_name: (),
#[webapi(accessor_property = "methodDetails", getter = payload_getter, data = v8str(scope, PAYMENT_METHOD_DETAILS_SLOT))]
method_details: (),
}
#[derive(webidl::WebIdlDictionary)]
#[webidl(prefix = "PaymentMethodChangeEventInit")]
struct PaymentMethodChangeEventInit<'s> {
#[webidl(inherit)]
base: EventInit,
#[webidl(nullable)]
method_details: Option<v8::Local<'s, v8::Object>>,
#[webidl(with = string_member)]
method_name: v8::Local<'s, v8::String>,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::PaymentRequestUpdateEvent, enumerable, receiver)]
struct PaymentRequestUpdateEventPrototypeDeclaration {
#[webapi(method, length = 1, callback = payment_update_with_callback)]
update_with: (),
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "PaymentRequestUpdateEvent.updateWith")]
struct PaymentUpdateWithArgs<'s> {
#[webidl(required)]
_details_promise: v8::Local<'s, v8::Promise>,
}
fn payment_update_with_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
if webidl::parse_args::<PaymentUpdateWithArgs>(scope, &args).is_none() {
return;
}
// Only author-created payment events are currently produced. They must
// reject updates even during dispatch, after Web IDL Promise conversion.
// Trusted payment events require an interactive PaymentRequest backend.
crate::native_bridge::throw_dom_exception(
scope,
"InvalidStateError",
11,
"updateWith requires a trusted event associated with an interactive payment request",
);
}
fn string_member<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
@@ -265,6 +325,16 @@ pub(in crate::context_bootstrap) fn install_value_event_template_bindings<'s>(
"GamepadEvent" => {
GamepadEventPrototypeDeclaration::initialize_prototype_template(scope, prototype)
}
"PaymentRequestUpdateEvent" => {
PaymentRequestUpdateEventPrototypeDeclaration::initialize_prototype_template(
scope, prototype,
)
}
"PaymentMethodChangeEvent" => {
PaymentMethodChangeEventPrototypeDeclaration::initialize_prototype_template(
scope, prototype,
)
}
_ => {}
}
}
@@ -306,6 +376,8 @@ fn value_event_constructor<'s>(
Some(5) => ValueEventKind::ToolCancel,
Some(6) => ValueEventKind::Gamepad,
Some(7) => ValueEventKind::PictureInPicture,
Some(8) => ValueEventKind::PaymentRequestUpdate,
Some(9) => ValueEventKind::PaymentMethodChange,
_ => return,
};
if !args.is_construct_call() {
@@ -427,6 +499,32 @@ fn value_event_constructor<'s>(
parsed.base.composed,
)
}
ValueEventKind::PaymentRequestUpdate => {
// PaymentRequestUpdateEventInit adds no members to EventInit.
let parsed = webidl::parse_dictionary_object::<EventInit>(scope, dictionary)?;
(parsed.bubbles, parsed.cancelable, parsed.composed)
}
ValueEventKind::PaymentMethodChange => {
let parsed = webidl::parse_dictionary_object::<PaymentMethodChangeEventInit>(
scope, dictionary,
)?;
let details = parsed
.method_details
.map(Into::into)
.unwrap_or_else(|| v8::null(scope).into());
set_event_private_value(scope, state, PAYMENT_METHOD_DETAILS_SLOT, details);
set_event_private_value(
scope,
state,
PAYMENT_METHOD_NAME_SLOT,
parsed.method_name.into(),
);
(
parsed.base.bubbles,
parsed.base.cancelable,
parsed.base.composed,
)
}
ValueEventKind::Gamepad => {
let parsed =
webidl::parse_dictionary_object::<GamepadEventInit>(scope, dictionary)?;
@@ -107,6 +107,8 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] =
];
const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[
"PaymentRequestUpdateEvent",
"PaymentMethodChangeEvent",
"AuthenticatorAssertionResponse",
"AuthenticatorAttestationResponse",
"AuthenticatorResponse",
@@ -4,6 +4,14 @@ use crate::web_api_interfaces;
use std::collections::HashSet;
const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[
ConstructorSpec {
interface: web_api_interfaces::PaymentRequestUpdateEvent::DESCRIPTOR,
kind: ConstructorKind::PaymentRequestUpdateEvent,
},
ConstructorSpec {
interface: web_api_interfaces::PaymentMethodChangeEvent::DESCRIPTOR,
kind: ConstructorKind::PaymentMethodChangeEvent,
},
ConstructorSpec {
interface: web_api_interfaces::PictureInPictureEvent::DESCRIPTOR,
kind: ConstructorKind::PictureInPictureEvent,
@@ -15,6 +15,8 @@ pub(in crate::context_bootstrap) enum ConstructorKind {
ToolCancelEvent,
GamepadEvent,
PictureInPictureEvent,
PaymentRequestUpdateEvent,
PaymentMethodChangeEvent,
UiEvent,
FocusEvent,
CompositionEvent,
@@ -2246,3 +2246,5 @@ mod dom_rect_structured_clone;
mod domrect_receiver_consolidation;
mod resize_observer_entries;
mod payment_events;
@@ -0,0 +1,100 @@
(async function paymentEventChecks(realms) {
const checks = [];
const check = (name, passed) => checks.push({name, passed: Boolean(passed)});
const throws = (name, action, Constructor, expectedName) => {
let error;
try { action(); } catch (caught) { error = caught; }
check(name, error instanceof Constructor && (!expectedName || error.name === expectedName));
};
for (let ownerIndex = 0; ownerIndex < realms.length; ownerIndex++) {
const owner = realms[ownerIndex];
for (const name of ['PaymentRequestUpdateEvent', 'PaymentMethodChangeEvent']) {
const C = owner[name], p = C.prototype;
const prefix = ownerIndex + ':' + name + ':';
check(prefix + 'constructor metadata', C.length === 1 && C.name === name);
const empty = new C('test');
check(prefix + 'Event inheritance', empty instanceof owner.Event && empty instanceof owner.PaymentRequestUpdateEvent);
check(prefix + 'default event header', empty.type === 'test' && !empty.bubbles && !empty.cancelable && !empty.composed && !empty.isTrusted && empty.target === null);
for (const init of [undefined, null, {}]) {
const event = new C('test', init);
check(prefix + 'empty dictionary ' + String(init), !event.bubbles && !event.cancelable && !event.composed);
if (name === 'PaymentMethodChangeEvent') check(prefix + 'payload defaults ' + String(init), event.methodName === '' && event.methodDetails === null);
}
throws(prefix + 'requires new', () => C('test'), owner.TypeError);
throws(prefix + 'requires type', () => new C(), owner.TypeError);
const order = [];
const details = new Proxy({}, {get() {throw Error('payload object must not be read');}});
const init = {get bubbles() {order.push('bubbles');return true;}, get cancelable() {order.push('cancelable');return true;}, get composed() {order.push('composed');return true;}, get methodDetails() {order.push('methodDetails');return details;}, get methodName() {order.push('methodName');return {toString() {order.push('name-string');return 'pay\ud800';}};}};
const event = new C({toString() {order.push('type');return 'change\udc00';}}, init);
const expectedOrder = ['type', 'bubbles', 'cancelable', 'composed'];
if (name === 'PaymentMethodChangeEvent') expectedOrder.push('methodDetails', 'methodName', 'name-string');
check(prefix + 'dictionary conversion order', order.join() === expectedOrder.join());
check(prefix + 'UTF16 event type', event.type === 'change\udc00');
check(prefix + 'event flags', event.bubbles && event.cancelable && event.composed && !event.isTrusted);
if (name === 'PaymentMethodChangeEvent') {
check(prefix + 'payload identity and UTF16', event.methodDetails === details && event.methodName === 'pay\ud800');
for (const value of [undefined, null, {}, [], () => {}, new Proxy({}, {})]) {
const result = new C('x', {methodDetails: value});
check(prefix + 'nullable object ' + String(typeof value), result.methodDetails === (value == null ? null : value));
}
const revoked = Proxy.revocable({}, {}); revoked.revoke();
check(prefix + 'revoked object payload retained', new C('x', {methodDetails: revoked.proxy}).methodDetails === revoked.proxy);
for (const value of [true, 1, 'x', Symbol('s'), 2n]) throws(prefix + 'reject primitive payload ' + typeof value, () => new C('x', {methodDetails: value}), owner.TypeError);
const token = {};
let nameRead = false, caught;
try {new C('x', {get methodDetails() {throw token;}, get methodName() {nameRead = true;return 'x';}});} catch(error) {caught = error;}
check(prefix + 'dictionary exception and stop order', caught === token && !nameRead);
}
const target = new owner.EventTarget();
const seen = [];
target.addEventListener(event.type, received => {seen.push(received === event && received.target === target && received.currentTarget === target);});
target.dispatchEvent(event);
check(prefix + 'native EventTarget dispatch', seen.length === 1 && seen[0] && event.target === target && event.currentTarget === null && !event.isTrusted);
for (let calleeIndex = 0; calleeIndex < realms.length; calleeIndex++) {
const callee = realms[calleeIndex], q = callee.PaymentRequestUpdateEvent.prototype;
const label = prefix + 'callee-' + calleeIndex + ':';
const update = q.updateWith;
const descriptor = Object.getOwnPropertyDescriptor(q, 'updateWith');
check(label + 'operation descriptor', descriptor.enumerable && descriptor.configurable && descriptor.writable && update.length === 1 && update.name === 'updateWith');
const invalid = [null, undefined, {}, Object.create(p), Object.create(event), new Proxy(event, {})];
const revoked = Proxy.revocable(event, {}); revoked.revoke(); invalid.push(revoked.proxy);
for (let i = 0; i < invalid.length; i++) {
let reads = 0;
throws(label + 'reject receiver before conversion ' + i, () => update.call(invalid[i], {get then() {reads++;throw Error('conversion');}}), callee.TypeError);
check(label + 'no receiver conversion ' + i, reads === 0);
}
throws(label + 'missing argument', () => update.call(event), callee.TypeError);
for (const value of [undefined, null, {}, 1]) throws(label + 'untrusted value ' + typeof value, () => update.call(event, value), callee.DOMException, 'InvalidStateError');
const calls = [];
const value = {get then() {calls.push('get');return resolve => {calls.push('call');resolve({});};}};
throws(label + 'thenable update error', () => update.call(event, value), callee.DOMException, 'InvalidStateError');
check(label + 'then getter before state check', calls.join() === 'get');
await Promise.resolve(); await Promise.resolve();
check(label + 'then invoked asynchronously', calls.join() === 'get,call');
const nativeCalls = [];
const promise = owner.Promise.resolve({});
Object.defineProperty(promise, 'then', {get() {nativeCalls.push('get');return resolve => {nativeCalls.push('call');resolve({});};}});
Object.defineProperty(promise, 'constructor', {get() {throw Error('Promise constructor must not be read');}});
throws(label + 'native promise update error', () => update.call(event, promise), callee.DOMException, 'InvalidStateError');
check(label + 'WebIDL native Promise wrapping', nativeCalls.join() === 'get');
await Promise.resolve(); await Promise.resolve();
check(label + 'native Promise then invoked asynchronously', nativeCalls.join() === 'get,call');
const delivered = new C('during');
const dispatchOrder = [];
target.addEventListener('during', () => {throws(label + 'untrusted during dispatch', () => update.call(delivered, {}), callee.DOMException, 'InvalidStateError');dispatchOrder.push(1);}, {once:true});
target.addEventListener('during', () => dispatchOrder.push(2), {once:true});
target.dispatchEvent(delivered);
check(label + 'failed update leaves propagation intact', dispatchOrder.join() === '1,2');
if (name === 'PaymentMethodChangeEvent') {
for (const key of ['methodName', 'methodDetails']) {
const desc = Object.getOwnPropertyDescriptor(callee.PaymentMethodChangeEvent.prototype, key);
check(label + key + ':accessor descriptor', desc.enumerable && desc.configurable && desc.set === undefined);
check(label + key + ':cross-realm getter', desc.get.call(event) === event[key]);
for (let i = 0; i < invalid.length; i++) throws(label + key + ':brand ' + i, () => desc.get.call(invalid[i]), callee.TypeError);
}
}
}
}
}
return checks;
})
@@ -0,0 +1,81 @@
use super::*;
#[test]
fn payment_update_event_payloads_and_operations_use_native_cross_realm_brands() {
let mut vm = new_storage_page_task_executor_test_vm("https://payment-events.test/");
vm.eval("document.body.innerHTML = '<iframe id=child></iframe>'")
.unwrap();
let source = format!(
"({})([globalThis, document.getElementById('child').contentWindow]).then(checks => {{globalThis.__paymentChecks=checks;}})",
include_str!("payment_events.js"),
);
vm.eval(&source).unwrap();
assert_eq!(vm.eval("typeof __paymentChecks").unwrap(), "object");
// Keep the native-Promise wrapping gaps explicit while the shared converter
// still preserves native Promise inputs. Every other check must pass.
let mut known_gaps = Vec::new();
for owner in 0..2 {
for event in ["PaymentRequestUpdateEvent", "PaymentMethodChangeEvent"] {
for callee in 0..2 {
for check in [
"WebIDL native Promise wrapping",
"native Promise then invoked asynchronously",
] {
known_gaps.push(format!("{owner}:{event}:callee-{callee}:{check}"));
}
}
}
}
known_gaps.sort();
assert_eq!(
vm.eval(
"JSON.stringify(__paymentChecks.filter(row => !row.passed).map(row => row.name).sort())"
)
.unwrap(),
serde_json::to_string(&known_gaps).unwrap()
);
assert_eq!(vm.eval("__paymentChecks.length").unwrap(), "378");
}
#[test]
fn payment_event_payloads_and_update_with_accept_registered_native_proxies() {
let mut vm = new_storage_page_task_executor_test_vm("https://payment-native-proxy.test/");
vm.eval("globalThis.paymentEvent = new PaymentMethodChangeEvent('x', {methodName:'pay', methodDetails:{token:42}})")
.unwrap();
let context_ptr = &vm.page_default_runtime.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, "paymentEvent");
let event =
v8::Local::<v8::Object>::try_from(global.get(scope, key.into()).unwrap()).unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, event, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
let key = crate::util::v8str(scope, "paymentProxy");
assert_eq!(
global.create_data_property(scope, key.into(), proxy.into()),
Some(true)
);
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"(() => {
const p=PaymentMethodChangeEvent.prototype;
for(const key of ['methodName','methodDetails']) {
const get=Object.getOwnPropertyDescriptor(p,key).get;
if(get.call(paymentProxy)!==paymentEvent[key]) throw Error('native identity');
let caught=false;try{get.call(new Proxy(paymentProxy,{}));}catch(error){caught=error instanceof TypeError;}
if(!caught) throw Error('author proxy accepted');
}
let gets=0, error;
try{PaymentRequestUpdateEvent.prototype.updateWith.call(paymentProxy,{get then(){gets++;return undefined;}});}catch(caught){error=caught;}
if(gets!==1 || !(error instanceof DOMException) || error.name!=='InvalidStateError') throw Error('native proxy operation');
return true;
})()"#).unwrap(), "true");
}
#[test]
fn payment_event_constructors_are_absent_in_insecure_window_realms() {
let mut vm = new_storage_page_task_executor_test_vm("http://payment-events.test/");
assert_eq!(vm.eval("[isSecureContext, 'PaymentRequestUpdateEvent' in globalThis, 'PaymentMethodChangeEvent' in globalThis].join(',')").unwrap(), "false,false,false");
}
@@ -193,6 +193,8 @@ interfaces! {
GamepadButton;
GamepadEvent: Event;
PictureInPictureEvent: Event;
PaymentRequestUpdateEvent: Event;
PaymentMethodChangeEvent: PaymentRequestUpdateEvent;
PictureInPictureWindow: EventTarget;
GamepadHapticActuator;
HTMLAllCollection;
@@ -1541,3 +1541,5 @@ mod tls;
mod canvas_fill_rect;
mod xhr_failure;
mod payment_events;
@@ -0,0 +1,16 @@
use super::*;
#[tokio::test]
async fn payment_update_event_interfaces_are_not_exposed_in_workers() {
ensure_v8();
let mut worker = spawn_worker(
"postMessage([typeof PaymentRequestUpdateEvent, typeof PaymentMethodChangeEvent]);close();"
.into(),
"https://payment-worker.test/".into(),
);
let message = timeout(TIMEOUT, worker.recv()).await.unwrap().unwrap();
assert_eq!(
serde_json::from_str::<serde_json::Value>(&expect_post_json(message)).unwrap(),
serde_json::json!(["undefined", "undefined"])
);
}