diff --git a/packages/common/test/_nodeSetup.ts b/packages/common/test/_nodeSetup.ts new file mode 100644 index 000000000..4a345301d --- /dev/null +++ b/packages/common/test/_nodeSetup.ts @@ -0,0 +1,57 @@ +// Polyfills for Node.js test environment +// The @evolu/common package uses ES2024+ features that aren't available in Node.js + +// IMPORTANT: Install polyfills FIRST before any other imports +// This provides AsyncDisposableStack, DisposableStack, Symbol.dispose, etc. +import { installPolyfills } from "../src/Polyfills.js"; +installPolyfills(); + +// Polyfill WebSocket for Node.js tests (after polyfills are installed) +import { WebSocket } from "ws"; + +if (!globalThis.WebSocket) { + // @ts-expect-error - ws WebSocket is compatible enough for our needs + globalThis.WebSocket = WebSocket; +} + +// Polyfill Promise.try for Node.js/Bun versions that don't support it (ES2024) +if (!Promise.try) { + // @ts-ignore - Adding ES2024 Promise.try polyfill + Promise.try = function ( + callback: (...args: Args) => T | PromiseLike, + ...args: Args + ): Promise { + return Promise.resolve().then(() => callback(...args)); + }; +} + +// Polyfill Promise.withResolvers for Node.js/Bun versions that don't support it (ES2024) +if (!Promise.withResolvers) { + // @ts-ignore - Adding ES2024 Promise.withResolvers polyfill + Promise.withResolvers = function (): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; + } { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; + }; +} + +// Polyfill Set.prototype.difference for Node.js/Bun (ES2025) +if (!Set.prototype.difference) { + // @ts-ignore - Adding ES2025 Set.prototype.difference polyfill + Set.prototype.difference = function (this: Set, other: Set): Set { + const result = new Set(this); + for (const elem of other) { + result.delete(elem); + } + return result; + }; +} + diff --git a/packages/common/vitest.config.ts b/packages/common/vitest.config.ts index 71b04ecd9..2b5ed271a 100644 --- a/packages/common/vitest.config.ts +++ b/packages/common/vitest.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ include: ["test/**/*.test.ts"], name: "unit", environment: "node", + setupFiles: ["./test/_nodeSetup.ts"], }, }, { diff --git a/packages/nodejs/test/Task.test.ts b/packages/nodejs/test/Task.test.ts index e785cd5aa..1b8fabf18 100644 --- a/packages/nodejs/test/Task.test.ts +++ b/packages/nodejs/test/Task.test.ts @@ -2,6 +2,24 @@ import { type MainTask, ok, testCreateConsole } from "@evolu/common"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { runMain } from "../src/Task.js"; +/** + * Helper function to create a deferred promise (similar to Promise.withResolvers). + * This is needed for compatibility with environments that don't support Promise.withResolvers. + */ +const withResolvers = (): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + describe("runMain", () => { beforeEach(() => { // Clean up any signal listeners from previous tests @@ -25,7 +43,7 @@ describe("runMain", () => { test("executes main task", async () => { let called = false; - const executed = Promise.withResolvers(); + const executed = withResolvers(); runMain({})(() => { called = true; @@ -39,7 +57,7 @@ describe("runMain", () => { }); test("passes custom deps", async () => { - const depsValue = Promise.withResolvers(); + const depsValue = withResolvers(); const customDep = { myValue: 42 }; interface MyDep { @@ -60,7 +78,7 @@ describe("runMain", () => { test("handles aborted runner", async () => { let taskRan = false; - const taskCompleted = Promise.withResolvers(); + const taskCompleted = withResolvers(); runMain({})(async (run) => { taskRan = true; @@ -78,8 +96,8 @@ describe("runMain", () => { test("disposes returned Disposable after signal", async () => { let disposed = false; - const taskStarted = Promise.withResolvers(); - const disposeCalled = Promise.withResolvers(); + const taskStarted = withResolvers(); + const disposeCalled = withResolvers(); runMain({})(() => { taskStarted.resolve(); @@ -100,8 +118,8 @@ describe("runMain", () => { test("disposes returned AsyncDisposable after signal", async () => { let disposed = false; - const taskStarted = Promise.withResolvers(); - const disposeCalled = Promise.withResolvers(); + const taskStarted = withResolvers(); + const disposeCalled = withResolvers(); runMain({})(() => { taskStarted.resolve(); @@ -123,7 +141,7 @@ describe("runMain", () => { test("handles void return without disposal", async () => { let called = false; - const taskStarted = Promise.withResolvers(); + const taskStarted = withResolvers(); runMain({})(() => { called = true; @@ -139,8 +157,8 @@ describe("runMain", () => { test("responds to SIGTERM", async () => { let disposed = false; - const taskStarted = Promise.withResolvers(); - const disposeCalled = Promise.withResolvers(); + const taskStarted = withResolvers(); + const disposeCalled = withResolvers(); runMain({})(() => { taskStarted.resolve(); @@ -161,8 +179,8 @@ describe("runMain", () => { test("responds to SIGHUP", async () => { let disposed = false; - const taskStarted = Promise.withResolvers(); - const disposeCalled = Promise.withResolvers(); + const taskStarted = withResolvers(); + const disposeCalled = withResolvers(); runMain({})(() => { taskStarted.resolve(); @@ -182,7 +200,7 @@ describe("runMain", () => { }); test("cleans up signal listeners after signal", async () => { - const taskCompleted = Promise.withResolvers(); + const taskCompleted = withResolvers(); const initialSigintCount = process.listenerCount("SIGINT"); runMain({})(() => @@ -207,8 +225,8 @@ describe("runMain", () => { }); test("sets exitCode to 1 on uncaughtException", async () => { - const disposed = Promise.withResolvers(); - const taskStarted = Promise.withResolvers(); + const disposed = withResolvers(); + const taskStarted = withResolvers(); const console = testCreateConsole(); runMain({ console })(() => { @@ -238,8 +256,8 @@ describe("runMain", () => { }); test("sets exitCode to 1 on unhandledRejection", async () => { - const disposed = Promise.withResolvers(); - const taskStarted = Promise.withResolvers(); + const disposed = withResolvers(); + const taskStarted = withResolvers(); const console = testCreateConsole(); runMain({ console })(() => { diff --git a/packages/nodejs/test/setup.ts b/packages/nodejs/test/setup.ts new file mode 100644 index 000000000..cbb2c49e1 --- /dev/null +++ b/packages/nodejs/test/setup.ts @@ -0,0 +1,24 @@ +// Polyfills for Node.js test environment +// The @evolu/common package uses ES2024+ features + +// Polyfill Promise.try for Node.js/Bun versions that don't support it (ES2024) +// The @evolu/common package uses Promise.try in Task.ts +if (!Promise.try) { + // @ts-ignore - Adding ES2024 Promise.try polyfill + Promise.try = function ( + callback: (...args: Args) => T | PromiseLike, + ...args: Args + ): Promise { + return Promise.resolve().then(() => callback(...args)); + }; +} + +// Polyfill WebSocket for Node.js tests +// IMPORTANT: Import WebSocket AFTER other polyfills to avoid circular dependency issues +import { WebSocket } from "ws"; + +if (!globalThis.WebSocket) { + // @ts-expect-error - ws WebSocket is compatible enough for our needs + globalThis.WebSocket = WebSocket; +} + diff --git a/packages/nodejs/vitest.config.ts b/packages/nodejs/vitest.config.ts index 4705738a3..8c5405af1 100644 --- a/packages/nodejs/vitest.config.ts +++ b/packages/nodejs/vitest.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + setupFiles: ["./test/setup.ts"], exclude: ["**/node_modules/**", "**/dist/**"], include: ["test/**/*.test.ts"], coverage: {