[gjs/ewlsh/nova-repl: 5/7] modules: Add events module for EventEmitter API




commit bba0ca1503b2d046f08ea2718e04d72af08fe778
Author: Evan Welsh <contact evanwelsh com>
Date:   Sun Jan 23 23:16:22 2022 -0800

    modules: Add events module for EventEmitter API

 installed-tests/js/.eslintrc.yml  |   2 +
 installed-tests/js/log.js         |  68 ++++++++++++++++++
 installed-tests/js/matchers.js    |   3 +-
 installed-tests/js/meson.build    |   3 +-
 installed-tests/js/testConsole.js | 120 +++++++-------------------------
 installed-tests/js/testEvents.js  | 139 ++++++++++++++++++++++++++++++++++++
 js.gresource.xml                  |   1 +
 modules/esm/events.js             | 143 ++++++++++++++++++++++++++++++++++++++
 8 files changed, 382 insertions(+), 97 deletions(-)
---
diff --git a/installed-tests/js/.eslintrc.yml b/installed-tests/js/.eslintrc.yml
index a8d60f9a1..d10c2c904 100644
--- a/installed-tests/js/.eslintrc.yml
+++ b/installed-tests/js/.eslintrc.yml
@@ -25,11 +25,13 @@ rules:
 overrides:
   - files:
       - matchers.js
+      - log.js
       - testAsync.js
       - testCairoModule.js
       - testConsole.js
       - testESModules.js
       - testEncoding.js
+      - testEvents.js
       - testGLibLogWriter.js
       - testTimers.js
       - modules/importmeta.js
