diff --git a/js/misc/signalTracker.js b/js/misc/signalTracker.js index e4497e26aa48289a0d0449f087046ae179b11dbf..2aa5f7e2c217b218b18586191ff13bbeb937953e 100644 --- a/js/misc/signalTracker.js +++ b/js/misc/signalTracker.js @@ -3,6 +3,9 @@ const { GObject } = imports.gi; const destroyableTypes = []; +// Add custom shell connection flags, ensuring we don't override standard ones +GObject.ConnectFlags.SHELL_ONCE = 1 << 25; + /** * @private * @param {Object} obj - an object @@ -21,8 +24,10 @@ class TransientSignalHolder extends GObject.Object { constructor(owner) { super(); - if (_hasDestroySignal(owner)) - owner.connectObject('destroy', () => this.destroy(), this); + if (_hasDestroySignal(owner)) { + owner.connectObject('destroy', () => this.destroy(), + GObject.ConnectFlags.AFTER, this); + } } destroy() { @@ -86,11 +91,11 @@ class SignalTracker { * @param {Object=} owner - object that owns the tracker */ constructor(owner) { - if (_hasDestroySignal(owner)) - this._ownerDestroyId = owner.connect_after('destroy', () => this.clear()); - this._owner = owner; this._map = new Map(); + + if (_hasDestroySignal(owner)) + this._trackOwnerDestroy(); } /** @@ -113,13 +118,34 @@ class SignalTracker { return data; } + /** + * Reconnects to owner 'destroy' if any + */ + updateOwnerDestroyTracker() { + if (!this._ownerDestroyId) + return; + + this._disconnectSignal(this._owner, this._ownerDestroyId); + this._trackOwnerDestroy(); + } + + /** + * @private + */ + _trackOwnerDestroy() { + this._ownerDestroyId = this._owner.connect_after('destroy', + () => this.clear()); + } + /** * @private * @param {GObject.Object} obj - tracked widget + * @param {object} signalData - object signal data, got via _getSignalData() */ - _trackDestroy(obj) { - const signalData = this._getSignalData(obj); + _trackDestroy(obj, signalData) { if (signalData.destroyId) + throw new Error('Destroy already tracked'); + if (obj === this._owner) return; signalData.destroyId = obj.connect_after('destroy', () => this.untrack(obj)); } @@ -154,10 +180,12 @@ class SignalTracker { * @returns {void} */ track(obj, ...handlerIds) { - if (_hasDestroySignal(obj)) - this._trackDestroy(obj); + const signalData = this._getSignalData(obj); + + if (!signalData.destroyId && _hasDestroySignal(obj)) + this._trackDestroy(obj, signalData); - this._getSignalData(obj).ownerSignals.push(...handlerIds); + signalData.ownerSignals.push(...handlerIds); } /** @@ -178,6 +206,24 @@ class SignalTracker { this._removeTracker(); } + /** + * @param {object} obj - tracked object instance + * @param {...number} handlerIds - tracked handler IDs to untrack + * @returns {void} + */ + untrackIds(obj, ...handlerIds) { + const {ownerSignals} = this._getSignalData(obj); + const ownerProto = this._getObjectProto(this._owner); + + handlerIds.forEach(id => { + this._disconnectSignalForProto(ownerProto, this._owner, id); + ownerSignals.splice(ownerSignals.indexOf(id), 1); + }); + + if (!ownerSignals.length) + this.untrack(obj); + } + /** * @returns {void} */ @@ -208,41 +254,63 @@ class SignalTracker { * @returns {void} */ function connectObject(thisObj, ...args) { + let flagsMask = 0; + Object.values(GObject.ConnectFlags).forEach(v => (flagsMask |= v)); + const getParams = argArray => { const [signalName, handler, arg, ...rest] = argArray; if (typeof arg !== 'number') return [signalName, handler, 0, arg, ...rest]; const flags = arg; - let flagsMask = 0; - Object.values(GObject.ConnectFlags).forEach(v => (flagsMask |= v)); - if (!(flags & flagsMask)) + if (flags && (flags & flagsMask) !== flags) throw new Error(`Invalid flag value ${flags}`); - if (flags & GObject.ConnectFlags.SWAPPED) - throw new Error('Swapped signals are not supported'); return [signalName, handler, flags, ...rest]; }; + const signalManager = SignalManager.getDefault(); + let obj; + const connectSignal = (emitter, signalName, handler, flags) => { + let connectionId; const isGObject = emitter instanceof GObject.Object; const func = (flags & GObject.ConnectFlags.AFTER) && isGObject ? 'connect_after' : 'connect'; + const orderedHandler = flags & GObject.ConnectFlags.SWAPPED + ? (instance, ...handlerArgs) => handler(...handlerArgs, instance) + : handler; + const realHandler = flags & GObject.ConnectFlags.SHELL_ONCE + ? (...handlerArgs) => { + const tracker = signalManager.getSignalTracker(emitter); + tracker.untrackIds(obj, connectionId); + return orderedHandler(...handlerArgs); + } + : orderedHandler; + const emitterProto = isGObject ? GObject.Object.prototype : Object.getPrototypeOf(emitter); - return emitterProto[func].call(emitter, signalName, handler); + connectionId = emitterProto[func].call(emitter, signalName, realHandler); + return connectionId; }; + let trackingAfterDestroy = false; const signalIds = []; while (args.length > 1) { const [signalName, handler, flags, ...rest] = getParams(args); signalIds.push(connectSignal(thisObj, signalName, handler, flags)); + if (signalName === 'destroy' && flags & GObject.ConnectFlags.AFTER) + trackingAfterDestroy = true; args = rest; } - const obj = args.at(0) ?? globalThis; - const tracker = SignalManager.getDefault().getSignalTracker(thisObj); + obj = args.at(0) ?? globalThis; + const tracker = signalManager.getSignalTracker(thisObj); + + if (trackingAfterDestroy) + tracker.updateOwnerDestroyTracker(); + tracker.track(obj, ...signalIds); } @@ -255,7 +323,8 @@ function connectObject(thisObj, ...args) { * @returns {void} */ function disconnectObject(thisObj, obj) { - SignalManager.getDefault().maybeGetSignalTracker(thisObj)?.untrack(obj); + SignalManager.getDefault().maybeGetSignalTracker(thisObj)?.untrack( + obj ?? globalThis); } /** diff --git a/tests/meson.build b/tests/meson.build index 85339dd141acd1b1b48a920bc5769bd1db292551..38bd444ac7e28a876d1466d891ea8e3879b8c56f 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -25,6 +25,7 @@ tests = [ 'markup', 'params', 'signalTracker', + 'signals', 'url', 'versionCompare', ] @@ -32,6 +33,7 @@ tests = [ foreach test : tests test(test, run_test, args: 'unit/@0@.js'.format(test), + suite: ['unit', 'js'], workdir: meson.current_source_dir()) endforeach diff --git a/tests/run-test.sh.in b/tests/run-test.sh.in index ea6d1572614bf4bec55a56f785bbebb1be4d58de..a28be68e9a7ff450b0f8c0a009ac74440e37cdd0 100755 --- a/tests/run-test.sh.in +++ b/tests/run-test.sh.in @@ -11,7 +11,7 @@ debug= for arg in $@ ; do case $arg in -g|--debug) - debug="libtool --mode=execute gdb --args" + debug="gdb --args" ;; -v|--verbose) verbose=true @@ -34,11 +34,20 @@ GI_TYPELIB_PATH="$GI_TYPELIB_PATH${GI_TYPELIB_PATH:+:}@MUTTER_TYPELIB_DIR@:$buil GJS_PATH="$srcdir:$srcdir/../js:$builddir/../js" GJS_DEBUG_OUTPUT=stderr $verbose || GJS_DEBUG_TOPICS="JS ERROR;JS LOG" +G_DEBUG=fatal-warnings GNOME_SHELL_TESTSDIR="$srcdir/" GNOME_SHELL_JS="$srcdir/../js" GNOME_SHELL_DATADIR="$builddir/../data" -export GI_TYPELIB_PATH GJS_PATH GJS_DEBUG_OUTPUT GJS_DEBUG_TOPICS GNOME_SHELL_TESTSDIR GNOME_SHELL_JS GNOME_SHELL_DATADIR LD_PRELOAD +export GI_TYPELIB_PATH \ + GJS_DEBUG_OUTPUT \ + GJS_DEBUG_TOPICS \ + GJS_PATH \ + GNOME_SHELL_DATADIR \ + GNOME_SHELL_JS \ + GNOME_SHELL_TESTSDIR \ + G_DEBUG \ + LD_PRELOAD for test in $tests ; do $debug $builddir/../src/run-js-test $test || exit $? diff --git a/tests/unit/signalTracker.js b/tests/unit/signalTracker.js index 7943d0a20f468380447abc6691a40cf28a64a5ca..f3b79ae7f7a3069d54b20666b9850c69b18d0601 100644 --- a/tests/unit/signalTracker.js +++ b/tests/unit/signalTracker.js @@ -4,6 +4,9 @@ const { GObject } = imports.gi; +const {testUtils: TestUtils} = imports.unit; +const {testCase} = TestUtils; + const JsUnit = imports.jsUnit; const Signals = imports.misc.signals; @@ -21,95 +24,591 @@ const GObjectEmitter = GObject.registerClass({ Signals: { 'signal': {} }, }, class GObjectEmitter extends Destroyable {}); -const emitter1 = new Signals.EventEmitter(); -const emitter2 = new GObjectEmitter(); +const GObjectEmitterInt = GObject.registerClass({ + Signals: {'signal-int': {param_types: [GObject.TYPE_INT]}}, +}, class GObjectEmitterInt extends GObjectEmitter {}); + + +function hasSignalHandler(object, signalId) { + if (!(object instanceof GObject.Object)) + throw new Error('Unsupported Object'); + + return !!GObject.signal_handler_find(object, {signalId}); +} + +testCase('Regular JS Objects cannot be registered as Destroyable types', () => { + JsUnit.assertRaises(() => registerDestroyableType({}.constructor.prototype)); + JsUnit.assertRaises(() => registerDestroyableType(class {})); +}); + +testCase('Signals.EventEmitter cannot be registered as Destroyable types', () => { + class BadJSDestroyable extends Signals.EventEmitter {} + JsUnit.assertRaises(() => registerDestroyableType(BadJSDestroyable)); +}); + +testCase('TransientSignalHolder monitors destruction of owned object', () => { + let ownedDestroyCalled = false; + const owner = new Destroyable(); + const owned = new TransientSignalHolder(owner); + + owned.connectObject('destroy', () => (ownedDestroyCalled = true), owner); + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(owned, 'destroy')); + + owned.emit('destroy'); + JsUnit.assertTrue(ownedDestroyCalled); + + JsUnit.assertFalse(hasSignalHandler(owner, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(owned, 'destroy')); +}); + +testCase('TransientSignalHolder monitors destruction of owned object without being destroyed', () => { + let ownedDestroyCalled = false; + let ownerDestroyCalled = false; + const owner = new Destroyable(); + const owned = new TransientSignalHolder(owner); + + owned.connectObject('destroy', () => (ownedDestroyCalled = true), owner); + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(owned, 'destroy')); + + owner.connectObject('destroy', () => (ownerDestroyCalled = true)); + + owned.emit('destroy'); + JsUnit.assertTrue(ownedDestroyCalled); + JsUnit.assertFalse(ownerDestroyCalled); + + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(owned, 'destroy')); +}); + +testCase('TransientSignalHolder is destroyed on owner destruction', () => { + let ownedDestroyCalled = false; + let ownerDestroyCalled = false; + const owner = new Destroyable(); + const owned = new TransientSignalHolder(owner); + + owned.connectObject('destroy', () => (ownedDestroyCalled = true), owner); + owner.connectObject('destroy', () => (ownerDestroyCalled = true)); + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(owned, 'destroy')); + + owner.emit('destroy'); + JsUnit.assertTrue(ownedDestroyCalled); + JsUnit.assertTrue(ownerDestroyCalled); + + JsUnit.assertFalse(hasSignalHandler(owner, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(owned, 'destroy')); +}); + +testCase('TransientSignalHolder owner destruction keeps early monitored destructions', () => { + let ownedDestroyCalled = false; + let ownerDestroyCalled = false; + const owner = new Destroyable(); + + owner.connectObject('destroy', () => (ownerDestroyCalled = true)); + + const owned = new TransientSignalHolder(owner); + owned.connectObject('destroy', () => (ownedDestroyCalled = true), owner); + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(owned, 'destroy')); + + owner.emit('destroy'); + JsUnit.assertTrue(ownedDestroyCalled); + JsUnit.assertTrue(ownerDestroyCalled); + + JsUnit.assertFalse(hasSignalHandler(owner, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(owned, 'destroy')); +}); + +testCase('TransientSignalHolder owner destruction always happens as last destruction event', () => { + let ownedDestroyCalled = false; + let ownerDestroyCalled = false; + const owner = new Destroyable(); + const owned = new TransientSignalHolder(owner); + + owned.connectObject('destroy', () => (ownedDestroyCalled = true), owner); + owner.connectObject('destroy', () => (ownerDestroyCalled = true), + GObject.ConnectFlags.AFTER); + JsUnit.assertTrue(hasSignalHandler(owner, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(owned, 'destroy')); + + owner.emit('destroy'); + JsUnit.assertTrue(ownedDestroyCalled); + JsUnit.assertTrue(ownerDestroyCalled); + + JsUnit.assertFalse(hasSignalHandler(owner, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(owned, 'destroy')); +}); + +testCase('Signal emissions can be tracked', () => { + const emitter1 = new Signals.EventEmitter(); + const emitter2 = new GObjectEmitter(); + + const tracked1 = new Destroyable(); + const tracked2 = {}; + + let count = 0; + const handler = () => count++; + + emitter1.connectObject('signal', handler, tracked1); + emitter2.connectObject('signal', handler, tracked1); + + emitter1.connectObject('signal', handler, tracked2); + emitter2.connectObject('signal', handler, tracked2); + + JsUnit.assertEquals(count, 0); + + emitter1.emit('signal'); + emitter2.emit('signal'); + + JsUnit.assertEquals(count, 4); + + tracked1.emit('destroy'); + + emitter1.emit('signal'); + emitter2.emit('signal'); + + JsUnit.assertEquals(count, 6); + + emitter1.disconnectObject(tracked2); + emitter2.emit('destroy'); + + emitter1.emit('signal'); + emitter2.emit('signal'); + + JsUnit.assertEquals(count, 6); + + emitter1.connectObject( + 'signal', handler, + 'signal', handler, GObject.ConnectFlags.AFTER, + tracked1); + emitter2.connectObject( + 'signal', handler, + 'signal', handler, GObject.ConnectFlags.AFTER, + tracked1); + + emitter1.emit('signal'); + emitter2.emit('signal'); + + JsUnit.assertEquals(count, 10); -const tracked1 = new Destroyable(); -const tracked2 = {}; + tracked1.emit('destroy'); + emitter1.emit('signal'); + emitter2.emit('signal'); -let count = 0; -const handler = () => count++; + JsUnit.assertEquals(count, 10); -emitter1.connectObject('signal', handler, tracked1); -emitter2.connectObject('signal', handler, tracked1); + emitter1.connectObject('signal', handler, tracked1); + emitter2.connectObject('signal', handler, tracked1); -emitter1.connectObject('signal', handler, tracked2); -emitter2.connectObject('signal', handler, tracked2); + let transientHolder = new TransientSignalHolder(tracked1); -JsUnit.assertEquals(count, 0); + emitter1.connectObject('signal', handler, transientHolder); + emitter2.connectObject('signal', handler, transientHolder); -emitter1.emit('signal'); -emitter2.emit('signal'); + emitter1.emit('signal'); + emitter2.emit('signal'); -JsUnit.assertEquals(count, 4); + JsUnit.assertEquals(count, 14); -tracked1.emit('destroy'); + transientHolder.destroy(); -emitter1.emit('signal'); -emitter2.emit('signal'); + emitter1.emit('signal'); + emitter2.emit('signal'); -JsUnit.assertEquals(count, 6); + JsUnit.assertEquals(count, 16); -emitter1.disconnectObject(tracked2); -emitter2.emit('destroy'); + transientHolder = new TransientSignalHolder(tracked1); -emitter1.emit('signal'); -emitter2.emit('signal'); + emitter1.connectObject('signal', handler, transientHolder); + emitter2.connectObject('signal', handler, transientHolder); -JsUnit.assertEquals(count, 6); + emitter1.emit('signal'); + emitter2.emit('signal'); -emitter1.connectObject( - 'signal', handler, - 'signal', handler, GObject.ConnectFlags.AFTER, - tracked1); -emitter2.connectObject( - 'signal', handler, - 'signal', handler, GObject.ConnectFlags.AFTER, - tracked1); + JsUnit.assertEquals(count, 20); -emitter1.emit('signal'); -emitter2.emit('signal'); + tracked1.emit('destroy'); + emitter1.emit('signal'); + emitter2.emit('signal'); -JsUnit.assertEquals(count, 10); + JsUnit.assertEquals(count, 20); +}); -tracked1.emit('destroy'); -emitter1.emit('signal'); -emitter2.emit('signal'); +testCase('Signal support default flags', () => { + const obj = new Signals.EventEmitter(); + obj.connectObject('signal', () => {}, GObject.ConnectFlags.DEFAULT ?? 0, {}); +}); -JsUnit.assertEquals(count, 10); +testCase('Fails with unknown flags', () => { + const obj = new Signals.EventEmitter(); + TestUtils.assertRaisesError(() => obj.connectObject('signal', () => {}, 256, {}), + 'Invalid flag value 256'); + TestUtils.assertRaisesError(() => obj.connectObject('signal', () => {}, 234, {}), + 'Invalid flag value'); +}); -emitter1.connectObject('signal', handler, tracked1); -emitter2.connectObject('signal', handler, tracked1); +testCase('Emitter is same of tracker', () => { + const obj = new GObjectEmitter(); + let callbackCalled = false; -transientHolder = new TransientSignalHolder(tracked1); + obj.connectObject('signal', () => (callbackCalled = true), obj); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); -emitter1.connectObject('signal', handler, transientHolder); -emitter2.connectObject('signal', handler, transientHolder); + obj.emit('signal'); + JsUnit.assertTrue(callbackCalled); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); -emitter1.emit('signal'); -emitter2.emit('signal'); + obj.emit('destroy'); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); -JsUnit.assertEquals(count, 14); +testCase('Emitter is same of tracker after', () => { + const obj = new GObjectEmitter(); + let callbackCalled = false; -transientHolder.destroy(); + obj.connectObject('destroy', () => (callbackCalled = true), + GObject.ConnectFlags.AFTER, obj); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); -emitter1.emit('signal'); -emitter2.emit('signal'); + obj.emit('destroy'); + JsUnit.assertTrue(callbackCalled); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); -JsUnit.assertEquals(count, 16); +testCase('Emitter is same of tracker does not block after-destroy signals', () => { + let destroyCalled = false; + const obj = new Destroyable(); -transientHolder = new TransientSignalHolder(tracked1); + obj.connectObject('destroy', () => (destroyCalled = true), + GObject.ConnectFlags.AFTER, obj); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); -emitter1.connectObject('signal', handler, transientHolder); -emitter2.connectObject('signal', handler, transientHolder); + obj.emit('destroy'); + JsUnit.assertTrue(destroyCalled); -emitter1.emit('signal'); -emitter2.emit('signal'); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); -JsUnit.assertEquals(count, 20); +testCase('Emitter is disconnected on tracker destruction', () => { + const obj = new GObjectEmitter(); + const tracker = new Destroyable(); + let callbackCalled = false; -tracked1.emit('destroy'); -emitter1.emit('signal'); -emitter2.emit('signal'); + obj.connectObject('signal', () => (callbackCalled = true), tracker); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(tracker, 'destroy')); -JsUnit.assertEquals(count, 20); + obj.emit('signal'); + JsUnit.assertTrue(callbackCalled); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + JsUnit.assertTrue(hasSignalHandler(tracker, 'destroy')); + + tracker.emit('destroy'); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); + JsUnit.assertFalse(hasSignalHandler(tracker, 'destroy')); +}); + +testCase('Emitter with no tracker, disconnects on destruction', () => { + const obj = new GObjectEmitter(); + let callbackCalled = false; + + obj.connectObject('signal', () => (callbackCalled = true)); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + obj.emit('signal'); + JsUnit.assertTrue(callbackCalled); + + obj.emit('destroy'); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); + +testCase('Emitter with empty tracker, disconnects on disconnectObject', () => { + const obj = new GObjectEmitter(); + const tracker = {}; + + let callbackCalled = false; + obj.connectObject('signal', () => (callbackCalled = true), tracker); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + obj.emit('signal'); + JsUnit.assertTrue(callbackCalled); + + obj.disconnectObject(tracker); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); + +testCase('Emitter with no tracker, disconnects on disconnectObject', () => { + const obj = new GObjectEmitter(); + let callbackCalled = false; + obj.connectObject('signal', () => (callbackCalled = true)); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + obj.emit('signal'); + JsUnit.assertTrue(callbackCalled); + + obj.disconnectObject(); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); + +testCase('Signal arguments are respected', () => { + const emitter = new Signals.EventEmitter(); + const tracked = new Destroyable(); + let cbCalled = false; + + emitter.connectObject('signal', (...args) => { + TestUtils.assertArrayEquals([emitter, 'add', 4, 'arguments', null], args); + cbCalled = true; + }, tracked); + + emitter.emit('signal', 'add', 4, 'arguments', null); + tracked.emit('destroy'); + + JsUnit.assertTrue(cbCalled); + emitter.emit('signal'); +}); + +testCase('JSObject signal arguments can be swapped', () => { + const emitter = new Signals.EventEmitter(); + const tracked = new Destroyable(); + let cbCalled = false; + + emitter.connectObject('signal', (...args) => { + TestUtils.assertArrayEquals(['add', 4, 'arguments', null, emitter], args); + cbCalled = true; + }, GObject.ConnectFlags.SWAPPED, tracked); + + emitter.emit('signal', 'add', 4, 'arguments', null); + tracked.emit('destroy'); + + JsUnit.assertTrue(cbCalled); + emitter.emit('signal'); +}); + +testCase('GObject signal arguments can be swapped', () => { + const emitter = new GObjectEmitterInt(); + const tracked = new Destroyable(); + let cbCalled = false; + + emitter.connectObject('signal-int', (...args) => { + TestUtils.assertArrayEquals([5, emitter], args); + cbCalled = true; + }, GObject.ConnectFlags.SWAPPED, tracked); + + emitter.emit('signal-int', 5); + tracked.emit('destroy'); + + JsUnit.assertTrue(cbCalled); + + cbCalled = false; + emitter.emit('signal-int', 10); + JsUnit.assertFalse(cbCalled); +}); + +testCase('Signal after connection is respected', () => { + let callbackCalled = false; + let callbackAfterCalled = false; + const emitter = new GObjectEmitter(); + + emitter.connectObject('signal', () => { + JsUnit.assertTrue(callbackCalled); + JsUnit.assertFalse(callbackAfterCalled); + callbackAfterCalled = true; + }, GObject.ConnectFlags.AFTER, {}); + + emitter.connectObject('signal', () => { + JsUnit.assertFalse(callbackAfterCalled); + JsUnit.assertFalse(callbackCalled); + callbackCalled = true; + }); + + emitter.emit('signal'); + JsUnit.assertTrue(callbackCalled); + JsUnit.assertTrue(callbackAfterCalled); +}); + +testCase('Signal after connection is respected in batch connections', () => { + let callbackCalled = false; + let callbackAfterCalled = false; + const emitter = new GObjectEmitter(); + + emitter.connectObject( + 'signal', () => { + JsUnit.assertTrue(callbackCalled); + JsUnit.assertFalse(callbackAfterCalled); + callbackAfterCalled = true; + }, GObject.ConnectFlags.AFTER, + 'signal', () => { + JsUnit.assertFalse(callbackAfterCalled); + JsUnit.assertFalse(callbackCalled); + callbackCalled = true; + }); + + emitter.emit('signal'); + JsUnit.assertTrue(callbackCalled); + JsUnit.assertTrue(callbackAfterCalled); +}); + +testCase('Signal connections once are automatically disconnected from GObject', () => { + let callback1Called = 0; + let callback2Called = 0; + const obj = new GObjectEmitter(); + + obj.connectObject( + 'signal', () => { + callback1Called++; + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + }, GObject.ConnectFlags.SHELL_ONCE, + 'signal', () => { + callback2Called++; + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + }, GObject.ConnectFlags.SHELL_ONCE); + + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + obj.emit('signal'); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); + + JsUnit.assertEquals(1, callback1Called); + JsUnit.assertEquals(1, callback2Called); +}); + +testCase('Signal connections once are automatically disconnected from GObject keeping destroy monitor', () => { + let callback1Called = 0; + let callback2Called = 0; + + const obj = new GObjectEmitterInt(); + obj.connectObject( + 'signal-int', expectedCalls => { + JsUnit.assertEquals(callback1Called, expectedCalls); + callback1Called++; + JsUnit.assertFalse(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + obj.emit('signal-int', 10); + }, GObject.ConnectFlags.SHELL_ONCE | GObject.ConnectFlags.SWAPPED, + 'signal', () => { + callback2Called++; + JsUnit.assertFalse(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + }); + + JsUnit.assertTrue(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + obj.emit('signal-int', 0); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + JsUnit.assertEquals(1, callback1Called); + JsUnit.assertEquals(0, callback2Called); + + obj.emit('signal'); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertTrue(hasSignalHandler(obj, 'signal')); + JsUnit.assertTrue(hasSignalHandler(obj, 'destroy')); + + JsUnit.assertEquals(1, callback1Called); + JsUnit.assertEquals(1, callback2Called); + + obj.emit('signal'); + JsUnit.assertEquals(2, callback2Called); + + obj.disconnectObject(); + + JsUnit.assertFalse(hasSignalHandler(obj, 'signal-int')); + JsUnit.assertFalse(hasSignalHandler(obj, 'signal')); + JsUnit.assertFalse(hasSignalHandler(obj, 'destroy')); +}); + +testCase('Signal connections once are automatically disconnected from JSObject', () => { + let callback1Called = 0; + let callback2Called = 0; + const obj = new Signals.EventEmitter(); + + obj.connectObject( + 'signal', (...args) => { + TestUtils.assertArrayEquals(args, ['arg1', 2, obj]); + callback1Called++; + }, GObject.ConnectFlags.SHELL_ONCE | GObject.ConnectFlags.SWAPPED, + 'signal', (...args) => { + TestUtils.assertArrayEquals(args, [obj, 'arg1', 2]); + callback2Called++; + }, GObject.ConnectFlags.SHELL_ONCE); + + obj.emit('destroy'); // It's ignored in such objects, must be no-op! + obj.emit('signal', 'arg1', 2); + + JsUnit.assertEquals(1, callback1Called); + JsUnit.assertEquals(1, callback2Called); + + obj.emit('signal'); + JsUnit.assertEquals(1, callback1Called); + JsUnit.assertEquals(1, callback2Called); + + obj.connectObject( + 'signal1', () => { + callback1Called++; + obj.emit('signal1'); + }, GObject.ConnectFlags.SHELL_ONCE, + 'signal2', () => { + callback2Called++; + }); + + obj.emit('signal1'); + JsUnit.assertEquals(2, callback1Called); + JsUnit.assertEquals(1, callback2Called); + + obj.emit('signal2'); + JsUnit.assertEquals(2, callback1Called); + JsUnit.assertEquals(2, callback2Called); + + obj.disconnectObject(); + obj.emit('signal1'); + obj.emit('signal2'); + + JsUnit.assertEquals(2, callback1Called); + JsUnit.assertEquals(2, callback2Called); +}); + +testCase('Signal connections once are disconnected on tracker destruction', () => { + let callback1Called = 0; + let callback2Called = 0; + const obj = new Signals.EventEmitter(); + const tracker = new Destroyable(); + + obj.connectObject( + 'signal-once', () => { + callback1Called++; + }, GObject.ConnectFlags.SHELL_ONCE | GObject.ConnectFlags.SWAPPED, + 'signal-once', () => { + callback2Called++; + }, GObject.ConnectFlags.SHELL_ONCE, + tracker); + + JsUnit.assertTrue(hasSignalHandler(tracker, 'destroy')); + + tracker.emit('destroy'); + + obj.emit('signal-once', 'arg1', 'arg2'); + JsUnit.assertEquals(0, callback2Called); + JsUnit.assertEquals(0, callback1Called); + + JsUnit.assertFalse(hasSignalHandler(tracker, 'destroy')); +}); diff --git a/tests/unit/signals.js b/tests/unit/signals.js new file mode 100644 index 0000000000000000000000000000000000000000..ceb48eb8509e0705d35ed8ac5ff719fdea00aabf --- /dev/null +++ b/tests/unit/signals.js @@ -0,0 +1,73 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const {jsUnit: JsUnit} = imports; +const {testUtils: TestUtils} = imports.unit; +const {testCase} = TestUtils; +const {signals: Signals} = imports.misc; + +const Environment = imports.ui.environment; + +Environment.init(); + +testCase('EventEmitter simple connections', () => { + let fooCalled = 0, barCalled = 0; + const emitter = new Signals.EventEmitter(); + const idFoo = emitter.connect('foo', () => ++fooCalled); + const idBar = emitter.connect('bar', () => ++barCalled); + + emitter.emit('foo'); + JsUnit.assertEquals(1, fooCalled); + JsUnit.assertEquals(0, barCalled); + + emitter.disconnect(idBar); + emitter.emit('bar'); + JsUnit.assertEquals(0, barCalled); + + emitter.disconnect(idFoo); +}); + +testCase('EventEmitter callbacks blocked', () => { + let fooCalled = false, foo2Called = false; + const emitter = new Signals.EventEmitter(); + + emitter.connect('foo', () => { + fooCalled = true; + return true; + }); + emitter.connect('foo', () => (foo2Called = true)); + + emitter.emit('foo'); + + JsUnit.assertTrue(fooCalled); + JsUnit.assertFalse(foo2Called); +}); + +testCase('EventEmitter connections', () => { + let fooCalled = 0, barCalled = 0; + const emitter = new Signals.EventEmitter(); + emitter.disconnect(emitter.connect('foo', () => TestUtils.assertNotReached())); + const idFoo = emitter.connect('foo', (self, ...args) => { + JsUnit.assertEquals(self, emitter); + TestUtils.assertArrayEquals(args, ['args', 5, null, emitter]); + fooCalled++; + }); + const idBar = emitter.connect('bar', () => ++barCalled); + + JsUnit.assertTrue(emitter.signalHandlerIsConnected(idFoo)); + JsUnit.assertTrue(emitter.signalHandlerIsConnected(idBar)); + + emitter.emit('foo', 'args', 5, null, emitter); + JsUnit.assertEquals(1, fooCalled); + JsUnit.assertEquals(0, barCalled); + + emitter.disconnect(idBar); + JsUnit.assertTrue(emitter.signalHandlerIsConnected(idFoo)); + JsUnit.assertFalse(emitter.signalHandlerIsConnected(idBar)); + + emitter.emit('bar'); + JsUnit.assertEquals(0, barCalled); + + emitter.disconnect(idFoo); + JsUnit.assertFalse(emitter.signalHandlerIsConnected(idFoo)); + JsUnit.assertFalse(emitter.signalHandlerIsConnected(idBar)); +}); diff --git a/tests/unit/testUtils.js b/tests/unit/testUtils.js new file mode 100644 index 0000000000000000000000000000000000000000..0e623b56ead5be09c6bacf743730e070e3f8428c --- /dev/null +++ b/tests/unit/testUtils.js @@ -0,0 +1,70 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- +/* exported testCase, assertNotReached, checkIsNotReachedError, + assertRaisesError, assertArrayEquals */ + +const {jsUnit: JsUnit} = imports; + +class NotReachedError extends Error {} + +/** + * Runs test cases + * + * @param {string} name - The name of the test case + * @param {Function} test - The test function to execute + */ +function testCase(name, test) { + print(`Running test ${name}`); + test(); +} + +/** + * Deeply compares two arrays for equality + * + * @param {Array} array1 - The array to compare + * @param {Array} array2 - The array to compare + */ +function assertArrayEquals(array1, array2) { + JsUnit.assertEquals(array1.length, array2.length); + for (let j = 0; j < array1.length; j++) + JsUnit.assertEquals(array1[j], array2[j]); +} + +/** + * Ensures this code is not reached, throwing an error in case. + */ +function assertNotReached() { + const error = new NotReachedError('This must not be reached'); + throw error; +} + +/** + * Verifies that an error is not thrown by assertNotReached() + * + * @param {Error} error - An error to check + */ +function checkIsNotReachedError(error) { + JsUnit.assertFalse(error instanceof NotReachedError); +} + +/** + * Check if the function call would raise an error, comparing the result + * + * @param {Function} func - The function to verify + * @param {(Error|string)} [error] - An error or an error message to compare + */ +function assertRaisesError(func, error = undefined) { + if (!(func instanceof Function)) + throw new Error('Argument must be a function'); + + try { + func(); + assertNotReached(); + } catch (e) { + checkIsNotReachedError(e); + + if (error instanceof Error) + JsUnit.assertEquals(error, e); + else if (typeof error === 'string') + JsUnit.assertTrue(e.message.includes(error)); + } +}