5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-09 18:31:29 +08:00

Add runtime tests

This commit is contained in:
Fabio Massaioli 2025-02-25 03:30:59 +01:00
parent 95b4bfc253
commit 52f1b595e8
5 changed files with 618 additions and 69 deletions

View File

@ -28,6 +28,8 @@
"./dist/drag.js"
],
"scripts": {
"check": "npx tsc --noEmit",
"test": "npx vitest run",
"clean": "npx rimraf ./dist ./docs ./types ./tsconfig.tsbuildinfo",
"generate:events": "task generate:events",
"generate": "npm run generate:events",
@ -39,11 +41,21 @@
"prepack": "npm run build"
},
"devDependencies": {
"happy-dom": "^17.1.1",
"promises-aplus-tests": "2.1.2",
"rimraf": "^5.0.5",
"typedoc": "^0.27.7",
"typedoc-plugin-markdown": "^4.4.2",
"typedoc-plugin-mdn-links": "^4.0.13",
"typedoc-plugin-missing-exports": "^3.1.0",
"typescript": "^5.7.3",
"vitest": "^3.0.6"
},
"overrides": {
"promises-aplus-tests": {
"mocha": "^11.1.0",
"sinon": "^19.0.2",
"underscore": "^1.13.7"
}
}
}

View File