diff --git a/installed-tests/js/log.js b/installed-tests/js/log.js
new file mode 100644
index 000000000..60d55c071
--- /dev/null
+++ b/installed-tests/js/log.js
@@ -0,0 +1,68 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2022 Evan Welsh <contact evanwelsh com>
+
+import GLib from 'gi://GLib';
+
+import {DEFAULT_LOG_DOMAIN} from 'console';
+import {decodedStringMatching} from './matchers.js';
+
+export function objectContainingLogMessage(
+    message,
+    domain = DEFAULT_LOG_DOMAIN,
+    fields = {}
+) {
+    return jasmine.objectContaining({
+        MESSAGE: decodedStringMatching(message),
+        GLIB_DOMAIN: decodedStringMatching(domain),
+        ...fields,
+    });
+}
+
+/**
+ * @param {jasmine.Spy<(_level: any, _fields: any) => any>} writerFunc _
+ * @param {RegExp | string} message _
+ * @param {*} [logLevel] _
+ * @param {*} [domain] _
+ * @param {*} [fields] _
+ */
+export function expectLog(
+    writerFunc,
+    message,
+    logLevel = GLib.LogLevelFlags.LEVEL_MESSAGE,
+    domain = DEFAULT_LOG_DOMAIN,
+    fields = {}
+) {
+    expect(writerFunc).toHaveBeenCalledOnceWith(
+        logLevel,
+        objectContainingLogMessage(message, domain, fields)
+    );
+
+    // Always reset the calls, so that we can assert at the end that no
+    // unexpected messages were logged
+    writerFunc.calls.reset();
+}
+
+export function spyOnWriterFunc() {
+    /** @type {jasmine.Spy<(_level: any, _fields: any) => any>} */
+    let writerFunc = jasmine.createSpy(
+        'Console test writer func',
+        function (level, _fields) {
+            if (level === GLib.LogLevelFlags.ERROR)
+                return GLib.LogWriterOutput.UNHANDLED;
+
+            return GLib.LogWriterOutput.HANDLED;
+        }
+    );
+
+    beforeAll(function () {
+        writerFunc.and.callThrough();
+
+        GLib.log_set_writer_func(writerFunc);
+    });
+
+    beforeEach(function () {
+        writerFunc.calls.reset();
+    });
+
+    return writerFunc;
+}
diff --git a/installed-tests/js/matchers.js b/installed-tests/js/matchers.js
index 1e05828f5..676e68d23 100644
--- a/installed-tests/js/matchers.js
+++ b/installed-tests/js/matchers.js
@@ -26,8 +26,7 @@ export function arrayLikeWithExactContents(elements) {
          * @returns {string}
          */
         jasmineToString() {
-            return `<arrayLikeWithExactContents(${
-                elements.constructor.name
+            return `<arrayLikeWithExactContents(${elements.constructor.name
             }[${JSON.stringify(Array.from(elements))}]>)`;
         },
     };
diff --git a/installed-tests/js/meson.build b/installed-tests/js/meson.build
index 6a5c40b50..5f20bde61 100644
--- a/installed-tests/js/meson.build
+++ b/installed-tests/js/meson.build
@@ -223,7 +223,7 @@ gdbus_test_description = configure_file(
     install_dir: installed_tests_metadir)
 
 if get_option('installed_tests')
-    install_data('matchers.js', 'testGDBus.js',
+    install_data('matchers.js', 'log.js', 'testGDBus.js',
         install_dir: installed_js_tests_dir)
 endif
 
@@ -235,6 +235,7 @@ modules_tests = [
     'Console',
     'ESModules',
     'Encoding',
+    'Events',
     'GLibLogWriter',
     'Timers',
 ]
diff --git a/installed-tests/js/testConsole.js b/installed-tests/js/testConsole.js
index 95049d577..337778735 100644
--- a/installed-tests/js/testConsole.js
+++ b/installed-tests/js/testConsole.js
@@ -5,69 +5,10 @@
 /// <reference types="jasmine" />
 
 import GLib from 'gi://GLib';
-import {DEFAULT_LOG_DOMAIN} from 'console';
-
-import {decodedStringMatching} from './matchers.js';
-
-function objectContainingLogMessage(
-    message,
-    domain = DEFAULT_LOG_DOMAIN,
-    fields = {}
-) {
-    return jasmine.objectContaining({
-        MESSAGE: decodedStringMatching(message),
-        GLIB_DOMAIN: decodedStringMatching(domain),
-        ...fields,
-    });
-}
+import {spyOnWriterFunc, expectLog} from './log.js';
 
 describe('console', function () {
-    /** @type {jasmine.Spy<(_level: any, _fields: any) => any>} */
-    let writer_func;
-
-    /**
-     * @param {RegExp | string} message _
-     * @param {*} [logLevel] _
-     * @param {*} [domain] _
-     * @param {*} [fields] _
-     */
-    function expectLog(
-        message,
-        logLevel = GLib.LogLevelFlags.LEVEL_MESSAGE,
-        domain = DEFAULT_LOG_DOMAIN,
-        fields = {}
-    ) {
-        expect(writer_func).toHaveBeenCalledOnceWith(
-            logLevel,
-            objectContainingLogMessage(message, domain, fields)
-        );
-
-        // Always reset the calls, so that we can assert at the end that no
-        // unexpected messages were logged
-        writer_func.calls.reset();
-    }
-
-    beforeAll(function () {
-        writer_func = jasmine.createSpy(
-            'Console test writer func',
-            function (level, _fields) {
-                if (level === GLib.LogLevelFlags.ERROR)
-                    return GLib.LogWriterOutput.UNHANDLED;
-
-                return GLib.LogWriterOutput.HANDLED;
-            }
-        );
-
-        writer_func.and.callThrough();
-
-        // @ts-expect-error The existing binding doesn't accept any parameters because
-        // it is a raw pointer.
-        GLib.log_set_writer_func(writer_func);
-    });
-
-    beforeEach(function () {
-        writer_func.calls.reset();
-    });
+    const writerFunc = spyOnWriterFunc();
 
     it('has correct object tag', function () {
         expect(console.toString()).toBe('[object console]');
@@ -76,31 +17,19 @@ describe('console', function () {
     it('logs a message', function () {
         console.log('a log');
 
-        expect(writer_func).toHaveBeenCalledOnceWith(
-            GLib.LogLevelFlags.LEVEL_MESSAGE,
-            objectContainingLogMessage('a log')
-        );
-        writer_func.calls.reset();
+        expectLog(writerFunc, 'a log', GLib.LogLevelFlags.LEVEL_MESSAGE);
     });
 
     it('logs a warning', function () {
         console.warn('a warning');
 
-        expect(writer_func).toHaveBeenCalledOnceWith(
-            GLib.LogLevelFlags.LEVEL_WARNING,
-            objectContainingLogMessage('a warning')
-        );
-        writer_func.calls.reset();
+        expectLog(writerFunc, 'a warning', GLib.LogLevelFlags.LEVEL_WARNING);
     });
 
     it('logs an informative message', function () {
         console.info('an informative message');
 
-        expect(writer_func).toHaveBeenCalledOnceWith(
-            GLib.LogLevelFlags.LEVEL_INFO,
-            objectContainingLogMessage('an informative message')
-        );
-        writer_func.calls.reset();
+        expectLog(writerFunc, 'an informative message', GLib.LogLevelFlags.LEVEL_INFO);
     });
 
     describe('clear()', function () {
@@ -110,19 +39,19 @@ describe('console', function () {
 
         it('resets indentation', function () {
             console.group('a group');
-            expectLog('a group');
+            expectLog(writerFunc, 'a group');
             console.log('a log');
-            expectLog('  a log');
+            expectLog(writerFunc, '  a log');
             console.clear();
             console.log('a log');
-            expectLog('a log');
+            expectLog(writerFunc, 'a log');
         });
     });
 
     describe('table()', function () {
         it('logs at least something', function () {
             console.table(['title', 1, 2, 3]);
-            expectLog(/title/);
+            expectLog(writerFunc, /title/);
         });
     });
 
@@ -143,37 +72,37 @@ describe('console', function () {
         Object.entries(functions).forEach(([fn, level]) => {
             it(`console.${fn}() supports %s`, function () {
                 console[fn]('Does this %s substitute correctly?', 'modifier');
-                expectLog('Does this modifier substitute correctly?', level);
+                expectLog(writerFunc, 'Does this modifier substitute correctly?', level);
             });
 
             it(`console.${fn}() supports %d`, function () {
                 console[fn]('Does this %d substitute correctly?', 10);
-                expectLog('Does this 10 substitute correctly?', level);
+                expectLog(writerFunc, 'Does this 10 substitute correctly?', level);
             });
 
             it(`console.${fn}() supports %i`, function () {
                 console[fn]('Does this %i substitute correctly?', 26);
-                expectLog('Does this 26 substitute correctly?', level);
+                expectLog(writerFunc, 'Does this 26 substitute correctly?', level);
             });
 
             it(`console.${fn}() supports %f`, function () {
                 console[fn]('Does this %f substitute correctly?', 27.56331);
-                expectLog('Does this 27.56331 substitute correctly?', level);
+                expectLog(writerFunc, 'Does this 27.56331 substitute correctly?', level);
             });
 
             it(`console.${fn}() supports %o`, function () {
                 console[fn]('Does this %o substitute correctly?', new Error());
-                expectLog(/Does this Error\n.*substitute correctly\?/s, level);
+                expectLog(writerFunc, /Does this Error\n.*substitute correctly\?/s, level);
             });
 
             it(`console.${fn}() supports %O`, function () {
                 console[fn]('Does this %O substitute correctly?', new Error());
-                expectLog('Does this {} substitute correctly?', level);
+                expectLog(writerFunc, 'Does this {} substitute correctly?', level);
             });
 
             it(`console.${fn}() ignores %c`, function () {
                 console[fn]('Does this %c substitute correctly?', 'modifier');
-                expectLog('Does this  substitute correctly?', level);
+                expectLog(writerFunc, 'Does this  substitute correctly?', level);
             });
 
             it(`console.${fn}() supports mixing substitutions`, function () {
@@ -184,6 +113,7 @@ describe('console', function () {
                     14
                 );
                 expectLog(
+                    writerFunc,
                     'Does this string and the 3.14 substitute correctly alongside 14?',
                     level
                 );
@@ -194,12 +124,13 @@ describe('console', function () {
                     'Does this support parsing %i incorrectly?',
                     'a string'
                 );
-                expectLog('Does this support parsing NaN incorrectly?', level);
+                expectLog(writerFunc, 'Does this support parsing NaN incorrectly?', level);
             });
 
             it(`console.${fn}() supports missing substitutions`, function () {
                 console[fn]('Does this support a missing %s substitution?');
                 expectLog(
+                    writerFunc,
                     'Does this support a missing %s substitution?',
                     level
                 );
@@ -212,20 +143,21 @@ describe('console', function () {
             console.time('testing time');
 
             // console.time logs nothing.
-            expect(writer_func).not.toHaveBeenCalled();
+            expect(writerFunc).not.toHaveBeenCalled();
 
             setTimeout(() => {
                 console.timeLog('testing time');
 
-                expectLog(/testing time: (.*)ms/);
+                expectLog(writerFunc, /testing time: (.*)ms/);
 
                 console.timeEnd('testing time');
 
-                expectLog(/testing time: (.*)ms/);
+                expectLog(writerFunc, /testing time: (.*)ms/);
 
                 console.timeLog('testing time');
 
                 expectLog(
+                    writerFunc,
                     "No time log found for label: 'testing time'.",
                     GLib.LogLevelFlags.LEVEL_WARNING
                 );
@@ -238,11 +170,11 @@ describe('console', function () {
             console.time('testing time');
 
             // console.time logs nothing.
-            expect(writer_func).not.toHaveBeenCalled();
+            expect(writerFunc).not.toHaveBeenCalled();
 
             setTimeout(() => {
                 console.timeEnd('testing time');
-                expectLog(/testing time: (.*)ms/);
+                expectLog(writerFunc, /testing time: (.*)ms/);
 
                 done();
             }, 10);
@@ -250,7 +182,7 @@ describe('console', function () {
 
         afterEach(function () {
             // Ensure we only got the log lines that we expected
-            expect(writer_func).not.toHaveBeenCalled();
+            expect(writerFunc).not.toHaveBeenCalled();
         });
     });
 });
diff --git a/installed-tests/js/testEvents.js b/installed-tests/js/testEvents.js
new file mode 100644
index 000000000..b21556459
--- /dev/null
+++ b/installed-tests/js/testEvents.js
@@ -0,0 +1,139 @@
+/* eslint-disable no-restricted-properties */
+// SPDX-License-Connectionentifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2008 litl, LLC
+// SPDX-FileCopyrightText: 2022 Evan Welsh <contact evanwelsh com>
+
+import GLib from 'gi://GLib';
+
+import {EventEmitter} from 'events';
+import {spyOnWriterFunc, expectLog} from './log.js';
+
+class FooEmitter extends EventEmitter { }
+
+describe('Class extending EventEmitter', () => {
+    let foo, bar;
+
+    beforeEach(function () {
+        foo = new FooEmitter();
+        bar = jasmine.createSpy('bar');
+    });
+
+    it('calls a signal handler when a signal is emitted', function () {
+        foo.connect('bar', bar);
+        foo.emit('bar', 'This is a', 'This is b');
+        expect(bar).toHaveBeenCalledWith(foo, 'This is a', 'This is b');
+    });
+
+    it('does not call a signal handler after the signal is disconnected', function () {
+        let connection = foo.connect('bar', bar);
+        foo.emit('bar', 'This is a', 'This is b');
+        bar.calls.reset();
+        foo.disconnect(connection);
+        // this emission should do nothing
+        foo.emit('bar', 'Another a', 'Another b');
+        expect(bar).not.toHaveBeenCalled();
+    });
+
+    it('does not call a signal handler after disconnect() is called on the connection', function () {
+        let connection = foo.connect('bar', bar);
+        foo.emit('bar', 'This is a', 'This is b');
+        bar.calls.reset();
+        connection.disconnect();
+        // this emission should do nothing
+        foo.emit('bar', 'Another a', 'Another b');
+        expect(bar).not.toHaveBeenCalled();
+    });
+
+    it('calls a signal handler after trigger() is called on the connection', function () {
+        foo.connect('bar', bar).trigger();
+
+        expect(bar).toHaveBeenCalled();
+    });
+
+    it('can disconnect a signal handler during signal emission', function () {
+        var toRemove = [];
+        let firstConnection = foo.connect('bar', function (theFoo) {
+            theFoo.disconnect(toRemove[0]);
+            theFoo.disconnect(toRemove[1]);
+        });
+        toRemove.push(foo.connect('bar', bar));
+        toRemove.push(foo.connect('bar', bar));
+
+        // emit signal; what should happen is that the second two handlers are
+        // disconnected before they get invoked
+        foo.emit('bar');
+        expect(bar).not.toHaveBeenCalled();
+
+        // clean up the last handler
+        foo.disconnect(firstConnection);
+
+        expect(foo.signalHandlerIsConnected(firstConnection)).toBeFalse();
+        expect(foo.signalHandlerIsConnected(toRemove[0])).toBeFalse();
+        expect(foo.signalHandlerIsConnected(toRemove[1])).toBeFalse();
+    });
+
+    it('distinguishes multiple signals', function () {
+        let bonk = jasmine.createSpy('bonk');
+        foo.connect('bar', bar);
+        foo.connect('bonk', bonk);
+        foo.connect('bar', bar);
+
+        foo.emit('bar');
+        expect(bar).toHaveBeenCalledTimes(2);
+        expect(bonk).not.toHaveBeenCalled();
+
+        foo.emit('bonk');
+        expect(bar).toHaveBeenCalledTimes(2);
+        expect(bonk).toHaveBeenCalledTimes(1);
+
+        foo.emit('bar');
+        expect(bar).toHaveBeenCalledTimes(4);
+        expect(bonk).toHaveBeenCalledTimes(1);
+
+        foo.disconnectAll();
+        bar.calls.reset();
+        bonk.calls.reset();
+
+        // these post-disconnect emissions should do nothing
+        foo.emit('bar');
+        foo.emit('bonk');
+        expect(bar).not.toHaveBeenCalled();
+        expect(bonk).not.toHaveBeenCalled();
+    });
+
+    it('determines if a signal is connected on a JS object', function () {
+        let connection = foo.connect('bar', bar);
+        expect(foo.signalHandlerIsConnected(connection)).toEqual(true);
+        foo.disconnect(connection);
+        expect(foo.signalHandlerIsConnected(connection)).toEqual(false);
+    });
+
+    describe('with exception in signal handler', function () {
+        const writerFunc = spyOnWriterFunc();
+
+        let bar2;
+
+        beforeEach(function () {
+            bar.and.throwError('Exception we are throwing on purpose');
+            bar2 = jasmine.createSpy('bar');
+            foo.connect('bar', bar);
+            foo.connect('bar', bar2);
+            foo.emit('bar');
+
+            expectLog(writerFunc, /Exception in callback for signal: bar/, 
GLib.LogLevelFlags.LEVEL_CRITICAL);
+        });
+
+        it('does not affect other callbacks', function () {
+            expect(bar).toHaveBeenCalledTimes(1);
+            expect(bar2).toHaveBeenCalledTimes(1);
+        });
+
+        it('does not disconnect the callback', function () {
+            foo.emit('bar');
+            expect(bar).toHaveBeenCalledTimes(2);
+            expect(bar2).toHaveBeenCalledTimes(2);
+
+            expectLog(writerFunc, /Exception in callback for signal: bar/, 
GLib.LogLevelFlags.LEVEL_CRITICAL);
+        });
+    });
+});
diff --git a/js.gresource.xml b/js.gresource.xml
index 4d3fde355..e3eab651a 100644
--- a/js.gresource.xml
+++ b/js.gresource.xml
@@ -17,6 +17,7 @@
     <file>modules/esm/_timers.js</file>
 
     <file>modules/esm/cairo.js</file>
+    <file>modules/esm/events.js</file>
     <file>modules/esm/gettext.js</file>
     <file>modules/esm/console.js</file>
     <file>modules/esm/gi.js</file>
diff --git a/modules/esm/events.js b/modules/esm/events.js
new file mode 100644
index 000000000..c482f0ec0
--- /dev/null
+++ b/modules/esm/events.js
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: MIT OR LGPL-2.0-or-later
+// SPDX-FileCopyrightText: 2022 Evan Welsh <contact evanwelsh com>
+
+class Connection {
+    #instance;
+    #name;
+    #callback;
+    #disconnected;
+
+    /**
+     * @param {object} params _
+     * @param {EventEmitter} params.instance the instance the connection is connected to
+     * @param {string} params.name the name of the signal
+     * @param {Function} params.callback the callback for the signal
+     * @param {boolean} params.disconnected whether the connection is disconnected
+     */
+    constructor({instance, name, callback, disconnected = false}) {
+        this.#instance = instance;
+        this.#name = name;
+        this.#callback = callback;
+        this.#disconnected = disconnected;
+    }
+
+    disconnect() {
+        this.#instance.disconnect(this);
+    }
+
+    trigger(...args) {
+        this.#callback.apply(null, [this.#instance, ...args]);
+    }
+
+    get name() {
+        return this.#name;
+    }
+
+    set disconnected(value) {
+        if (!value)
+            throw new Error('Connections cannot be re-connected.');
+
+        this.#disconnected = value;
+    }
+
+    get disconnected() {
+        return this.#disconnected;
+    }
+}
+
+export class EventEmitter {
+    /** @type {Connection[]} */
+    #signalConnections = [];
+
+    connect(name, callback) {
+        // be paranoid about callback arg since we'd start to throw from emit()
+        // if it was messed up
+        if (typeof callback !== 'function')
+            throw new Error('When connecting signal must give a callback that is a function');
+
+        const connection = new Connection({
+            instance: this,
+            name,
+            callback,
+        });
+
+        // this makes it O(n) in total connections to emit, but I think
+        // it's right to optimize for low memory and reentrancy-safety
+        // rather than speed
+        this.#signalConnections.push(connection);
+
+        return connection;
+    }
+
+    /**
+     * @param {Connection} connection the connection returned by {@link connect}
+     */
+    disconnect(connection) {
+        if (connection.disconnected)
+            throw new Error(`Signal handler for ${connection.name} already disconnected`);
+
+        const index = this.#signalConnections.indexOf(connection);
+        if (index !== -1) {
+            // Mark the connection as disconnected.
+            connection.disconnected = true;
+
+            this.#signalConnections.splice(index, 1);
+            return;
+        }
+
+        throw new Error('No signal connection found for connection');
+    }
+
+    /**
+     * @param {Connection} connection the connection returned by {@link connect}
+     * @returns {boolean} whether the signal connection is connected
+     */
+    signalHandlerIsConnected(connection) {
+        const index = this.#signalConnections.indexOf(connection);
+        return index !== -1 && !connection.disconnected;
+    }
+
+    disconnectAll() {
+        while (this.#signalConnections.length > 0)
+            this.#signalConnections[0].disconnect();
+    }
+
+    /**
+     * @param {string} name the signal name to emit
+     * @param {...any} args the arguments to pass
+     */
+    emit(name, ...args) {
+        // To deal with re-entrancy (removal/addition while
+        // emitting), we copy out a list of what was connected
+        // at emission start; and just before invoking each
+        // handler we check its disconnected flag.
+        let handlers = [];
+        let i;
+        let length = this.#signalConnections.length;
+        for (i = 0; i < length; ++i) {
+            let connection = this.#signalConnections[i];
+            if (connection.name === name)
+                handlers.push(connection);
+        }
+
+        length = handlers.length;
+        for (i = 0; i < length; ++i) {
+            let connection = handlers[i];
+            if (!connection.disconnected) {
+                try {
+                    // since we pass "null" for this, the global object will be used.
+                    let ret = connection.trigger(...args);
+
+                    // if the callback returns true, we don't call the next
+                    // signal handlers
+                    if (ret === true)
+                        break;
+                } catch (e) {
+                    // just log any exceptions so that callbacks can't disrupt
+                    // signal emission
+                    console.error(`Exception in callback for signal: ${name}\n`, e);
+                }
+            }
+        }
+    }
+}


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]