diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /testing/web-platform/tests/dom/observable | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/web-platform/tests/dom/observable')
10 files changed, 1994 insertions, 0 deletions
diff --git a/testing/web-platform/tests/dom/observable/tentative/idlharness.html b/testing/web-platform/tests/dom/observable/tentative/idlharness.html new file mode 100644 index 0000000000..9a3842c4e6 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/idlharness.html @@ -0,0 +1,20 @@ +<!doctype html> + <meta charset="utf-8" /> + <meta name="author" title="Keith Cirkel" href="mailto:keithamus@github.com" /> + <link rel="help" href="https://open-ui.org/components/invokers.explainer/" /> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="/resources/WebIDLParser.js"></script> + <script src="/resources/idlharness.js"></script> + + <script> + idl_test(["observable.tentative"], ["dom"], (idl_array) => { + idl_array.add_objects({ + Observable: ["new Observable(() => {})"], + Subscriber: [ + "(() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })()", + ], + }); + }); + </script> +</t> diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-constructor.any.js b/testing/web-platform/tests/dom/observable/tentative/observable-constructor.any.js new file mode 100644 index 0000000000..f108e902b3 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-constructor.any.js @@ -0,0 +1,935 @@ +// Because we test that the global error handler is called at various times. +setup({allow_uncaught_exception: true}); + +test(() => { + assert_implements(self.Observable, "The Observable interface is not implemented"); + + assert_true( + typeof Observable === "function", + "Observable constructor is defined" + ); + + assert_throws_js(TypeError, () => { new Observable(); }); +}, "Observable constructor"); + +test(() => { + let initializerCalled = false; + const source = new Observable(() => { + initializerCalled = true; + }); + + assert_false( + initializerCalled, + "initializer should not be called by construction" + ); + source.subscribe(); + assert_true(initializerCalled, "initializer should be called by subscribe"); +}, "subscribe() can be called with no arguments"); + +test(() => { + assert_implements(self.Subscriber, "The Subscriber interface is not implemented"); + assert_true( + typeof Subscriber === "function", + "Subscriber interface is defined as a function" + ); + + assert_throws_js(TypeError, () => { new Subscriber(); }); + + let initializerCalled = false; + new Observable(subscriber => { + assert_not_equals(subscriber, undefined, "A Subscriber must be passed into the subscribe callback"); + assert_implements(subscriber.next, "A Subscriber object must have a next() method"); + assert_implements(subscriber.complete, "A Subscriber object must have a complete() method"); + assert_implements(subscriber.error, "A Subscriber object must have an error() method"); + initializerCalled = true; + }).subscribe(); + assert_true(initializerCalled, "initializer should be called by subscribe"); +}, "Subscriber interface is not constructible"); + +test(() => { + let initializerCalled = false; + const results = []; + + const source = new Observable((subscriber) => { + initializerCalled = true; + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + }); + + assert_false( + initializerCalled, + "initializer should not be called by construction" + ); + + source.subscribe(x => results.push(x)); + + assert_true(initializerCalled, "initializer should be called by subscribe"); + assert_array_equals( + results, + [1, 2, 3], + "should emit values synchronously, but not complete" + ); +}, "Subscribe with just a function as the next handler"); + +test(() => { + let initializerCalled = false; + const results = []; + + const source = new Observable((subscriber) => { + initializerCalled = true; + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + assert_false( + initializerCalled, + "initializer should not be called by construction" + ); + + source.subscribe({ + next: (x) => results.push(x), + error: () => assert_unreached("error should not be called"), + complete: () => results.push("complete"), + }); + + assert_true(initializerCalled, "initializer should be called by subscribe"); + assert_array_equals( + results, + [1, 2, 3, "complete"], + "should emit values synchronously" + ); +}, "Observable constructor calls initializer on subscribe"); + +test(() => { + const error = new Error("error"); + const results = []; + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.error(error); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (e) => results.push(e), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, error], + "should emit error synchronously" + ); +}, "Observable error path called synchronously"); + +test(() => { + let subscriber; + new Observable(s => { subscriber = s }).subscribe(); + const {next, complete, error} = subscriber; + assert_throws_js(TypeError, () => next(1)); + assert_throws_js(TypeError, () => complete()); + assert_throws_js(TypeError, () => error(1)); +}, "Subscriber must have receiver"); + +test(() => { + let subscriber; + new Observable(s => { subscriber = s }).subscribe(); + assert_throws_js(TypeError, () => subscriber.next()); + assert_throws_js(TypeError, () => subscriber.error()); +}, "Subscriber next & error must recieve argument"); + +test(() => { + let subscriber; + new Observable(s => { subscriber = s }).subscribe(); + assert_true(subscriber.active); + assert_false(subscriber.signal.aborted); + subscriber.complete(); + assert_false(subscriber.active); + assert_true(subscriber.signal.aborted); +}, "Subscriber complete() will set active to false, and abort signal"); + +test(() => { + let subscriber; + new Observable(s => { subscriber = s }).subscribe(); + assert_true(subscriber.active); + subscriber.active = false; + assert_true(subscriber.active); +}, "Subscriber active is readonly"); + +test(() => { + let subscriber; + new Observable(s => { subscriber = s }).subscribe(); + assert_false(subscriber.signal.aborted); + const oldSignal = subscriber.signal; + const newSignal = AbortSignal.abort(); + subscriber.signal = newSignal; + assert_false(subscriber.signal.aborted); + assert_equals(subscriber.signal, oldSignal, "signal did not change"); +}, "Subscriber signal is readonly"); + +test(() => { + const error = new Error("error"); + const results = []; + let errorReported = null; + let innerSubscriber = null; + let subscriptionActivityInFinallyAfterThrow; + let subscriptionActivityInErrorHandlerAfterThrow; + + self.addEventListener("error", e => errorReported = e, {once: true}); + + const source = new Observable((subscriber) => { + innerSubscriber = subscriber; + subscriber.next(1); + try { + throw error; + } finally { + subscriptionActivityInFinallyAfterThrow = subscriber.active; + } + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (e) => { + subscriptionActivityInErrorHandlerAfterThrow = innerSubscriber.active; + results.push(e); + }, + complete: () => assert_unreached("complete should not be called"), + }); + + assert_equals(errorReported, null, "The global error handler should not be " + + "invoked when the subscribe callback throws an error and the " + + "subscriber has given an error handler"); + assert_true(subscriptionActivityInFinallyAfterThrow, "Subscriber is " + + "considered active in finally block before error handler is invoked"); + assert_false(subscriptionActivityInErrorHandlerAfterThrow, "Subscriber is " + + "considered inactive in error handler block after thrown error"); + assert_array_equals( + results, + [1, error], + "should emit values and the thrown error synchronously" + ); +}, "Observable should error if initializer throws"); + +test(t => { + let innerSubscriber = null; + let activeBeforeComplete = false; + let activeAfterComplete = false; + let activeDuringComplete = false; + let abortedBeforeComplete = false; + let abortedDuringComplete = false; + let abortedAfterComplete = false; + + const source = new Observable((subscriber) => { + innerSubscriber = subscriber; + activeBeforeComplete = subscriber.active; + abortedBeforeComplete = subscriber.signal.aborted; + + subscriber.complete(); + activeAfterComplete = subscriber.active; + abortedAfterComplete = subscriber.signal.aborted; + }); + + source.subscribe({ + complete: () => { + activeDuringComplete = innerSubscriber.active + abortedDuringComplete = innerSubscriber.active + } + }); + assert_true(activeBeforeComplete, "Subscription is active before complete"); + assert_false(abortedBeforeComplete, "Subscription is not aborted before complete"); + assert_false(activeDuringComplete, "Subscription is not active during complete"); + assert_false(abortedDuringComplete, "Subscription is not aborted during complete"); + assert_false(activeAfterComplete, "Subscription is not active after complete"); + assert_true(abortedAfterComplete, "Subscription is aborted after complete"); +}, "Subscription is inactive after complete()"); + +test(t => { + let innerSubscriber = null; + let activeBeforeError = false; + let activeAfterError = false; + let activeDuringError = false; + let abortedBeforeError = false; + let abortedDuringError = false; + let abortedAfterError = false; + + const error = new Error("error"); + const source = new Observable((subscriber) => { + innerSubscriber = subscriber; + activeBeforeError = subscriber.active; + abortedBeforeError = subscriber.signal.aborted; + + subscriber.error(error); + activeAfterError = subscriber.active; + abortedAfterError = subscriber.signal.aborted; + }); + + source.subscribe({ + error: () => { + activeDuringError = innerSubscriber.active + } + }); + assert_true(activeBeforeError, "Subscription is active before error"); + assert_false(abortedBeforeError, "Subscription is not aborted before error"); + assert_false(activeDuringError, "Subscription is not active during error"); + assert_false(abortedDuringError, "Subscription is not aborted during error"); + assert_false(activeAfterError, "Subscription is not active after error"); + assert_true(abortedAfterError, "Subscription is not aborted after error"); +}, "Subscription is inactive after error()"); + +test(t => { + let innerSubscriber; + let initialActivity; + let initialSignalAborted; + + const source = new Observable((subscriber) => { + innerSubscriber = subscriber; + initialActivity = subscriber.active; + initialSignalAborted = subscriber.signal.aborted; + }); + + source.subscribe({}, {signal: AbortSignal.abort('Initially aborted')}); + assert_false(initialActivity); + assert_true(initialSignalAborted); + assert_equals(innerSubscriber.signal.reason, 'Initially aborted'); +}, "Subscription is inactive when aborted signal is passed in"); + +test(() => { + let outerSubscriber = null; + + const source = new Observable(subscriber => outerSubscriber = subscriber); + + const controller = new AbortController(); + source.subscribe({}, {signal: controller.signal}); + + assert_not_equals(controller.signal, outerSubscriber.signal); +}, "Subscriber#signal is not the same AbortSignal as the one passed into `subscribe()`"); + +test(() => { + const results = []; + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.complete(); + subscriber.next(3); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: () => assert_unreached("error should not be called"), + complete: () => results.push("complete"), + }); + + assert_array_equals( + results, + [1, 2, "complete"], + "should emit values synchronously, but not nexted values after complete" + ); +}, "Subscription does not emit values after completion"); + +test(() => { + const error = new Error("error"); + const results = []; + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.error(error); + subscriber.next(3); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (e) => results.push(e), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, error], + "should emit values synchronously, but not nexted values after error" + ); +}, "Subscription does not emit values after error"); + +test(() => { + const error = new Error("error"); + const results = []; + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.error(error); + assert_false(subscriber.active, "subscriber is closed after error"); + subscriber.next(3); + subscriber.complete(); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals(results, [1, 2, error], "should emit synchronously"); +}, "Completing or nexting a subscriber after an error does nothing"); + +test(() => { + const error = new Error("custom error"); + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.error(error); + }); + + // No error handler provided... + source.subscribe({ + next: () => assert_unreached("next should not be called"), + complete: () => assert_unreached("complete should not be called"), + }); + + // ... still the exception is reported to the global. + assert_true(errorReported !== null, "Exception was reported to global"); + assert_equals(errorReported.message, "Uncaught Error: custom error", "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Errors pushed to the subscriber that are not handled by the subscription " + + "are reported to the global"); + +test(() => { + const error = new Error("custom error"); + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + throw error; + }); + + // No error handler provided... + source.subscribe({ + next: () => assert_unreached("next should not be called"), + complete: () => assert_unreached("complete should not be called"), + }); + + // ... still the exception is reported to the global. + assert_true(errorReported !== null, "Exception was reported to global"); + assert_equals(errorReported.message, "Uncaught Error: custom error", "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Errors thrown in the initializer that are not handled by the " + + "subscription are reported to the global"); + +test(() => { + const error = new Error("custom error"); + const results = []; + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.complete(); + subscriber.error(error); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: () => assert_unreached("error should not be called"), + complete: () => results.push("complete"), + }); + + assert_array_equals( + results, + [1, 2, "complete"], + "should emit values synchronously, but not error values after complete" + ); + + // Error reporting still happens even after the subscription is closed. + assert_true(errorReported !== null, "Exception was reported to global"); + assert_equals(errorReported.message, "Uncaught Error: custom error", "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Subscription reports errors that are pushed after subscriber is closed " + + "by completion"); + +test(t => { + const error = new Error("custom error"); + const results = []; + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.complete(); + throw error; + }); + + source.subscribe({ + next: (x) => results.push(x), + error: () => assert_unreached("error should not be called"), + complete: () => results.push("complete"), + }); + + assert_array_equals(results, [1, 2, "complete"], + "should emit values synchronously, but not error after complete" + ); + + assert_true(errorReported !== null, "Exception was reported to global"); + assert_true(errorReported.message.includes("custom error"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Errors thrown by initializer function after subscriber is closed by " + + "completion are reported"); + +test(() => { + const error1 = new Error("error 1"); + const error2 = new Error("error 2"); + const results = []; + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.error(error1); + throw error2; + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, error1], + "should emit values synchronously, but not nexted values after error" + ); + + assert_true(errorReported !== null, "Exception was reported to global"); + assert_true(errorReported.message.includes("error 2"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error2, "Error object is equivalent"); +}, "Errors thrown by initializer function after subscriber is closed by " + + "error are reported"); + +test(() => { + const error1 = new Error("error 1"); + const error2 = new Error("error 2"); + const results = []; + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.error(error1); + subscriber.error(error2); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, error1], + "should emit values synchronously, but not nexted values after error" + ); + + assert_true(errorReported !== null, "Exception was reported to global"); + assert_true(errorReported.message.includes("error 2"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error2, "Error object is equivalent"); +}, "Errors pushed by initializer function after subscriber is closed by " + + "error are reported"); + +test(() => { + const results = []; + const target = new EventTarget(); + + const source = new Observable((subscriber) => { + target.addEventListener('custom event', e => { + subscriber.next(1); + subscriber.complete(); + subscriber.error('not a real error'); + }); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => { + results.push('complete'), + // Re-entrantly tries to invoke `complete()`. However, this function must + // only ever run once. + target.dispatchEvent(new Event('custom event')); + }, + }); + + target.dispatchEvent(new Event('custom event')); + + assert_array_equals( + results, + [1, 'complete'], + "complete() can only be called once, and cannot invoke other Observer methods" + ); +}, "Subscriber#complete() cannot re-entrantly invoke itself"); + +test(() => { + const results = []; + const target = new EventTarget(); + + const source = new Observable((subscriber) => { + target.addEventListener('custom event', e => { + subscriber.next(1); + subscriber.error('not a real error'); + subscriber.complete(); + }); + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => { + results.push('error'), + // Re-entrantly tries to invoke `error()`. However, this function must + // only ever run once. + target.dispatchEvent(new Event('custom event')); + }, + complete: () => results.push('complete'), + }); + + target.dispatchEvent(new Event('custom event')); + + assert_array_equals( + results, + [1, 'error'], + "error() can only be called once, and cannot invoke other Observer methods" + ); +}, "Subscriber#error() cannot re-entrantly invoke itself"); + +test(() => { + const results = []; + let innerSubscriber = null; + let activeDuringTeardown1 = null; + let abortedDuringTeardown1 = null; + let activeDuringTeardown2 = null; + let abortedDuringTeardown2 = null; + + const source = new Observable((subscriber) => { + assert_true(subscriber.active); + assert_false(subscriber.signal.aborted); + results.push('subscribe() callback'); + innerSubscriber = subscriber; + + subscriber.signal.addEventListener('abort', () => { + assert_false(subscriber.active); + assert_true(subscriber.signal.aborted); + results.push('inner abort handler'); + subscriber.next('next from inner abort handler'); + subscriber.complete(); + }); + + subscriber.addTeardown(() => { + activeDuringTeardown1 = subscriber.active; + abortedDuringTeardown1 = subscriber.signal.aborted; + results.push('teardown 1'); + }); + + subscriber.addTeardown(() => { + activeDuringTeardown2 = subscriber.active; + abortedDuringTeardown2 = subscriber.signal.aborted; + results.push('teardown 2'); + }); + }); + + const ac = new AbortController(); + source.subscribe({ + // This should never get called. If it is, the array assertion below will fail. + next: (x) => results.push(x), + complete: () => results.push('complete()') + }, {signal: ac.signal}); + + ac.signal.addEventListener('abort', () => { + results.push('outer abort handler'); + assert_true(ac.signal.aborted); + assert_false(innerSubscriber.signal.aborted); + }); + + assert_array_equals(results, ['subscribe() callback']); + ac.abort(); + results.push('abort() returned'); + assert_array_equals(results, [ + 'subscribe() callback', + 'outer abort handler', 'teardown 2', 'teardown 1', + 'inner abort handler', 'abort() returned', + ]); + assert_false(activeDuringTeardown1, 'should not be active during teardown callback 1'); + assert_false(activeDuringTeardown2, 'should not be active during teardown callback 2'); + assert_true(abortedDuringTeardown1, 'should be aborted during teardown callback 1'); + assert_true(abortedDuringTeardown2, 'should be aborted during teardown callback 2'); +}, "Unsubscription lifecycle"); + +test(t => { + const source = new Observable((subscriber) => { + let n = 0; + while (!subscriber.signal.aborted) { + assert_true(subscriber.active); + subscriber.next(n++); + if (n > 3) { + assert_unreached("The subscriber should be closed by now"); + } + } + assert_false(subscriber.active); + }); + + const ac = new AbortController(); + const results = []; + + source.subscribe({ + next: (x) => { + results.push(x); + if (x === 2) { + ac.abort(); + } + }, + error: () => results.push('error'), + complete: () => results.push('complete') + }, {signal: ac.signal}); + + assert_array_equals( + results, + [0, 1, 2], + "should emit values synchronously before abort" + ); +}, "Aborting a subscription should stop emitting values"); + +test(() => { + const error = new Error("custom error"); + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable(() => { + throw error; + }); + + try { + source.subscribe(); + } catch { + assert_unreached("subscriber() never throws an error"); + } + + assert_true(errorReported !== null, "Exception was reported to global"); + assert_true(errorReported.message.includes("custom error"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Calling subscribe should never throw an error synchronously, initializer throws error"); + +test(() => { + const error = new Error("custom error"); + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable((subscriber) => { + subscriber.error(error); + }); + + try { + source.subscribe(); + } catch { + assert_unreached("subscriber() never throws an error"); + } + + assert_true(errorReported !== null, "Exception was reported to global"); + assert_true(errorReported.message.includes("custom error"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "Calling subscribe should never throw an error synchronously, subscriber pushes error"); + +test(() => { + let addTeardownCalled = false; + let activeDuringTeardown; + + const source = new Observable((subscriber) => { + subscriber.addTeardown(() => { + addTeardownCalled = true; + activeDuringTeardown = subscriber.active; + }); + }); + + const ac = new AbortController(); + source.subscribe({}, {signal: ac.signal}); + + assert_false(addTeardownCalled, "Teardown is not be called upon subscription"); + ac.abort(); + assert_true(addTeardownCalled, "Teardown is called when subscription is aborted"); + assert_false(activeDuringTeardown, "Teardown observers inactive subscription"); +}, "Teardown should be called when subscription is aborted"); + +test(() => { + const addTeardownsCalled = []; + // This is used to snapshot `addTeardownsCalled` from within the subscribe + // callback, for assertion/comparison later. + let teardownsSnapshot = []; + const results = []; + + const source = new Observable((subscriber) => { + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 1")); + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 2")); + + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + + // We don't run the actual `assert_array_equals` here because if it fails, + // it won't be properly caught. This is because assertion failures throw an + // error, and in subscriber callback, thrown errors result in + // `window.onerror` handlers being called, which this test file doesn't + // record as an error (see the first line of this file). + teardownsSnapshot = addTeardownsCalled; + }); + + source.subscribe({ + next: (x) => results.push(x), + error: () => results.push("unreached"), + complete: () => results.push("complete"), + }); + + assert_array_equals( + results, + [1, 2, 3, "complete"], + "should emit values and complete synchronously" + ); + + assert_array_equals(teardownsSnapshot, addTeardownsCalled); + assert_array_equals(addTeardownsCalled, ["teardown 2", "teardown 1"], + "Teardowns called in LIFO order synchronously after complete()"); +}, "Teardowns should be called when subscription is closed by completion"); + +test(() => { + const addTeardownsCalled = []; + let teardownsSnapshot = []; + const error = new Error("error"); + const results = []; + + const source = new Observable((subscriber) => { + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 1")); + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 2")); + + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.error(error); + + teardownsSnapshot = addTeardownsCalled; + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, 3, error], + "should emit values and error synchronously" + ); + + assert_array_equals(teardownsSnapshot, addTeardownsCalled); + assert_array_equals(addTeardownsCalled, ["teardown 2", "teardown 1"], + "Teardowns called in LIFO order synchronously after error()"); +}, "Teardowns should be called when subscription is closed by subscriber pushing an error"); + +test(() => { + const addTeardownsCalled = []; + const error = new Error("error"); + const results = []; + + const source = new Observable((subscriber) => { + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 1")); + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 2")); + + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + throw error; + }); + + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => assert_unreached("complete should not be called"), + }); + + assert_array_equals( + results, + [1, 2, 3, error], + "should emit values and error synchronously" + ); + + assert_array_equals(addTeardownsCalled, ["teardown 2", "teardown 1"], + "Teardowns called in LIFO order synchronously after thrown error"); +}, "Teardowns should be called when subscription is closed by subscriber throwing error"); + +test(() => { + const addTeardownsCalled = []; + const results = []; + let firstTeardownInvokedSynchronously = false; + let secondTeardownInvokedSynchronously = false; + + const source = new Observable((subscriber) => { + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 1")); + if (addTeardownsCalled.length === 1) { + firstTeardownInvokedSynchronously = true; + } + subscriber.addTeardown(() => addTeardownsCalled.push("teardown 2")); + if (addTeardownsCalled.length === 2) { + secondTeardownInvokedSynchronously = true; + } + + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + const ac = new AbortController(); + ac.abort(); + source.subscribe({ + next: (x) => results.push(x), + error: (error) => results.push(error), + complete: () => results.push('complete') + }, {signal: ac.signal}); + + assert_array_equals(results, []); + assert_true(firstTeardownInvokedSynchronously, "First teardown callback is invoked during addTeardown()"); + assert_true(secondTeardownInvokedSynchronously, "Second teardown callback is invoked during addTeardown()"); + assert_array_equals(addTeardownsCalled, ["teardown 1", "teardown 2"], + "Teardowns called synchronously upon addition end up in FIFO order"); +}, "Teardowns should be called synchronously during addTeardown() if the subscription is inactive"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-constructor.window.js b/testing/web-platform/tests/dom/observable/tentative/observable-constructor.window.js new file mode 100644 index 0000000000..d2b597c819 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-constructor.window.js @@ -0,0 +1,127 @@ +async function loadIframeAndReturnContentWindow() { + // Create and attach an iframe. + const iframe = document.createElement('iframe'); + const iframeLoadPromise = new Promise((resolve, reject) => { + iframe.onload = resolve; + iframe.onerror = reject; + }); + document.body.append(iframe); + await iframeLoadPromise; + return iframe.contentWindow; +} + +promise_test(async t => { + // Hang this off of the main document's global, so the child can easily reach + // it. + window.results = []; + const contentWin = await loadIframeAndReturnContentWindow(); + + contentWin.eval(` + // Get a reference to the parent result array before we detach and lose + // access to the parent. + const parentResults = parent.results; + + const source = new Observable((subscriber) => { + parentResults.push("subscribe"); + // Detach the iframe and push a value to the subscriber/Observer. + window.frameElement.remove(); + parentResults.push("detached"); + subscriber.next("next"); + subscriber.complete(); + subscriber.error("error"); + }); + source.subscribe({ + next: v => { + // Should never run. + parentResults.push(v); + }, + complete: () => { + // Should never run. + parentResults.push("complete"); + }, + erorr: e => { + // Should never run. + parentResults.push(e); + } + }); + `); + + assert_array_equals(results, ["subscribe", "detached"]); +}, "No observer handlers can be invoked in detached document"); + +promise_test(async t => { + const contentWin = await loadIframeAndReturnContentWindow(); + + // Set a global error handler on the iframe document's window, and verify that + // it is never called (because the thing triggering the error happens when the + // document is detached, and "reporting the exception" relies on an attached + // document). + contentWin.addEventListener("error", + t.unreached_func("Error should not be called"), { once: true }); + + contentWin.eval(` + const source = new Observable((subscriber) => { + // Detach the iframe and push an error, which would normally "report the + // exception", since this subscriber did not specify an error handler. + window.frameElement.remove(); + subscriber.error("this is an error that should not be reported"); + }); + source.subscribe(); + `); +}, "Subscriber.error() does not \"report the exception\" even when an " + + "`error()` handler is not present, when it is invoked in a detached document"); + +promise_test(async t => { + // Make this available off the global so the child can reach it. + window.results = []; + const contentWin = await loadIframeAndReturnContentWindow(); + + // Set a global error handler on the iframe document's window, and verify that + // it is never called (because the thing triggering the error happens when the + // document is detached, and "reporting the exception" relies on an attached + // document). + contentWin.addEventListener("error", + t.unreached_func("Error should not be called"), { once: true }); + + contentWin.eval(` + const parentResults = parent.results; + const source = new Observable((subscriber) => { + // This should never run. + parentResults.push('subscribe'); + }); + + // Detach the iframe and try to subscribe. + window.frameElement.remove(); + parentResults.push('detached'); + source.subscribe(); + `); + + assert_array_equals(results, ["detached"], "Subscribe callback is never invoked"); +}, "Cannot subscribe to an Observable in a detached document"); + +promise_test(async t => { + // Make this available off the global so the child can reach it. + window.results = []; + const contentWin = await loadIframeAndReturnContentWindow(); + + contentWin.eval(` + const parentResults = parent.results; + const event_target = new EventTarget(); + // Set up two event listeners, both of which will mutate |parentResults|: + // 1. A traditional event listener + // 2. An observable + event_target.addEventListener('customevent', e => parentResults.push(e)); + const source = event_target.on('customevent'); + source.subscribe(e => parentResults.push(e)); + + // Detach the iframe and fire an event at the event target. The parent will + // confirm that the observable's next handler did not get invoked, because + // the window is detached. + const event = new Event('customevent'); + window.frameElement.remove(); + parentResults.push('detached'); + event_target.dispatchEvent(event); + `); + + assert_array_equals(results, ["detached"], "Subscribe callback is never invoked"); +}, "Observable from EventTarget does not get notified for events in detached documents"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-event-target.any.js b/testing/web-platform/tests/dom/observable/tentative/observable-event-target.any.js new file mode 100644 index 0000000000..0f7ace2acc --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-event-target.any.js @@ -0,0 +1,71 @@ +test(() => { + const target = new EventTarget(); + assert_implements(target.on, "The EventTarget interface has an `on` method"); + assert_equals(typeof target.on, "function", + "EventTarget should have the on method"); + + const testEvents = target.on("test"); + assert_true(testEvents instanceof Observable, + "EventTarget.on returns an Observable"); + + const results = []; + testEvents.subscribe({ + next: value => results.push(value), + error: () => results.push("error"), + complete: () => results.push("complete"), + }); + + assert_array_equals(results, [], + "Observable does not emit events until event is fired"); + + const event = new Event("test"); + target.dispatchEvent(event); + assert_array_equals(results, [event]); + + target.dispatchEvent(event); + assert_array_equals(results, [event, event]); +}, "EventTarget.on() returns an Observable"); + +test(() => { + const target = new EventTarget(); + const testEvents = target.on("test"); + const ac = new AbortController(); + const results = []; + testEvents.subscribe({ + next: (value) => results.push(value), + error: () => results.push('error'), + complete: () => results.complete('complete'), + }, { signal: ac.signal }); + + assert_array_equals(results, [], + "Observable does not emit events until event is fired"); + + const event1 = new Event("test"); + const event2 = new Event("test"); + const event3 = new Event("test"); + target.dispatchEvent(event1); + target.dispatchEvent(event2); + + assert_array_equals(results, [event1, event2]); + + ac.abort(); + target.dispatchEvent(event3); + + assert_array_equals(results, [event1, event2], + "Aborting the subscription removes the event listener and stops the " + + "emission of events"); +}, "Aborting the subscription should stop the emission of events"); + +test(() => { + const target = new EventTarget(); + const testEvents = target.on("test"); + const results = []; + testEvents.subscribe(e => results.push(e)); + testEvents.subscribe(e => results.push(e)); + + const event1 = new Event("test"); + const event2 = new Event("test"); + target.dispatchEvent(event1); + target.dispatchEvent(event2); + assert_array_equals(results, [event1, event1, event2, event2]); +}, "EventTarget Observables can multicast subscriptions for event handling"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-event-target.window.js b/testing/web-platform/tests/dom/observable/tentative/observable-event-target.window.js new file mode 100644 index 0000000000..77a137a362 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-event-target.window.js @@ -0,0 +1,39 @@ +test(() => { + // See https://dom.spec.whatwg.org/#dom-event-eventphase. + const CAPTURING_PHASE = 1; + const BUBBLING_PHASE = 3; + + // First, create a div underneath the `<body>` element. It will be the + // dispatch target for synthetic click events. + const target = + document.querySelector('body').appendChild(document.createElement('div')); + + const body = document.querySelector('body'); + const captureObservable = body.on('click', {capture: true}); + const bubbleObservable = body.on('click', {capture: false}); + + const results = []; + captureObservable.subscribe(e => results.push(e.eventPhase)); + bubbleObservable.subscribe(e => results.push(e.eventPhase)); + + target.dispatchEvent(new MouseEvent('click', {bubbles: true})); + + assert_array_equals(results, [CAPTURING_PHASE, BUBBLING_PHASE]); +}, "EventTarget Observables can listen for events in the capturing or bubbling phase"); + +test(() => { + const target = new EventTarget(); + + const observable = target.on('event', {passive: true}); + observable.subscribe(event => { + assert_false(event.defaultPrevented); + // Should do nothing, since `observable` is "passive". + event.preventDefault(); + assert_false(event.defaultPrevented); + }); + + // Create a cancelable event which ordinarily would be able to have its + // "default" prevented. + const event = new Event('event', {cancelable: true}); + target.dispatchEvent(event); +}, "EventTarget Observables can be 'passive'"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-forEach.any.js b/testing/web-platform/tests/dom/observable/tentative/observable-forEach.any.js new file mode 100644 index 0000000000..d0948b295e --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-forEach.any.js @@ -0,0 +1,184 @@ +promise_test(async (t) => { + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + const results = []; + + const completion = source.forEach((value) => { + results.push(value); + }); + + assert_array_equals(results, [1, 2, 3]); + await completion; +}, "forEach(): Visitor callback called synchronously for each value"); + +promise_test(async (t) => { + const error = new Error("error"); + const source = new Observable((subscriber) => { + throw error; + }); + + try { + await source.forEach(() => { + assert_unreached("Visitor callback is not invoked when Observable errors"); + }); + assert_unreached("forEach() promise does not resolve when Observable errors"); + } catch (e) { + assert_equals(e, error); + } +}, "Errors thrown by Observable reject the returned promise"); + +promise_test(async (t) => { + const error = new Error("error"); + const source = new Observable((subscriber) => { + subscriber.error(error); + }); + + try { + await source.forEach(() => { + assert_unreached("Visitor callback is not invoked when Observable errors"); + }); + assert_unreached("forEach() promise does not resolve when Observable errors"); + } catch (reason) { + assert_equals(reason, error); + } +}, "Errors pushed by Observable reject the returned promise"); + +promise_test(async (t) => { + // This will be assigned when `source`'s teardown is called during + // unsubscription. + let abortReason = null; + + const error = new Error("error"); + const source = new Observable((subscriber) => { + // Should be called from within the second `next()` call below, when the + // `forEach()` visitor callback throws an error, because that triggers + // unsubscription from `source`. + subscriber.addTeardown(() => abortReason = subscriber.signal.reason); + + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + const results = []; + + const completion = source.forEach((value) => { + results.push(value); + if (value === 2) { + throw error; + } + }); + + assert_array_equals(results, [1, 2]); + assert_equals(abortReason, error, + "forEach() visitor callback throwing an error triggers unsubscription " + + "from the source observable, with the correct abort reason"); + + try { + await completion; + assert_unreached("forEach() promise does not resolve when visitor throws"); + } catch (e) { + assert_equals(e, error); + } +}, "Errors thrown in the visitor callback reject the promise and " + + "unsubscribe from the source"); + +// See https://github.com/WICG/observable/issues/96 for discussion about the +// timing of Observable AbortSignal `abort` firing and promise rejection. +promise_test(async t => { + const error = new Error('custom error'); + let rejectionError = null; + let outerAbortEventMicrotaskRun = false, + forEachPromiseRejectionMicrotaskRun = false, + innerAbortEventMicrotaskRun = false; + + const source = new Observable(subscriber => { + subscriber.signal.addEventListener('abort', () => { + queueMicrotask(() => { + assert_true(outerAbortEventMicrotaskRun, + "Inner abort: outer abort microtask has fired"); + assert_true(forEachPromiseRejectionMicrotaskRun, + "Inner abort: forEach rejection microtask has fired"); + assert_false(innerAbortEventMicrotaskRun, + "Inner abort: inner abort microtask has not fired"); + + innerAbortEventMicrotaskRun = true; + }); + }); + }); + + const controller = new AbortController(); + controller.signal.addEventListener('abort', () => { + queueMicrotask(() => { + assert_false(outerAbortEventMicrotaskRun, + "Outer abort: outer abort microtask has not fired"); + assert_false(forEachPromiseRejectionMicrotaskRun, + "Outer abort: forEach rejection microtask has not fired"); + assert_false(innerAbortEventMicrotaskRun, + "Outer abort: inner abort microtask has not fired"); + + outerAbortEventMicrotaskRun = true; + }); + }); + + const promise = source.forEach(() => {}, {signal: controller.signal}).catch(e => { + rejectionError = e; + assert_true(outerAbortEventMicrotaskRun, + "Promise rejection: outer abort microtask has fired"); + assert_false(forEachPromiseRejectionMicrotaskRun, + "Promise rejection: forEach rejection microtask has not fired"); + assert_false(innerAbortEventMicrotaskRun, + "Promise rejection: inner abort microtask has not fired"); + + forEachPromiseRejectionMicrotaskRun = true; + }); + + // This should trigger the following, in this order: + // 1. Fire the `abort` event at the outer AbortSignal, whose handler + // manually queues a microtask. + // 2. Calls "signal abort" on the outer signal's dependent signals. This + // queues a microtask to reject the `forEach()` promise. + // 3. Fire the `abort` event at the inner AbortSignal, whose handler + // manually queues a microtask. + controller.abort(error); + + // After a single task, assert that everything has happened correctly (and + // incrementally in the right order); + await new Promise(resolve => { + t.step_timeout(resolve); + }); + assert_true(outerAbortEventMicrotaskRun, + "Final: outer abort microtask has fired"); + assert_true(forEachPromiseRejectionMicrotaskRun, + "Final: forEach rejection microtask has fired"); + assert_true(innerAbortEventMicrotaskRun, + "Final: inner abort microtask has fired"); + assert_equals(rejectionError, error, "Promise is rejected with the right " + + "value"); +}, "forEach visitor callback rejection microtask ordering"); + +promise_test(async (t) => { + const source = new Observable((subscriber) => { + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + const results = []; + + const completion = source.forEach((value) => { + results.push(value); + }); + + assert_array_equals(results, [1, 2, 3]); + + const completionValue = await completion; + assert_equals(completionValue, undefined, "Promise resolves with undefined"); +}, "forEach() promise resolves with undefined"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-forEach.window.js b/testing/web-platform/tests/dom/observable/tentative/observable-forEach.window.js new file mode 100644 index 0000000000..71c2e17303 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-forEach.window.js @@ -0,0 +1,59 @@ +async function loadIframeAndReturnContentWindow() { + // Create and attach an iframe. + const iframe = document.createElement('iframe'); + const iframeLoadPromise = new Promise((resolve, reject) => { + iframe.onload = resolve; + iframe.onerror = reject; + }); + document.body.append(iframe); + await iframeLoadPromise; + return iframe.contentWindow; +} + +promise_test(async t => { + const contentWin = await loadIframeAndReturnContentWindow(); + + window.results = []; + + contentWin.eval(` + const parentResults = parent.results; + + const source = new Observable(subscriber => { + window.frameElement.remove(); + + // This invokes the forEach() operator's internal observer's next steps, + // which at least in Chromium, must have a special "context is detached" + // check to early-return, so as to not crash. + subscriber.next(1); + }); + + source.forEach(value => { + parentResults.push(value); + }); + `); + + // If we got here, we didn't crash! Let's also check that `results` is empty. + assert_array_equals(results, []); +}, "forEach()'s internal observer's next steps do not crash in a detached document"); + +promise_test(async t => { + const contentWin = await loadIframeAndReturnContentWindow(); + + window.results = []; + + contentWin.eval(` + const parentResults = parent.results; + + const source = new Observable(subscriber => { + subscriber.next(1); + }); + + source.forEach(value => { + window.frameElement.remove(); + parentResults.push(value); + }); + `); + + assert_array_equals(results, [1]); +}, "forEach()'s internal observer's next steps do not crash when visitor " + + "callback detaches the document"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.any.js b/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.any.js new file mode 100644 index 0000000000..6421777e09 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.any.js @@ -0,0 +1,324 @@ +// Because we test that the global error handler is called at various times. +setup({allow_uncaught_exception: true}); + +promise_test(async () => { + const source = new Observable(subscriber => { + let i = 0; + const interval = setInterval(() => { + if (i < 5) { + subscriber.next(++i); + } else { + subscriber.complete(); + clearInterval(interval); + } + }, 0); + }); + + const result = await source.takeUntil(new Observable(() => {})).toArray(); + assert_array_equals(result, [1, 2, 3, 4, 5]); +}, "takeUntil subscribes to source Observable and mirrors it uninterrupted"); + +promise_test(async () => { + const source = new Observable(() => {}); + let notifierSubscribedTo = false; + const notifier = new Observable(() => notifierSubscribedTo = true); + + source.takeUntil(notifier).subscribe(); + assert_true(notifierSubscribedTo); +}, "takeUntil subscribes to notifier"); + +// This test is important because ordinarily, calling `subscriber.next()` does +// not cancel a subscription associated with `subscriber`. However, for the +// `takeUntil()` operator, the spec responds to `notifier`'s `next()` by +// unsubscribing from `notifier`, which is what this test asserts. +promise_test(async () => { + const source = new Observable(subscriber => {}); + + let notifierSubscriberActiveBeforeNext; + let notifierSubscriberActiveAfterNext; + let teardownCalledAfterNext; + let notifierSignalAbortedAfterNext; + const notifier = new Observable(subscriber => { + let teardownCalled; + subscriber.addTeardown(() => teardownCalled = true); + + // Calling `next()` causes `takeUntil()` to unsubscribe from `notifier`. + notifierSubscriberActiveBeforeNext = subscriber.active; + subscriber.next('value'); + notifierSubscriberActiveAfterNext = subscriber.active; + teardownCalledAfterNext = (teardownCalled === true); + notifierSignalAbortedAfterNext = subscriber.signal.aborted; + }); + + let nextOrErrorCalled = false; + let completeCalled = false; + source.takeUntil(notifier).subscribe({ + next: () => nextOrErrorCalled = true, + error: () => nextOrErrorCalled = true, + complete: () => completeCalled = true, + }); + assert_true(notifierSubscriberActiveBeforeNext); + assert_false(notifierSubscriberActiveAfterNext); + assert_true(teardownCalledAfterNext); + assert_true(notifierSignalAbortedAfterNext); + assert_false(nextOrErrorCalled); + assert_true(completeCalled); +}, "takeUntil: notifier next() unsubscribes to notifier"); + +// This test is identical to the one above, with the exception being that the +// `notifier` calls `subscriber.error()` instead `subscriber.next()`. +promise_test(async () => { + const source = new Observable(subscriber => {}); + + let notifierSubscriberActiveBeforeNext; + let notifierSubscriberActiveAfterNext; + let teardownCalledAfterNext; + let notifierSignalAbortedAfterNext; + const notifier = new Observable(subscriber => { + let teardownCalled; + subscriber.addTeardown(() => teardownCalled = true); + + // Calling `next()` causes `takeUntil()` to unsubscribe from `notifier`. + notifierSubscriberActiveBeforeNext = subscriber.active; + subscriber.error('error'); + notifierSubscriberActiveAfterNext = subscriber.active; + teardownCalledAfterNext = (teardownCalled === true); + notifierSignalAbortedAfterNext = subscriber.signal.aborted; + }); + + let nextOrErrorCalled = false; + let completeCalled = false; + source.takeUntil(notifier).subscribe({ + next: () => nextOrErrorCalled = true, + error: () => nextOrErrorCalled = true, + complete: () => completeCalled = true, + }); + assert_true(notifierSubscriberActiveBeforeNext); + assert_false(notifierSubscriberActiveAfterNext); + assert_true(teardownCalledAfterNext); + assert_true(notifierSignalAbortedAfterNext); + assert_false(nextOrErrorCalled); + assert_true(completeCalled); +}, "takeUntil: notifier error() unsubscribes to notifier"); + +// Test that `notifier` unsubscribes from source Observable. +promise_test(async t => { + const results = []; + + const source = new Observable(subscriber => { + results.push('source subscribed'); + subscriber.addTeardown(() => results.push('source teardown')); + subscriber.signal.addEventListener('abort', + e => results.push('source signal abort')); + }); + + let notifierTeardownCalled = false; + const notifier = new Observable(subscriber => { + results.push('notifier subscribed'); + subscriber.addTeardown(() => { + results.push('notifier teardown'); + notifierTeardownCalled = true; + }); + subscriber.signal.addEventListener('abort', + e => results.push('notifier signal abort')); + + // Asynchronously shut everything down. + t.step_timeout(() => subscriber.next('value')); + }); + + let nextOrErrorCalled = false; + let notifierTeardownCalledBeforeCompleteCallback; + await new Promise(resolve => { + source.takeUntil(notifier).subscribe({ + next: () => nextOrErrorCalled = true, + error: () => nextOrErrorCalled = true, + complete: () => { + notifierTeardownCalledBeforeCompleteCallback = notifierTeardownCalled; + resolve(); + }, + }); + }); + + // The outer `Observer#complete()` callback is called before any teardowns are + // invoked. + assert_false(nextOrErrorCalled); + // The notifier/source teardowns are not called by the time the outer + // `Observer#complete()` callback is invoked, but they are all run *after* + // (i.e., before `notifier`'s `subscriber.next()` returns internally). + assert_false(notifierTeardownCalledBeforeCompleteCallback); + assert_true(notifierTeardownCalled); + assert_array_equals(results, [ + "notifier subscribed", + "source subscribed", + "notifier teardown", + "notifier signal abort", + "source teardown", + "source signal abort" + ]); +}, "takeUntil: notifier next() unsubscribes from notifier & source observable"); + +// This test is almost identical to the above test, however instead of the +// `notifier` Observable being the thing that causes the unsubscription from +// `notifier` and `source`, it is the outer composite Observable's +// `SubscribeOptions#signal` being aborted that does this. +promise_test(async t => { + const results = []; + // This will get populated later with a function that resolves a promise. + let resolver; + + const source = new Observable(subscriber => { + results.push('source subscribed'); + subscriber.addTeardown(() => results.push('source teardown')); + subscriber.signal.addEventListener('abort', e => { + results.push('source signal abort'); + // This should be the last thing run in the whole teardown sequence. After + // this, we can resolve the promise that this test is waiting on, via + // `resolver`. That'll wrap things up and move us on to the assertions. + resolver(); + }); + }); + + const notifier = new Observable(subscriber => { + results.push('notifier subscribed'); + subscriber.addTeardown(() => { + results.push('notifier teardown'); + }); + subscriber.signal.addEventListener('abort', + e => results.push('notifier signal abort')); + }); + + let observerCallbackCalled = false; + await new Promise(resolve => { + resolver = resolve; + const controller = new AbortController(); + source.takeUntil(notifier).subscribe({ + next: () => observerCallbackCalled = true, + error: () => observerCallbackCalled = true, + complete: () => observerCallbackCalled = true, + }, {signal: controller.signal}); + + // Asynchronously shut everything down. + t.step_timeout(() => controller.abort()); + }); + + assert_false(observerCallbackCalled); + assert_array_equals(results, [ + "notifier subscribed", + "source subscribed", + "notifier teardown", + "notifier signal abort", + "source teardown", + "source signal abort" + ]); +}, "takeUntil()'s AbortSignal unsubscribes from notifier & source observable"); + +promise_test(async () => { + let sourceSubscribedTo = false; + const source = new Observable(subscriber => { + sourceSubscribedTo = true; + }); + + const notifier = new Observable(subscriber => subscriber.next('value')); + + let nextOrErrorCalled = false; + let completeCalled = false; + const result = source.takeUntil(notifier).subscribe({ + next: v => nextOrErrorCalled = true, + error: e => nextOrErrorCalled = true, + complete: () => completeCalled = true, + }); + + assert_false(sourceSubscribedTo); + assert_true(completeCalled); + assert_false(nextOrErrorCalled); +}, "takeUntil: source never subscribed to when notifier synchronously emits a value"); + +promise_test(async () => { + let sourceSubscribedTo = false; + const source = new Observable(subscriber => { + sourceSubscribedTo = true; + }); + + const notifier = new Observable(subscriber => subscriber.error('error')); + + let nextOrErrorCalled = false; + let completeCalled = false; + const result = source.takeUntil(notifier).subscribe({ + next: v => nextOrErrorCalled = true, + error: e => nextOrErrorCalled = true, + complete: () => completeCalled = true, + }); + + assert_false(sourceSubscribedTo); + assert_true(completeCalled); + assert_false(nextOrErrorCalled); +}, "takeUntil: source never subscribed to when notifier synchronously emits error"); + +promise_test(async () => { + const source = new Observable(subscriber => { + let i = 0; + const interval = setInterval(() => { + if (i < 5) { + subscriber.next(++i); + } else { + subscriber.complete(); + clearInterval(interval); + } + }, 500); + }); + + const notifier = new Observable(subscriber => subscriber.complete()); + + const result = await source.takeUntil(notifier).toArray(); + assert_array_equals(result, [1, 2, 3, 4, 5]); +}, "takeUntil: source is uninterrupted when notifier completes, even synchronously"); + +promise_test(async () => { + const results = []; + + let sourceSubscriber; + let notifierSubscriber; + const source = new Observable(subscriber => sourceSubscriber = subscriber); + const notifier = new Observable(subscriber => notifierSubscriber = subscriber); + + source.takeUntil(notifier).subscribe({ + next: v => results.push(v), + complete: () => results.push("complete"), + }); + + sourceSubscriber.next(1); + sourceSubscriber.next(2); + notifierSubscriber.next('notifier value'); + sourceSubscriber.next(3); + + assert_array_equals(results, [1, 2, 'complete']); +}, "takeUntil() mirrors the source Observable until its first next() value"); + +promise_test(async t => { + let errorReported = null; + + self.addEventListener("error", e => errorReported = e, { once: true }); + + const source = new Observable(() => {}); + const notifier = new Observable(subscriber => { + t.step_timeout(() => { + subscriber.error('error 1'); + subscriber.error('error 2'); + }); + }); + + let errorCallbackCalled = false; + await new Promise(resolve => { + source.takeUntil(notifier).subscribe({ + error: e => errorCallbackCalled = true, + complete: () => resolve(), + }); + }); + + assert_false(errorCallbackCalled); + assert_true(errorReported !== null, "Exception was reported to global"); + assert_equals(errorReported.message, "Uncaught error 2", "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, 'error 2', "Error object is equivalent (just a string)"); +}, "takeUntil: notifier calls `Subscriber#error()` twice; second goes to global error handler"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.window.js b/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.window.js new file mode 100644 index 0000000000..e4906d5f16 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-takeUntil.window.js @@ -0,0 +1,61 @@ +async function loadIframeAndReturnContentWindow() { + // Create and attach an iframe. + const iframe = document.createElement('iframe'); + const iframeLoadPromise = new Promise((resolve, reject) => { + iframe.onload = resolve; + iframe.onerror = reject; + }); + document.body.append(iframe); + await iframeLoadPromise; + return iframe.contentWindow; +} + +// This is a regression test to ensure there is no crash inside `takeUntil()` +// once `notifier` detaches its document, before `source` is subscribed to. +promise_test(async () => { + // Hang this off of the main document's global, so the child can easily reach + // it. + window.results = []; + const contentWin = await loadIframeAndReturnContentWindow(); + + contentWin.eval(` + const parentResults = parent.results; + + const source = new Observable(() => parentResults.push('source subscribed')); + const notifier = new Observable(() => { + parentResults.push('notifier subscribed'); + + // Detach this child document. + window.frameElement.remove(); + parentResults.push('notifier has detached document'); + }); + + source.takeUntil(notifier).subscribe(); + `); + + assert_array_equals(results, ["notifier subscribed", "notifier has detached document"]); +}, "takeUntil(): notifier Observable detaches document before source " + + "Observable would be subscribed to"); + +promise_test(async () => { + window.results = []; + const contentWin = await loadIframeAndReturnContentWindow(); + + contentWin.eval(` + let completeSubscriber, errorSubscriber, notifierSubscriber; + const sourceComplete = new Observable(subscriber => completeSubscriber = subscriber); + const sourceError = new Observable(subscriber => errorSubscriber = subscriber); + const notifier = new Observable(subscriber => notifierSubscriber = subscriber); + + sourceComplete.takeUntil(notifier).subscribe(); + sourceError.takeUntil(notifier).subscribe(); + + // Detach this child document. + window.frameElement.remove(); + + completeSubscriber.complete(); + errorSubscriber.error('error'); + notifierSubscriber.error('error'); + `); +}, "takeUntil(): Source and notifier internal observers do not crash in a " + + "detached document"); diff --git a/testing/web-platform/tests/dom/observable/tentative/observable-toArray.any.js b/testing/web-platform/tests/dom/observable/tentative/observable-toArray.any.js new file mode 100644 index 0000000000..9e6e3abee5 --- /dev/null +++ b/testing/web-platform/tests/dom/observable/tentative/observable-toArray.any.js @@ -0,0 +1,174 @@ +// Because we test that the global error handler is called at various times. +setup({allow_uncaught_exception: true}); + +promise_test(async () => { + const observable = new Observable(subscriber => { + subscriber.next(1); + subscriber.next(2); + subscriber.next(3); + subscriber.complete(); + }); + + const array = await observable.toArray(); + assert_array_equals(array, [1, 2, 3]); +}, "toArray(): basic next/complete"); + +promise_test(async t => { + let errorReported = null; + let innerSubscriber = null; + self.addEventListener('error', e => errorReported = e, {once: true}); + + const error = new Error("custom error"); + const observable = new Observable(subscriber => { + innerSubscriber = subscriber; + subscriber.error(error); + }); + + try { + const array = await observable.toArray(); + assert_unreached("toArray() promise must not resolve"); + } catch (e) { + assert_equals(e, error); + assert_equals(errorReported, null); + + // Calls to `error()` after the subscription is closed still report the + // exception. + innerSubscriber.error(error); + assert_not_equals(errorReported, null, "Exception was reported to global"); + assert_true(errorReported.message.includes("custom error"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); + } +}, "toArray(): first error() rejects promise; subsequent error()s report the exceptions"); + +promise_test(async t => { + let errorReported = null; + let innerSubscriber = null; + self.addEventListener('error', e => errorReported = e, {once: true}); + + const error = new Error("custom error"); + const observable = new Observable(subscriber => { + innerSubscriber = subscriber; + subscriber.complete(); + }); + + const array = await observable.toArray(); + assert_array_equals(array, []); + assert_equals(errorReported, null); + + // Calls to `error()` after the subscription is closed still report the + // exception. + innerSubscriber.error(error); + assert_not_equals(errorReported, null, "Exception was reported to global"); + assert_true(errorReported.message.includes("custom error"), "Error message matches"); + assert_greater_than(errorReported.lineno, 0, "Error lineno is greater than 0"); + assert_greater_than(errorReported.colno, 0, "Error lineno is greater than 0"); + assert_equals(errorReported.error, error, "Error object is equivalent"); +}, "toArray(): complete() resolves promise; subsequent error()s report the exceptions"); + +promise_test(async () => { + // This tracks whether `postSubscriptionPromise` has had its then handler run. + // This helps us keep track of the timing/ordering of everything. Calling a + // Promise-returning operator with an aborted signal must *immediately* reject + // the returned Promise, which means code "awaiting" it should run before any + // subsequent Promise resolution/rejection handlers are run. + let postSubscriptionPromiseResolved = false; + let subscriptionImmediatelyInactive = false; + + const observable = new Observable(subscriber => { + const inactive = !subscriber.active; + subscriptionImmediatelyInactive = inactive; + }); + + const rejectedPromise = observable.toArray({signal: AbortSignal.abort()}) + .then(() => { + assert_unreached("Operator promise must not resolve its abort signal is " + + "rejected"); + }, () => { + // See the documentation above. The rejection handler (i.e., this code) for + // immediately-aborted operator Promises runs before any later-scheduled + // Promise resolution/rejections. + assert_false(postSubscriptionPromiseResolved, + "Operator promise rejects before later promise"); + }); + const postSubscriptionPromise = + Promise.resolve().then(() => postSubscriptionPromiseResolved = true); + + await rejectedPromise; +}, "toArray(): Subscribing with an aborted signal returns an immediately " + + "rejected promise"); + +promise_test(async () => { + let postSubscriptionPromiseResolved = false; + + const observable = new Observable(subscriber => {}); + const controller = new AbortController(); + const arrayPromise = observable.toArray({signal: controller.signal}) + .then(() => { + assert_unreached("Operator promise must not resolve if its abort signal " + + "is rejected"); + }, () => { + assert_false(postSubscriptionPromiseResolved, + "controller.abort() synchronously rejects the operator " + + "Promise"); + }); + + // This must synchronously reject `arrayPromise`, scheduling in the next + // microtask. + controller.abort(); + Promise.resolve().then(value => postSubscriptionPromiseResolved = true); + + await arrayPromise; +}, "toArray(): Aborting the passed-in signal rejects the returned promise"); + +// See https://github.com/WICG/observable/issues/96 for discussion about this. +promise_test(async () => { + const results = []; + + const observable = new Observable(subscriber => { + results.push(`Subscribed. active: ${subscriber.active}`); + + subscriber.signal.addEventListener('abort', e => { + results.push("Inner signal abort event"); + Promise.resolve("Inner signal Promise").then(value => results.push(value)); + }); + + subscriber.addTeardown(() => { + results.push("Teardown"); + Promise.resolve("Teardown Promise").then(value => results.push(value)); + }); + }); + + const controller = new AbortController(); + controller.signal.addEventListener('abort', e => { + results.push("Outer signal abort event"); + Promise.resolve("Outer signal Promise").then(value => results.push(value)); + }); + + // Subscribe. + observable.toArray({signal: controller.signal}); + controller.abort(); + + assert_array_equals(results, [ + "Subscribed. active: true", + "Outer signal abort event", + "Teardown", + "Inner signal abort event", + ], "Events and teardowns are fired in the right ordered"); + + // Everything microtask above should be queued up by now, so queue one more + // final microtask that will run after all of the others, wait for it, and the + // check `results` is right. + await Promise.resolve(); + assert_array_equals(results, [ + "Subscribed. active: true", + "Outer signal abort event", + "Teardown", + "Inner signal abort event", + "Outer signal Promise", + "Teardown Promise", + "Inner signal Promise", + ], "Promises resolve in the right order"); +}, "Operator Promise abort ordering"); + |