[gjs/ewlsh/nova-repl: 5/7] modules: Add events module for EventEmitter API
- From: Evan Welsh <ewlsh src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [gjs/ewlsh/nova-repl: 5/7] modules: Add events module for EventEmitter API
- Date: Sun, 30 Jan 2022 05:16:44 +0000 (UTC)
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]