@ -0,0 +1,430 @@
import * as util from "node:util";
import { describe, it, beforeEach, afterEach, assert, expect, vi } from "vitest";
import { CancelError, CancellablePromise, CancelledRejectionError } from "./cancellable";
// TODO: In order of importance:
// TODO: test cancellation of subpromises the main promise resolves to.
// TODO: test cancellation of promise chains built by calling then() and friends:
// - all promises up the chain should be cancelled;
// - rejection handlers should be always executed with the CancelError of their parent promise in the chain;
// - promises returned from rejection handlers should be cancelled too;
// - if a rejection handler throws or returns a promise that ultimately rejects,
// it should be reported as an unhandled rejection,
// - unless it is a CancelError with the same reason given for cancelling the returned promise.
// TODO: test multiple calls to cancel() (second and later should have no effect).
let expectedUnhandled = new Map();
process.on('unhandledRejection', function (error, promise) {
let reason = error;
if (reason instanceof CancelledRejectionError) {
promise = reason.promise;
reason = reason.cause;
}
let reasons = expectedUnhandled.get(promise);
const callbacks = reasons?.get(reason);
if (callbacks) {
for (const cb of callbacks) {
try {
cb(reason, promise);
} catch (e) {
console.error("Exception in unhandled rejection callback.", e);
}
}
reasons.delete(reason);
if (reasons.size === 0) {
expectedUnhandled.delete(promise);
}
return;
}
console.log(util.format("Unhandled rejection.\nReason: %o\nPromise: %o", reason, promise));
throw error;
});
function ignoreUnhandled(reason, promise) {
expectUnhandled(reason, promise, null);
}
function expectUnhandled(reason, promise, cb) {
let reasons = expectedUnhandled.get(promise);
if (!reasons) {
reasons = new Map();
expectedUnhandled.set(promise, reasons);
}
let callbacks = reasons.get(reason);
if (!callbacks) {
callbacks = [];
reasons.set(reason, callbacks);
}
if (cb) {
callbacks.push(cb);
}
}
afterEach(() => {
vi.resetAllMocks();
vi.restoreAllMocks();
});
const dummyValue = { value: "value" };
const dummyCause = { dummy: "dummy" };
const dummyError = new Error("dummy");
const oncancelled = vi.fn().mockName("oncancelled");
const sentinel = vi.fn().mockName("sentinel");
const unhandled = vi.fn().mockName("unhandled");
const resolutionPatterns = [
["forever", "pending", (test, value, { cls = CancellablePromise, cb = oncancelled } = {}) => test(
new cls(() => {}, cb)
)],
["already", "fulfilled", (test, value, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
prw.resolve(value ?? dummyValue);
return test(prw.promise);
}],
["immediately", "fulfilled", (test, value, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
const tp = test(prw.promise);
prw.resolve(value ?? dummyValue);
return tp;
}],
["eventually", "fulfilled", async (test, value, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
const tp = test(prw.promise);
await new Promise((resolve) => {
setTimeout(() => {
prw.resolve(value ?? dummyValue);
resolve();
}, 50);
});
return tp;
}],
["already", "rejected", (test, reason, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
prw.reject(reason ?? dummyError);
return test(prw.promise);
}],
["immediately", "rejected", (test, reason, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
const tp = test(prw.promise);
prw.reject(reason ?? dummyError);
return tp;
}],
["eventually", "rejected", async (test, reason, { cls = CancellablePromise, cb = oncancelled } = {}) => {
const prw = cls.withResolvers();
prw.oncancelled = cb;
const tp = test(prw.promise);
await new Promise((resolve) => {
setTimeout(() => {
prw.reject(reason ?? dummyError);
resolve();
}, 50);
});
return tp;
}],
];
describe("CancellablePromise.cancel", ()=> {
it("should suppress its own unhandled cancellation error", async () => {
const p = new CancellablePromise(() => {});
p.cancel();
process.on('unhandledRejection', sentinel);
await new Promise((resolve) => setTimeout(resolve, 100));
process.off('unhandledRejection', sentinel);
expect(sentinel).not.toHaveBeenCalled();
});
it.for([
["rejections", dummyError],
["cancellation errors", new CancelError("dummy", { cause: dummyCause })],
])("should not suppress arbitrary unhandled %s", async ([kind, err]) => {
const p = new CancellablePromise(() => { throw err; });
p.cancel();
await new Promise((resolve) => {
expectUnhandled(err, p, unhandled);
expectUnhandled(err, p, resolve);
});
expect(unhandled).toHaveBeenCalledExactlyOnceWith(err, p);
});
describe.for(resolutionPatterns)("when applied to %s %s promises", ([time, state, test]) => {
if (time === "already") {
it("should have no effect", () => test(async (promise) => {
promise.then(sentinel, sentinel);
let reason;
try {
promise.cancel();
await promise;
assert(state === "fulfilled", "Promise fulfilled unexpectedly");
} catch (err) {
reason = err;
assert(state === "rejected", "Promise rejected unexpectedly");
}
expect(sentinel).toHaveBeenCalled();
expect(oncancelled).not.toHaveBeenCalled();
expect(reason).not.toBeInstanceOf(CancelError);
}));
} else {
if (state === "rejected") {
it("should report late rejections as unhandled", () => test(async (promise) => {
promise.cancel();
await new Promise((resolve) => {
expectUnhandled(dummyError, promise, unhandled);
expectUnhandled(dummyError, promise, resolve);
});
expect(unhandled).toHaveBeenCalledExactlyOnceWith(dummyError, promise);
}));
}
it("should reject with a CancelError", () => test(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
let reason;
try {
promise.cancel();
await promise;
} catch (err) {
reason = err;
}
expect(reason).toBeInstanceOf(CancelError);
}));
it("should call the oncancelled callback synchronously", () => test(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
try {
promise.cancel();
sentinel();
await promise;
} catch {}
expect(oncancelled).toHaveBeenCalledBefore(sentinel);
}));
it("should propagate the given cause", () => test(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
let reason;
try {
promise.cancel(dummyCause);
await promise;
} catch (err) {
reason = err;
}
expect(reason).toBeInstanceOf(CancelError);
expect(reason).toHaveProperty('cause', dummyCause);
expect(oncancelled).toHaveBeenCalledWith(reason.cause);
}));
}
});
});
const onabort = vi.fn().mockName("abort");
const abortPatterns = [
["never", "standalone", (test) => {
const signal = new AbortSignal();
signal.addEventListener('abort', onabort, { capture: true });
return test(signal);
}],
["already", "standalone", (test) => {
const signal = AbortSignal.abort(dummyCause);
onabort();
return test(signal);
}],
["eventually", "standalone", (test) => {
const signal = AbortSignal.timeout(25);
signal.addEventListener('abort', onabort, { capture: true });
return test(signal);
}],
["never", "controller-bound", (test) => {
const signal = new AbortController().signal;
signal.addEventListener('abort', onabort, { capture: true });
return test(signal);
}],
["already", " controller-bound", (test) => {
const ctrl = new AbortController();
ctrl.signal.addEventListener('abort', onabort, { capture: true });
ctrl.abort(dummyCause);
return test(ctrl.signal);
}],
["immediately", "controller-bound", (test) => {
const ctrl = new AbortController();
ctrl.signal.addEventListener('abort', onabort, { capture: true });
const tp = test(ctrl.signal);
ctrl.abort(dummyCause);
return tp;
}],
["eventually", "controller-bound", (test) => {
const ctrl = new AbortController();
ctrl.signal.addEventListener('abort', onabort, { capture: true });
const tp = test(ctrl.signal);
setTimeout(() => ctrl.abort(dummyCause), 25);
return tp;
}]
];
describe("CancellablePromise.cancelOn", ()=> {
it("should return the target promise for chaining", () => {
const p = new CancellablePromise(() => {});
expect(p.cancelOn(AbortSignal.abort())).toBe(p);
});
function tests(abortTime, mode, testSignal, resolveTime, state, testPromise) {
if (abortTime !== "never") {
it(`should call CancellablePromise.cancel ${abortTime === "already" ? "immediately" : "on abort"} with the abort reason as cause`, () => testSignal((signal) => testPromise(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
const cancelSpy = vi.spyOn(promise, 'cancel');
promise.catch(() => {});
promise.cancelOn(signal);
if (signal.aborted) {
sentinel();
} else {
await new Promise((resolve) => {
signal.onabort = () => {
sentinel();
resolve();
};
});
}
expect(cancelSpy).toHaveBeenCalledAfter(onabort);
expect(cancelSpy).toHaveBeenCalledBefore(sentinel);
expect(cancelSpy).toHaveBeenCalledExactlyOnceWith(signal.reason);
})));
}
if (
resolveTime === "already"
|| abortTime === "never"
|| (
["immediately", "eventually"].includes(abortTime)
&& ["already", "immediately"].includes(resolveTime)
)
) {
it("should have no effect", () => testSignal((signal) => testPromise(async (promise) => {
promise.then(sentinel, sentinel);
let reason;
try {
if (resolveTime !== "forever") {
await promise.cancelOn(signal);
assert(state === "fulfilled", "Promise fulfilled unexpectedly");
} else {
await Promise.race([promise, new Promise((resolve) => setTimeout(resolve, 100))]).then(sentinel);
}
} catch (err) {
reason = err;
assert(state === "rejected", "Promise rejected unexpectedly");
}
if (abortTime !== "never" && !signal.aborted) {
// Wait for the AbortSignal to have actually aborted.
await new Promise((resolve) => signal.onabort = resolve);
}
expect(sentinel).toHaveBeenCalled();
expect(oncancelled).not.toHaveBeenCalled();
expect(reason).not.toBeInstanceOf(CancelError);
})));
} else {
if (state === "rejected") {
it("should report late rejections as unhandled", () => testSignal((signal) => testPromise(async (promise) => {
promise.cancelOn(signal);
await new Promise((resolve) => {
expectUnhandled(dummyError, promise, unhandled);
expectUnhandled(dummyError, promise, resolve);
});
expect(unhandled).toHaveBeenCalledExactlyOnceWith(dummyError, promise);
})));
}
it("should reject with a CancelError", () => testSignal((signal) => testPromise(async (promise)=> {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
let reason;
try {
await promise.cancelOn(signal);
} catch (err) {
reason = err;
}
expect(reason).toBeInstanceOf(CancelError);
})));
it(`should call the oncancelled callback ${abortTime === "already" ? "" : "a"}synchronously`, () => testSignal((signal) => testPromise(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
try {
promise.cancelOn(signal);
sentinel();
await promise;
} catch {}
expect(oncancelled).toHaveBeenCalledAfter(onabort);
if (abortTime === "already") {
expect(oncancelled).toHaveBeenCalledBefore(sentinel);
} else {
expect(oncancelled).toHaveBeenCalledAfter(sentinel);
}
})));
it("should propagate the abort reason as cause", () => testSignal((signal) => testPromise(async (promise) => {
// Ignore the unhandled rejection from the test promise.
if (state === "rejected") { ignoreUnhandled(dummyError, promise); }
let reason;
try {
await promise.cancelOn(signal);
} catch (err) {
reason = err;
}
expect(reason).toBeInstanceOf(CancelError);
expect(reason).toHaveProperty('cause', signal.reason);
expect(oncancelled).toHaveBeenCalledWith(signal.reason);
})));
}
}
describe.for(abortPatterns)("when called with %s aborted %s signals", ([abortTime, mode, testSignal]) => {
describe.for(resolutionPatterns)("when applied to %s %s promises", ([resolveTime, state, testPromise]) => {
tests(abortTime, mode, testSignal, resolveTime, state, testPromise);
});
});
describe.for(resolutionPatterns)("when applied to %s %s promises", ([resolveTime, state, testPromise]) => {
describe.for(abortPatterns)("when called with %s aborted %s signals", ([abortTime, mode, testSignal]) => {
tests(abortTime, mode, testSignal, resolveTime, state, testPromise);
});
});
});

