mirror of
https://github.com/lexmount/moli.git
synced 2026-10-16 00:01:20 +00:00
test(events): cover native abort event trust and dispatch cleanup
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
async function runAbortSignalEventProbe(scenario) {
|
||||
if (typeof document !== 'undefined' && document.readyState !== 'complete') {
|
||||
await new Promise(resolve => addEventListener('load', resolve, {once: true}));
|
||||
}
|
||||
// Let document lifecycle events finish before replacing the public constructor.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const errors = [];
|
||||
const events = [];
|
||||
let reads = 0;
|
||||
const check = (condition, label) => { if (!condition) errors.push(label); };
|
||||
const during = (event, signal, trusted, label) => {
|
||||
check(event.target === signal && event.srcElement === signal, label + ': target');
|
||||
check(event.currentTarget === signal && event.eventPhase === 2, label + ': dispatch fields');
|
||||
check(event.isTrusted === trusted, label + ': trust');
|
||||
const path = event.composedPath();
|
||||
check(path.length === 1 && path[0] === signal, label + ': path');
|
||||
};
|
||||
const after = (event, signal, label) => {
|
||||
check(event.target === signal, label + ': target retained');
|
||||
check(event.currentTarget === null && event.eventPhase === 0, label + ': dispatch fields cleared');
|
||||
check(event.composedPath().length === 0 && !event.cancelBubble, label + ': propagation cleared');
|
||||
};
|
||||
const throws = (callback, name, label) => {
|
||||
let caught = false;
|
||||
try { callback(); }
|
||||
catch (error) { caught = true; check(error.name === name, label + ': exception type'); }
|
||||
check(caught, label + ': throws');
|
||||
};
|
||||
|
||||
try {
|
||||
if (scenario.startsWith('native-')) {
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'Event');
|
||||
const observed = [];
|
||||
const onerror = event => { errors.push('uncaught: ' + event.message); event.preventDefault(); };
|
||||
addEventListener('error', onerror);
|
||||
let deadline;
|
||||
try {
|
||||
if (scenario === 'native-getter') {
|
||||
Object.defineProperty(globalThis, 'Event', {configurable: true, get() {
|
||||
reads++; throw new Error('public Event getter');
|
||||
}});
|
||||
} else if (scenario === 'native-replacement') {
|
||||
Object.defineProperty(globalThis, 'Event', {configurable: true, value: function () {
|
||||
reads++; throw new Error('public Event constructor');
|
||||
}});
|
||||
} else if (scenario === 'native-missing') {
|
||||
delete globalThis.Event;
|
||||
}
|
||||
const record = (signal, label) => signal.addEventListener('abort', function (event) {
|
||||
events.push(label);
|
||||
observed.push([event, signal, label]);
|
||||
during(event, signal, true, label);
|
||||
check(this === signal && event.type === 'abort', label + ': callback');
|
||||
check(!event.bubbles && !event.cancelable && !event.composed, label + ': defaults');
|
||||
event.preventDefault();
|
||||
check(!event.defaultPrevented, label + ': uncancelable');
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const composite = AbortSignal.any([controller.signal]);
|
||||
record(controller.signal, 'source');
|
||||
record(composite, 'composite');
|
||||
const reason = {cancel: true};
|
||||
controller.abort(reason);
|
||||
check(events.join(',') === 'source,composite', 'controller events are synchronous');
|
||||
check(composite.reason === reason, 'composite reason identity');
|
||||
const writable = new WritableStream({start(controller) { record(controller.signal, 'stream'); }});
|
||||
await writable.abort(reason);
|
||||
const timeout = AbortSignal.timeout(0);
|
||||
record(timeout, 'timeout');
|
||||
await new Promise(resolve => {
|
||||
deadline = setTimeout(() => { errors.push('timeout abort event missing'); resolve(); }, 500);
|
||||
timeout.onabort = () => { clearTimeout(deadline); resolve(); };
|
||||
});
|
||||
check(timeout.reason.name === 'TimeoutError', 'timeout reason');
|
||||
// Promise reactions can run at callback cleanup before native dispatch ends.
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
Object.defineProperty(globalThis, 'Event', original);
|
||||
removeEventListener('error', onerror);
|
||||
}
|
||||
check(reads === 0, 'native event construction does not access public Event');
|
||||
check(new Set(observed.map(([event]) => event)).size === 4, 'each signal gets a distinct event');
|
||||
for (const [event, signal, label] of observed) {
|
||||
check(event instanceof Event, label + ': intrinsic prototype');
|
||||
check(event.isTrusted, label + ': stays trusted after native dispatch');
|
||||
after(event, signal, label);
|
||||
}
|
||||
} else if (scenario === 'script') {
|
||||
const source = new AbortController();
|
||||
const recipient = new AbortController();
|
||||
let nativeEvent;
|
||||
source.signal.addEventListener('abort', event => {
|
||||
nativeEvent = event;
|
||||
events.push('native');
|
||||
during(event, source.signal, true, 'native');
|
||||
throws(() => recipient.signal.dispatchEvent(event), 'InvalidStateError', 'active redispatch');
|
||||
during(event, source.signal, true, 'rejected redispatch preserves active event');
|
||||
event.initEvent('changed', true, true);
|
||||
check(event.type === 'abort' && !event.bubbles && !event.cancelable, 'active initEvent is ignored');
|
||||
event.stopImmediatePropagation();
|
||||
});
|
||||
source.signal.addEventListener('abort', () => errors.push('stopped listener fired'));
|
||||
source.abort('reason');
|
||||
after(nativeEvent, source.signal, 'native');
|
||||
recipient.signal.onabort = event => {
|
||||
events.push('replayed');
|
||||
during(event, recipient.signal, false, 'replayed');
|
||||
check(!recipient.signal.aborted && recipient.signal.reason === undefined, 'script event leaves signal pending');
|
||||
event.stopPropagation();
|
||||
};
|
||||
check(recipient.signal.dispatchEvent(nativeEvent), 'replayed abort is uncancelable');
|
||||
check(!nativeEvent.isTrusted, 'redispatch clears trust');
|
||||
after(nativeEvent, recipient.signal, 'replayed');
|
||||
const cancelable = new Event('probe', {cancelable: true});
|
||||
recipient.signal.addEventListener('probe', event => {
|
||||
events.push('passive');
|
||||
during(event, recipient.signal, false, 'synthetic');
|
||||
const before = event.defaultPrevented;
|
||||
event.preventDefault();
|
||||
check(event.defaultPrevented === before, 'passive cancellation is ignored');
|
||||
}, {passive: true});
|
||||
recipient.signal.addEventListener('probe', event => {
|
||||
events.push('active');
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
}, {once: true});
|
||||
recipient.signal.addEventListener('probe', () => events.push('tail'));
|
||||
check(!recipient.signal.dispatchEvent(cancelable), 'canceling a synthetic event returns false');
|
||||
after(cancelable, recipient.signal, 'synthetic');
|
||||
check(!recipient.signal.dispatchEvent(cancelable), 'redispatch preserves cancellation');
|
||||
after(cancelable, recipient.signal, 'synthetic again');
|
||||
const stopped = new Event('pre-stopped');
|
||||
recipient.signal.addEventListener('pre-stopped', () => events.push('pre-stopped'));
|
||||
stopped.stopPropagation();
|
||||
recipient.signal.dispatchEvent(stopped);
|
||||
check(!events.includes('pre-stopped'), 'pre-stopped event skips listeners');
|
||||
after(stopped, recipient.signal, 'pre-stopped');
|
||||
recipient.signal.dispatchEvent(stopped);
|
||||
check(events.includes('pre-stopped'), 'pre-stopped event can be dispatched again');
|
||||
for (const value of [null, undefined, {}, {type: 'abort'}, Object.create(Event.prototype)]) {
|
||||
throws(() => recipient.signal.dispatchEvent(value), 'TypeError', 'non-Event argument');
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
throws(() => recipient.signal.dispatchEvent(document.createEvent('Event')), 'InvalidStateError', 'uninitialized Event');
|
||||
}
|
||||
} else {
|
||||
errors.push('unknown scenario ' + scenario);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(String(error));
|
||||
}
|
||||
return {errors, events, reads};
|
||||
}
|
||||
@@ -49,3 +49,61 @@ async fn abort_signal_any_runs_listener_removal_before_dispatch_and_snapshots_ea
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn assert_abort_signal_events(scenarios: &[&str], events: &[&str]) -> Result<()> {
|
||||
let server = FixtureServer::spawn().await?;
|
||||
let browser = Browser::new(AppConfig::default())?;
|
||||
for scenario in scenarios {
|
||||
let source = format!(
|
||||
"{}\nrunAbortSignalEventProbe({}).then(finish, error => finish({{error: String(error)}}));",
|
||||
include_str!("../fixtures/runtime/abort_signal_events.js"),
|
||||
serde_json::to_string(scenario)?,
|
||||
);
|
||||
for target in ["window", "child", "worker"] {
|
||||
let observed = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
super::pipe_disturbed::run_probe(&browser, &server, target, &source),
|
||||
)
|
||||
.await??;
|
||||
assert_eq!(
|
||||
observed,
|
||||
serde_json::json!({"errors": [], "events": events, "reads": 0}),
|
||||
"{scenario}/{target}"
|
||||
);
|
||||
}
|
||||
}
|
||||
server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn abort_signal_native_events_use_intrinsic_constructors_and_preserve_trust() -> Result<()> {
|
||||
assert_abort_signal_events(
|
||||
&[
|
||||
"native-original",
|
||||
"native-getter",
|
||||
"native-replacement",
|
||||
"native-missing",
|
||||
],
|
||||
&["source", "composite", "stream", "timeout"],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn abort_signal_script_dispatch_validates_events_resets_trust_and_clears_dispatch_state()
|
||||
-> Result<()> {
|
||||
assert_abort_signal_events(
|
||||
&["script"],
|
||||
&[
|
||||
"native",
|
||||
"replayed",
|
||||
"passive",
|
||||
"active",
|
||||
"passive",
|
||||
"tail",
|
||||
"pre-stopped",
|
||||
],
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user