View File

@ -1,18 +1,28 @@
import { On, Off, OffAll, OnMultiple, WailsEvent, dispatchWailsEvent, eventListeners, Once } from './events';
import { On, Off, OffAll, OnMultiple, WailsEvent, Once } from './events';
import { eventListeners } from "./listener";
import { expect, describe, it, vi, afterEach, beforeEach } from 'vitest';
const dispatchWailsEvent = window._wails.dispatchWailsEvent;
afterEach(() => {
OffAll();
vi.resetAllMocks();
});
describe('OnMultiple', () => {
let testEvent = new WailsEvent('a', {});
describe("OnMultiple", () => {
const testEvent = { name: 'a', data: ["hello", "events"] };
const cb = vi.fn((ev) => {
expect(ev).toBeInstanceOf(WailsEvent);
expect(ev).toMatchObject(testEvent);
});
it('should stop after a specified number of times', () => {
const cb = vi.fn();
it("should dispatch a properly initialised WailsEvent", () => {
OnMultiple('a', cb, 5);
dispatchWailsEvent(testEvent);
expect(cb).toHaveBeenCalled();
});
it("should stop after the specified number of times", () => {
OnMultiple('a', cb, 5);
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
@ -20,77 +30,122 @@ describe('OnMultiple', () => {
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
expect(cb).toBeCalledTimes(5);
expect(cb).toHaveBeenCalledTimes(5);
});
it('should return a cancel fn', () => {
const cb = vi.fn()
const cancel = OnMultiple('a', cb, 5)
dispatchWailsEvent(testEvent)
dispatchWailsEvent(testEvent)
cancel()
dispatchWailsEvent(testEvent)
dispatchWailsEvent(testEvent)
expect(cb).toBeCalledTimes(2)
})
})
describe('On', () => {
it('should create a listener with a count of -1', () => {
On('a', () => {})
expect(eventListeners.get("a")[0].maxCallbacks).toBe(-1)
})
it('should return a cancel fn', () => {
const cancel = On('a', () => {})
it("should return a cancel fn", () => {
const cancel = OnMultiple('a', cb, 5);
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
cancel();
})
})
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
expect(cb).toBeCalledTimes(2);
});
});
describe('Once', () => {
it('should create a listener with a count of 1', () => {
Once('a', () => {})
expect(eventListeners.get("a")[0].maxCallbacks).toBe(1)
})
describe("On", () => {
let testEvent = { name: 'a', data: ["hello", "events"], sender: "window" };
const cb = vi.fn((ev) => {
expect(ev).toBeInstanceOf(WailsEvent);
expect(ev).toMatchObject(testEvent);
});
it('should return a cancel fn', () => {
const cancel = EventsOn('a', () => {})
it("should dispatch a properly initialised WailsEvent", () => {
On('a', cb);
dispatchWailsEvent(testEvent);
expect(cb).toHaveBeenCalled();
});
it("should never stop", () => {
On('a', cb);
expect(eventListeners.get('a')[0].maxCallbacks).toBe(-1);
dispatchWailsEvent(testEvent);
expect(eventListeners.get('a')[0].maxCallbacks).toBe(-1);
});
it("should return a cancel fn", () => {
const cancel = On('a', cb)
dispatchWailsEvent(testEvent);
cancel();
})
dispatchWailsEvent(testEvent);
expect(cb).toHaveBeenCalledTimes(1);
});
});
describe("Once", () => {
const testEvent = { name: 'a', data: ["hello", "events"] };
const cb = vi.fn((ev) => {
expect(ev).toBeInstanceOf(WailsEvent);
expect(ev).toMatchObject(testEvent);
});
it("should dispatch a properly initialised WailsEvent", () => {
Once('a', cb);
dispatchWailsEvent(testEvent);
expect(cb).toHaveBeenCalled();
});
it("should stop after one time", () => {
Once('a', cb)
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
dispatchWailsEvent(testEvent);
expect(cb).toHaveBeenCalledTimes(1);
});
it("should return a cancel fn", () => {
const cancel = Once('a', cb)
cancel();
dispatchWailsEvent(testEvent);
expect(cb).not.toHaveBeenCalled();
});
})
describe('Off', () => {
describe("Off", () => {
const cba = vi.fn(), cbb = vi.fn(), cbc = vi.fn();
beforeEach(() => {
On('a', () => {})
On('a', () => {})
On('a', () => {})
On('b', () => {})
On('c', () => {})
})
On('a', cba);
On('a', cba);
On('a', cba);
On('b', cbb);
On('c', cbc);
On('c', cbc);
});
it('should cancel all event listeners for a single type', () => {
Off('a')
expect(eventListeners.get('a')).toBeUndefined()
expect(eventListeners.get('b')).not.toBeUndefined()
expect(eventListeners.get('c')).not.toBeUndefined()
})
it("should cancel all event listeners for a single type", () => {
Off('a');
dispatchWailsEvent({ name: 'a' });
dispatchWailsEvent({ name: 'b' });
dispatchWailsEvent({ name: 'c' });
expect(cba).not.toHaveBeenCalled();
expect(cbb).toHaveBeenCalledTimes(1);
expect(cbc).toHaveBeenCalledTimes(2);
});
it('should cancel all event listeners for multiple types', () => {
Off('a', 'b')
expect(eventListeners.get('a')).toBeUndefined()
expect(eventListeners.get('b')).toBeUndefined()
expect(eventListeners.get('c')).not.toBeUndefined()
})
})
it("should cancel all event listeners for multiple types", () => {
Off('a', 'c')
dispatchWailsEvent({ name: 'a' });
dispatchWailsEvent({ name: 'b' });
dispatchWailsEvent({ name: 'c' });
expect(cba).not.toHaveBeenCalled();
expect(cbb).toHaveBeenCalledTimes(1);
expect(cbc).not.toHaveBeenCalled();
});
});
describe('OffAll', () => {
it('should cancel all event listeners', () => {
On('a', () => {})
On('a', () => {})
On('a', () => {})
On('b', () => {})
On('c', () => {})
OffAll()
expect(eventListeners.size).toBe(0)
})
})
describe("OffAll", () => {
it("should cancel all event listeners", () => {
const cba = vi.fn(), cbb = vi.fn(), cbc = vi.fn();
On('a', cba);
On('a', cba);
On('a', cba);
On('b', cbb);
On('c', cbc);
On('c', cbc);
expect(cba).not.toHaveBeenCalled();
expect(cbb).not.toHaveBeenCalled();
expect(cbc).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,44 @@
import * as util from "util";
import * as V from "vitest";
import { CancellablePromise } from "./cancellable";
// The Promises/A+ suite handles some errors late.
process.on('rejectionHandled', function () {});
// The Promises/A+ suite leaves some errors unhandled.
process.on('unhandledRejection', function (reason, promise) {
if (promise instanceof CancellablePromise && reason != null && typeof reason === 'object') {
for (const key of ['dummy', 'other', 'sentinel']) {
if (reason[key] === key) {
return;
}
}
}
throw new Error(`Unhandled rejection at: ${util.inspect(promise)}; reason: ${util.inspect(reason)}`, { cause: reason });
});
// Emulate a minimal version of the mocha BDD API using vitest primitives.
global.context = global.describe = V.describe;
global.specify = global.it = function it(desc, fn) {
let viTestFn = fn;
if (fn && fn.length) {
viTestFn = () => new Promise((done) => fn(done));
}
V.it(desc, viTestFn);
}
global.before = function(desc, fn) { V.beforeAll(typeof desc === 'function' ? desc : fn) };
global.after = function(desc, fn) { V.afterAll(typeof desc === 'function' ? desc : fn) };
global.beforeEach = function(desc, fn) { V.beforeEach(typeof desc === 'function' ? desc : fn) };
global.afterEach = function(desc, fn) { V.afterEach(typeof desc === 'function' ? desc : fn) };
require('promises-aplus-tests').mocha({
resolved(value) {
return CancellablePromise.resolve(value);
},
rejected(reason) {
return CancellablePromise.reject(reason);
},
deferred() {
return CancellablePromise.withResolvers();
}
});

View File

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: 'happy-dom',
testTimeout: 200
},
});