diff --git a/.oxlintrc.json b/.oxlintrc.json index ecdef44b..0f1719c7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,11 +30,14 @@ } ], "no-await-in-loop": "off", - "no-underscore-dangle": "off", // Disabled because it's a common pattern in the codebase + // Leading `_` marks internal members (paired with `/** @internal */` + + // stripInternal) that tests intentionally reach into; `__name__` members + // are the Redis connection protocol (`__runScript__`, `__addLimiter__`, ...). + "no-underscore-dangle": "off", "no-array-constructor": "error", "prefer-arrow-callback": "error", - "typescript/no-explicit-any": "off", // Disabled because it's a common pattern in the codebase - "typescript/no-require-imports": "off", // Disabled because it's a common pattern in the codebase + "typescript/no-explicit-any": "error", + "typescript/no-require-imports": "error", "typescript/no-unsafe-function-type": "error", "typescript/consistent-type-imports": ["error", { "fixStyle": "inline-type-imports" }], "typescript/ban-ts-comment": "error", diff --git a/src/Batcher.ts b/src/Batcher.ts index 2550615e..80d45106 100644 --- a/src/Batcher.ts +++ b/src/Batcher.ts @@ -9,7 +9,7 @@ type BatcherDefaults = { maxSize: number | null; }; -class Batcher { +class Batcher { /** @internal */ defaults: BatcherDefaults = { maxTime: null, maxSize: null }; /** @internal */ @@ -34,11 +34,11 @@ class Batcher { // Installed on the instance by Events (see Events constructor). declare on: { >(event: E, listener: BatcherEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare once: { >(event: E, listener: BatcherEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare removeAllListeners: (name?: string | null) => void; diff --git a/src/Bottleneck.ts b/src/Bottleneck.ts index 474361fb..dbc7143f 100644 --- a/src/Bottleneck.ts +++ b/src/Bottleneck.ts @@ -164,11 +164,11 @@ class Bottleneck { // is the public event contract. declare on: { (event: E, listener: BottleneckEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare once: { (event: E, listener: BottleneckEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare removeAllListeners: (name?: string | null) => void; diff --git a/src/Group.ts b/src/Group.ts index 51201cc7..ebdc897f 100644 --- a/src/Group.ts +++ b/src/Group.ts @@ -26,11 +26,11 @@ class Group { // Installed on the instance by Events (see Events constructor). declare on: { (event: E, listener: GroupEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare once: { (event: E, listener: GroupEvents[E]): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare removeAllListeners: (name?: string | null) => void; diff --git a/src/Queues.ts b/src/Queues.ts index a8005fb3..a8de56e9 100644 --- a/src/Queues.ts +++ b/src/Queues.ts @@ -13,8 +13,8 @@ class Queues { _lists: DLList[]; // Installed on the instance by Events (see Events constructor). - declare on: (name: string, cb: (...args: any[]) => void) => unknown; - declare once: (name: string, cb: (...args: any[]) => void) => unknown; + declare on: (name: string, cb: (...args: never[]) => void) => unknown; + declare once: (name: string, cb: (...args: never[]) => void) => unknown; declare removeAllListeners: (name?: string | null) => void; constructor(num_priorities: number) { diff --git a/src/async-context.ts b/src/async-context.ts index aa85e56a..dcd29924 100644 --- a/src/async-context.ts +++ b/src/async-context.ts @@ -8,9 +8,9 @@ import { AsyncResource } from "node:async_hooks"; * Falls back to the unbound task outside Node (the light/browser build stubs * this module out entirely). */ -export function bindTask unknown>(task: T): T { +export function bindTask unknown>(task: T): T { try { - return AsyncResource.bind(task); + return AsyncResource.bind(task as T & ((...args: unknown[]) => unknown)); } catch { return task; } diff --git a/src/cluster/IORedisConnection.ts b/src/cluster/IORedisConnection.ts index 5fd16de8..8b412076 100644 --- a/src/cluster/IORedisConnection.ts +++ b/src/cluster/IORedisConnection.ts @@ -31,11 +31,11 @@ class IORedisConnection { // Installed on the instance by Events (see Events constructor). declare on: { (event: "error", listener: (error: unknown) => void): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare once: { (event: "error", listener: (error: unknown) => void): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare removeAllListeners: (name?: string | null) => void; diff --git a/src/cluster/RedisConnection.ts b/src/cluster/RedisConnection.ts index 301605a3..64b22bbb 100644 --- a/src/cluster/RedisConnection.ts +++ b/src/cluster/RedisConnection.ts @@ -52,11 +52,11 @@ class RedisConnection { // Installed on the instance by Events (see Events constructor). declare on: { (event: "error", listener: (error: unknown) => void): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare once: { (event: "error", listener: (error: unknown) => void): unknown; - (event: string, listener: (...args: any[]) => unknown): unknown; + (event: string, listener: (...args: never[]) => unknown): unknown; }; declare removeAllListeners: (name?: string | null) => void; diff --git a/src/cluster/redis-types.ts b/src/cluster/redis-types.ts index d513ac5a..8c122bd6 100644 --- a/src/cluster/redis-types.ts +++ b/src/cluster/redis-types.ts @@ -9,7 +9,7 @@ import type { ScriptName } from "./Scripts"; type BaseClient = { - on(event: string, cb: (...args: any[]) => void): unknown; + on(event: string, cb: (...args: never[]) => void): unknown; removeAllListeners?(event?: string): unknown; publish(channel: string, message: string): Promise; unsubscribe(channel: string): unknown; @@ -40,7 +40,7 @@ type IORedisScriptCommands = Record< export type IORedisClient = BaseClient & { status: string; - once(event: string, cb: (...args: any[]) => void): unknown; + once(event: string, cb: (...args: never[]) => void): unknown; setMaxListeners(n: number): unknown; /** Absent on ioredis Cluster clients. */ duplicate?(): IORedisClient; diff --git a/src/types.ts b/src/types.ts index 45794e3d..457cefcf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -161,12 +161,12 @@ export type IORedisConnectionOptions = }; /** Datastore-specific map of raw redis clients. */ -export type ClientsList = { client?: any; subscriber?: any }; +export type ClientsList = { client?: unknown; subscriber?: unknown }; export type GroupLimiterPair = { key: string; limiter: Bottleneck }; export type EventInfo = { - readonly args: any[]; + readonly args: readonly unknown[]; readonly options: { readonly id: string; readonly priority: number; @@ -175,8 +175,8 @@ export type EventInfo = { }; }; export type EventInfoDropped = EventInfo & { - readonly task: (...args: any[]) => any; - readonly promise: Promise; + readonly task: (...args: never[]) => unknown; + readonly promise: Promise; }; export type EventInfoQueued = EventInfo & { readonly reachedHWM: boolean; @@ -199,7 +199,7 @@ export type Counts = { /** Event map for the limiter. The `"failed"` listener returns a retry delay in ms (or nothing). */ export type BottleneckEvents = { - debug: (message: string, info: any) => void; + debug: (message: string, info: unknown) => void; message: (message: string) => void; error: (error: unknown) => void; empty: () => void; @@ -220,13 +220,13 @@ export type BottleneckEvents = { }; export type GroupEvents = { - debug: (message: string, info: any) => void; + debug: (message: string, info: unknown) => void; error: (error: unknown) => void; created: (limiter: Bottleneck, key: string) => void; }; export type BatcherEvents = { - debug: (message: string, info: any) => void; + debug: (message: string, info: unknown) => void; error: (error: unknown) => void; batch: (batch: T[]) => void; }; diff --git a/test/batcher.test.ts b/test/batcher.test.ts index 6213294e..733b9390 100644 --- a/test/batcher.test.ts +++ b/test/batcher.test.ts @@ -11,7 +11,7 @@ useFakeClock(); describe("Batcher", () => { test("Should batch by time and size", async () => { - const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); + const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); const batches: number[][] = []; const batchTimes: number[] = []; @@ -31,7 +31,7 @@ describe("Batcher", () => { }); test("Should batch by time", async () => { - const batcher = new Bottleneck.Batcher({ maxTime: 100 }); + const batcher = new Bottleneck.Batcher({ maxTime: 100 }); const batches: number[][] = []; const batchTimes: number[] = []; @@ -57,7 +57,7 @@ describe("Batcher", () => { }); test("Should batch by size", async () => { - const batcher = new Bottleneck.Batcher({ maxSize: 2 }); + const batcher = new Bottleneck.Batcher({ maxSize: 2 }); const batches: number[][] = []; batcher.on("batch", (groups) => { @@ -75,7 +75,7 @@ describe("Batcher", () => { }); test("Should stagger flushes", async () => { - const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); + const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); const batches: number[][] = []; const batchTimes: number[] = []; @@ -95,7 +95,7 @@ describe("Batcher", () => { }); test("Should force then stagger flushes", async () => { - const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); + const batcher = new Bottleneck.Batcher({ maxTime: 100, maxSize: 3 }); const batches: number[][] = []; const batchTimes: number[] = []; @@ -123,7 +123,7 @@ describe("Batcher", () => { }); test("Should default to no time/size limits when constructed without options", () => { - const batcher = new Bottleneck.Batcher(); + const batcher = new Bottleneck.Batcher(); expect(batcher.maxTime).toBeNull(); expect(batcher.maxSize).toBeNull(); }); diff --git a/test/bottleneck.ts b/test/bottleneck.ts index 6ea7d4a4..395eb40b 100644 --- a/test/bottleneck.ts +++ b/test/bottleneck.ts @@ -21,7 +21,9 @@ const Bottleneck: BottleneckClass = // child limiters created from a "connection-only" Group inherit the 5000ms // production default and produce 5-second flakes (see cluster.test.ts:75 // and similar). -const isRedisBacked = (options: Record | undefined): options is Record => +const isRedisBacked = ( + options: Record | undefined, +): options is Record => options != null && typeof options === "object" && (options.datastore === "redis" || options.datastore === "ioredis" || options.connection != null); @@ -59,9 +61,9 @@ async function makeTestBottleneck(Base: BottleneckClass): Promise | undefined) => { + const withRedis = (options: Record | undefined) => { if (!isRedisBacked(options)) return options; - const next = { ...options } as Record; + const next = { ...options }; // Only inject the Redis library / clientOptions when the test isn't // bringing its own pre-built client or connection. Both of those carry // their own clientOptions and the Bottleneck constructor would reject @@ -69,7 +71,7 @@ async function makeTestBottleneck(Base: BottleneckClass): Promise) { + constructor(options?: Record) { super(withRedis(options)); } } @@ -117,7 +119,7 @@ async function makeTestBottleneck(Base: BottleneckClass): Promise) { + constructor(options?: Record) { super(withRedis(options)); // Group.key() instantiates child limiters via `this.Bottleneck`, which // the parent Group constructor sets to the library's Bottleneck class. diff --git a/test/cluster-coordination.test.ts b/test/cluster-coordination.test.ts index 078b939b..b8cc50c6 100644 --- a/test/cluster-coordination.test.ts +++ b/test/cluster-coordination.test.ts @@ -1,21 +1,18 @@ import { describe, expect } from "vitest"; import type BottleneckBase from "../src/Bottleneck"; -import * as Scripts from "../src/cluster/Scripts"; +import type { ScriptName } from "../src/cluster/Scripts"; import sleep from "../src/sleep"; import Bottleneck from "./bottleneck"; import { defined } from "./helpers/defined"; +import { limiterKeys, redisStore, runCommand } from "./helpers/store"; import { test, waitForState, deferred, enqueued } from "./helpers/test-api"; // Causality policy (Workstream B): observe product-timer effects via waitForState // and state counts — never assert wall-clock bounds around real network time. type Limiter = BottleneckBase; -const limiterKeys = (limiter: Limiter): any[] => - Scripts.allKeys((limiter._store as any).originalId); const countKeys = (limiter: Limiter) => runCommand(limiter, "exists", limiterKeys(limiter)); const deleteKeys = (limiter: Limiter) => runCommand(limiter, "del", limiterKeys(limiter)); -const runCommand = (limiter: Limiter, command: string, args: string[]) => - (limiter._store as any).connection.__runCommand__([command, ...args]); const runningOrExecuting = (limiter: Limiter) => { const counts = limiter.counts(); return counts.RUNNING + counts.EXECUTING; @@ -26,11 +23,11 @@ const SETTINGS_KEY_NOT_FOUND = /^(.*\s)?SETTINGS_KEY_NOT_FOUND$/; const UNKNOWN_CLIENT = /^(.*\s)?UNKNOWN_CLIENT$/; async function captureFirstScriptError(limiter: Limiter, trigger: () => unknown) { - const connection = (limiter._store as any).connection; + const connection = redisStore(limiter).connection; const original = connection.__runScript__.bind(connection); - let captured: any; + let captured: unknown; - connection.__runScript__ = async (name: string, id: string, args: unknown[]) => { + connection.__runScript__ = async (name: ScriptName, id: string, args: unknown[]) => { try { return await original(name, id, args); } catch (e) { @@ -209,7 +206,7 @@ describe("Cluster coordination", () => { const err = await captureFirstScriptError(limiter, () => limiter.running()); expect(err).toBeTruthy(); - expect(err.message).toMatch(SETTINGS_KEY_NOT_FOUND); + expect(err).toHaveProperty("message", expect.stringMatching(SETTINGS_KEY_NOT_FOUND)); }); test("Should re-register when client registration is missing in Redis", async ({ @@ -236,7 +233,7 @@ describe("Cluster coordination", () => { const err = await captureFirstScriptError(limiter, () => limiter.running()); expect(err).toBeTruthy(); - expect(err.message).toMatch(UNKNOWN_CLIENT); + expect(err).toHaveProperty("message", expect.stringMatching(UNKNOWN_CLIENT)); }); test("Should drop all jobs in the Cluster when entering blocked mode", async ({ @@ -368,7 +365,7 @@ describe("Cluster coordination", () => { minTime: 100, id: "nope", }); - const received: any[] = []; + const received: unknown[] = []; rootLimiter.on("message", (msg) => { received.push(1, msg); @@ -400,7 +397,7 @@ describe("Cluster coordination", () => { minTime: 100, datastore: process.env.DATASTORE, }); - const received: any[] = []; + const received: unknown[] = []; await new Promise((resolve, _reject) => { const limiter = group.key("A"); @@ -526,7 +523,7 @@ describe("Cluster coordination", () => { // need to be flushed before the assertions below. await waitForState(() => { expect(group.keys().length).toBe(0); - expect(Object.keys((group.connection as any).limiters).length).toBe(0); + expect(Object.keys(defined(group.connection).limiters).length).toBe(0); }); const countsAfterCleanup = await Promise.all([ @@ -536,7 +533,7 @@ describe("Cluster coordination", () => { ]); expect(countsAfterCleanup).toEqual([0, 0, 0]); expect(group.keys().length).toEqual(0); - expect(Object.keys((group.connection as any).limiters).length).toEqual(0); + expect(Object.keys(defined(group.connection).limiters).length).toEqual(0); }); test("Should not recreate a key when running heartbeat", async ({ harness: h, makeGroup }) => { @@ -774,7 +771,7 @@ describe("Cluster coordination", () => { // - Per-limiter FIFO: 4 before 6 (limiter3), 5 before 7 (limiter4) — // each client drains its own queue in order no matter which slots // it wins. - const calls = h.results().calls.map((call) => (call.result as any)[0]); + const calls = h.results().calls.map((call) => call.result[0]); expect(calls.length).toEqual(11); expect(calls.slice(0, 5)).toEqual(["A", "B", "C", "D", 1]); expect(calls.slice(5, 9).sort()).toEqual([4, 5, 6, 7]); @@ -845,7 +842,7 @@ describe("Cluster coordination", () => { // capacity-priority tiebreak is covered at the broadcast level below. // What is guaranteed: warm-ups + [1] first, all three 50ms jobs ran // before the 550ms job, and limiter4's own FIFO ([4] before [5]). - const calls = h.results().calls.map((call) => (call.result as any)[0]); + const calls = h.results().calls.map((call) => call.result[0]); expect(calls.length).toEqual(9); expect(calls.slice(0, 5)).toEqual(["A", "B", "C", "D", 1]); expect(calls.slice(5, 8).sort()).toEqual([3, 4, 5]); diff --git a/test/cluster.test.ts b/test/cluster.test.ts index 8d64e9c3..70e03b46 100644 --- a/test/cluster.test.ts +++ b/test/cluster.test.ts @@ -1,10 +1,13 @@ +import assert from "node:assert"; import { describe, expect } from "vitest"; import type BottleneckBase from "../src/Bottleneck"; +import type { ScriptName } from "../src/cluster/Scripts"; import type { JobOptions } from "../src/types"; -import * as Scripts from "../src/cluster/Scripts"; import sleep from "../src/sleep"; +import { defined } from "./helpers/defined"; +import { limiterKeys, redisStore, runCommand } from "./helpers/store"; import { test, waitForState, deferred, enqueued } from "./helpers/test-api"; -const assert = require("assert"); +import { wrongType } from "./helpers/wrong-type"; // Causality policy (Workstream B): observe product-timer effects via waitForState // and state counts — never assert wall-clock bounds around real network time. @@ -12,17 +15,15 @@ const assert = require("assert"); // release. "Must NOT happen" uses bounded sleeps only after positive preconditions. type Limiter = BottleneckBase; -const limiterKeys = (limiter: Limiter): any[] => - Scripts.allKeys((limiter._store as any).originalId); -const runCommand = (limiter: Limiter, command: string, args: string[]) => - (limiter._store as any).connection.__runCommand__([command, ...args]); -const sumWeights = (weights: Record) => - Object.keys(weights).reduce((acc: number, x: string) => acc + ~~weights[x], 0); +const sumWeights = (weights: Record | string[]) => + Object.values(weights).reduce((acc, x) => acc + (Number(x) | 0), 0); + +const messageOf = (e: unknown) => (e instanceof Error ? e.message : e); const pauseHeartbeats = (limiter: Limiter) => { - const connection = (limiter._store as any).connection; + const connection = redisStore(limiter).connection; const original = connection.__runScript__; - connection.__runScript__ = (name: string, id: string, args: unknown[]) => + connection.__runScript__ = (name: ScriptName, id: string, args: unknown[]) => name === "heartbeat" ? Promise.resolve(0) : original.call(connection, name, id, args); return () => { connection.__runScript__ = original; @@ -30,10 +31,10 @@ const pauseHeartbeats = (limiter: Limiter) => { }; const countHeartbeats = (limiter: Limiter) => { - const connection = (limiter._store as any).connection; + const connection = redisStore(limiter).connection; const original = connection.__runScript__.bind(connection); const heartbeats = { count: 0 }; - connection.__runScript__ = async (name: string, id: string, args: unknown[]) => { + connection.__runScript__ = async (name: ScriptName, id: string, args: unknown[]) => { const result = await original(name, id, args); if (name === "heartbeat") heartbeats.count++; return result; @@ -75,14 +76,13 @@ describe("Cluster-only", () => { makeLimiter, }) => { const rootLimiter = makeLimiter(); - (rootLimiter.connection as any).id = "some-id"; const limiter = makeLimiter({ minTime: 50, connection: rootLimiter.connection, }); await Promise.all([rootLimiter.ready(), limiter.ready()]); - expect((limiter.connection as any).id).toEqual("some-id"); + expect(limiter.connection).toBe(rootLimiter.connection); expect(limiter.datastore).toEqual(process.env.DATASTORE); await Promise.all([ @@ -99,7 +99,6 @@ describe("Cluster-only", () => { makeGroup, }) => { const rootLimiter = makeLimiter(); - (rootLimiter.connection as any).id = "some-id"; const group = makeGroup({ minTime: 50, connection: rootLimiter.connection, @@ -108,8 +107,8 @@ describe("Cluster-only", () => { const limiter2 = group.key("B"); await Promise.all([rootLimiter.ready(), limiter1.ready(), limiter2.ready()]); - expect((limiter1.connection as any).id).toEqual("some-id"); - expect((limiter2.connection as any).id).toEqual("some-id"); + expect(limiter1.connection).toBe(rootLimiter.connection); + expect(limiter2.connection).toBe(rootLimiter.connection); expect(limiter1.datastore).toEqual(process.env.DATASTORE); expect(limiter2.datastore).toEqual(process.env.DATASTORE); @@ -133,7 +132,6 @@ describe("Cluster-only", () => { datastore: process.env.DATASTORE, clearDatastore: true, }); - (group.connection as any).id = "some-id"; const limiter1 = group.key("A"); const limiter2 = makeLimiter({ @@ -142,8 +140,8 @@ describe("Cluster-only", () => { }); await Promise.all([limiter1.ready(), limiter2.ready()]); - expect((limiter1.connection as any).id).toEqual("some-id"); - expect((limiter2.connection as any).id).toEqual("some-id"); + expect(limiter1.connection).toBe(group.connection); + expect(limiter2.connection).toBe(group.connection); expect(limiter1.datastore).toEqual(process.env.DATASTORE); expect(limiter2.datastore).toEqual(process.env.DATASTORE); @@ -166,7 +164,6 @@ describe("Cluster-only", () => { datastore: process.env.DATASTORE, clearDatastore: true, }); - (group1.connection as any).id = "some-id"; const group2 = makeGroup({ minTime: 50, @@ -180,12 +177,11 @@ describe("Cluster-only", () => { const limiter4 = group1.key("DDD"); await Promise.all([limiter1.ready(), limiter2.ready(), limiter3.ready(), limiter4.ready()]); - expect((group1.connection as any).id).toEqual("some-id"); - expect((group2.connection as any).id).toEqual("some-id"); - expect((limiter1.connection as any).id).toEqual("some-id"); - expect((limiter2.connection as any).id).toEqual("some-id"); - expect((limiter3.connection as any).id).toEqual("some-id"); - expect((limiter4.connection as any).id).toEqual("some-id"); + expect(group2.connection).toBe(group1.connection); + expect(limiter1.connection).toBe(group1.connection); + expect(limiter2.connection).toBe(group1.connection); + expect(limiter3.connection).toBe(group1.connection); + expect(limiter4.connection).toBe(group1.connection); expect(limiter1.datastore).toEqual(process.env.DATASTORE); expect(limiter2.datastore).toEqual(process.env.DATASTORE); expect(limiter3.datastore).toEqual(process.env.DATASTORE); @@ -234,15 +230,13 @@ describe("Cluster-only", () => { const clientKeys = keys.filter((k) => k.includes("_client_")); // First verify that client_* keys actually exist (were created by register_client) - for (let i = 0; i < clientKeys.length; i++) { - const key = clientKeys[i]; + for (const key of clientKeys) { const exists = await runCommand(rootLimiter, "exists", [key]); assert(exists === 1, `Expected ${key} to exist after register_client, but it doesn't`); } // Now verify that all keys have TTL set - for (let i = 0; i < keys.length; i++) { - const key = keys[i]; + for (const key of keys) { const ttl = await runCommand(rootLimiter, "ttl", [key]); if (ttl == -2) continue; // key doesn't exist @@ -356,8 +350,8 @@ describe("Cluster-only", () => { "lastReservoirIncrease", ]); const timestamps = values.slice(-2); - timestamps.forEach((t: string) => { - const num = parseInt(t); + timestamps.forEach((t) => { + const num = parseInt(defined(t)); expect(num).toBeGreaterThanOrEqual(testStart); // timestamp written during this test expect(num).toBeLessThanOrEqual(Date.now()); // not somehow in the future }); @@ -411,8 +405,8 @@ describe("Cluster-only", () => { const queuedA = await runCommand(rootLimiter, "hgetall", [client_num_queued_key]); expect(rootLimiter.counts().QUEUED).toEqual(2); expect(limiter2.counts().QUEUED).toEqual(1); - expect(~~queuedA[clientId1]).toEqual(2); - expect(~~queuedA[clientId2]).toEqual(1); + expect(Number(queuedA[clientId1] ?? 0)).toEqual(2); + expect(Number(queuedA[clientId2] ?? 0)).toEqual(1); expect(await rootLimiter.clusterQueued()).toEqual(3); @@ -421,8 +415,8 @@ describe("Cluster-only", () => { const queuedB = await runCommand(rootLimiter, "hgetall", [client_num_queued_key]); expect(rootLimiter.counts().QUEUED).toEqual(0); expect(limiter2.counts().QUEUED).toEqual(0); - expect(~~queuedB[clientId1]).toEqual(0); - expect(~~queuedB[clientId2]).toEqual(0); + expect(Number(queuedB[clientId1] ?? 0)).toEqual(0); + expect(Number(queuedB[clientId2] ?? 0)).toEqual(0); expect(rootLimiter.counts().DONE).toEqual(3); expect(limiter2.counts().DONE).toEqual(1); @@ -558,8 +552,8 @@ describe("Cluster-only", () => { }; const job1 = deferred(); let numExpirations = 0; - const errorHandler = (err: any) => { - if (err.message.indexOf("This job timed out") === 0) { + const errorHandler = (err: unknown) => { + if (err instanceof Error && err.message.startsWith("This job timed out")) { numExpirations++; } }; @@ -624,11 +618,11 @@ describe("Cluster-only", () => { expect(sumWeights(client_running)).toEqual(1); expect(client_num_queued).toEqual(["0", "0"]); expect(client_last_registered[1]).toEqual("0"); - expect(parseFloat(client_last_seen[1])).toBeGreaterThanOrEqual(testStart); - expect(parseFloat(client_last_seen[1])).toBeLessThanOrEqual(Date.now()); + expect(parseFloat(defined(client_last_seen[1]))).toBeGreaterThanOrEqual(testStart); + expect(parseFloat(defined(client_last_seen[1]))).toBeLessThanOrEqual(Date.now()); // Limiter2's registration timestamp falls within the test window. - expect(parseFloat(client_last_registered[3])).toBeGreaterThanOrEqual(testStart); - expect(parseFloat(client_last_registered[3])).toBeLessThanOrEqual(Date.now()); + expect(parseFloat(defined(client_last_registered[3]))).toBeGreaterThanOrEqual(testStart); + expect(parseFloat(defined(client_last_registered[3]))).toBeLessThanOrEqual(Date.now()); expect(numExpirations).toEqual(4); }); @@ -733,7 +727,7 @@ describe("Cluster-only", () => { const clientId1 = rootLimiter._store.clientId; await waitForState(async () => { const score = await runCommand(limiter2, "zscore", [client_last_seen_key, clientId1]); - expect(Date.now() - parseFloat(score)).toBeGreaterThan(200); + expect(Date.now() - parseFloat(defined(score))).toBeGreaterThan(200); }); // running() fires process_tick, which now sees client 1 as unresponsive — @@ -929,9 +923,9 @@ describe("Cluster-only", () => { }); // Expire the orphaned job the way free.lua releases one: its expiration score drops to 0. - const [job_weights_key, job_expirations_key] = limiterKeys(limiter2).slice(1, 3); + const [, job_weights_key, job_expirations_key] = limiterKeys(limiter2); const [index] = await runCommand(limiter2, "hkeys", [job_weights_key]); - await runCommand(limiter2, "zadd", [job_expirations_key, "0", index]); + await runCommand(limiter2, "zadd", [job_expirations_key, "0", defined(index)]); await waitForState(() => { expect(events.at(-1)).toEqual({ running: 0 }); @@ -1076,11 +1070,11 @@ describe("Cluster-only", () => { }) => { const limiter = makeLimiter({ maxConcurrent: 2 }); await limiter.ready(); - const store = limiter._store as any; - const published: any[] = []; - store.clients.client = { + const store = redisStore(limiter); + const published: [string, string][] = []; + store.clients.client = wrongType({ publish: async (ch: string, msg: string) => published.push([ch, msg]), - }; + }); const capacities: (number | undefined)[] = []; limiter.on("capacity-priority", (capacity) => capacities.push(capacity)); @@ -1093,7 +1087,7 @@ describe("Cluster-only", () => { await store.onMessage(limiter.channel(), `capacity-priority::${store.clientId}:0`); expect(capacities).toEqual([5, undefined]); - expect(published[1][1]).toBe("capacity-priority:::0"); + expect(published[1]?.[1]).toBe("capacity-priority:::0"); }); test("Should handle a capacity-priority broadcast with no priority client", async ({ @@ -1101,7 +1095,7 @@ describe("Cluster-only", () => { }) => { const limiter = makeLimiter({ maxConcurrent: 2 }); await limiter.ready(); - const store = limiter._store as any; + const store = redisStore(limiter); // No timeout should be scheduled, and the drain must run to completion. await store.onMessage(limiter.channel(), "capacity-priority:5::0"); @@ -1111,19 +1105,19 @@ describe("Cluster-only", () => { test("Should surface onMessage errors unless disconnecting", async ({ makeLimiter }) => { const limiter = makeLimiter({ maxConcurrent: 2 }, { expectErrors: true }); await limiter.ready(); - const store = limiter._store as any; - const errors: any[] = []; + const store = redisStore(limiter); + const errors: unknown[] = []; limiter.on("error", (e) => errors.push(e)); store.instance._drainAll = async () => { throw new Error("drain boom"); }; await store.onMessage(limiter.channel(), "capacity:5"); - expect(errors.map((e) => e.message)).toEqual(["drain boom"]); + expect(errors.map(messageOf)).toEqual(["drain boom"]); store._disconnecting = true; await store.onMessage(limiter.channel(), "capacity:5"); - expect(errors.map((e) => e.message)).toEqual(["drain boom"]); + expect(errors.map(messageOf)).toEqual(["drain boom"]); }); test("Should surface heartbeat errors unless disconnecting", async ({ makeLimiter }) => { @@ -1132,8 +1126,8 @@ describe("Cluster-only", () => { { expectErrors: true }, ); await limiter.ready(); - const store = limiter._store as any; - const errors: any[] = []; + const store = redisStore(limiter); + const errors: unknown[] = []; limiter.on("error", (e) => errors.push(e)); const originalRunScript = store.runScript.bind(store); @@ -1146,7 +1140,7 @@ describe("Cluster-only", () => { // ours), not the tick count. await sleep(1000); expect(errors.length).toBeGreaterThanOrEqual(1); - expect(errors.every((e) => e.message === "heartbeat boom")).toBe(true); + expect(errors.every((e) => messageOf(e) === "heartbeat boom")).toBe(true); store._disconnecting = true; store.runScript = originalRunScript; @@ -1157,7 +1151,7 @@ describe("Cluster-only", () => { }) => { const limiter = makeLimiter({ maxConcurrent: 2 }); await limiter.ready(); - const store = limiter._store as any; + const store = redisStore(limiter); await store.onMessage(limiter.channel(), "capacity-priority:5:some-other-client:0"); expect(Object.keys(store.capacityPriorityCounters)).toEqual(["0"]); @@ -1171,7 +1165,7 @@ describe("Cluster-only", () => { test("Should rethrow non-OVERWEIGHT submit errors", async ({ makeLimiter }) => { const limiter = makeLimiter({ maxConcurrent: 2 }); await limiter.ready(); - (limiter._store as any).runScript = async () => { + redisStore(limiter).runScript = async () => { throw new Error("redis exploded"); }; diff --git a/test/connection.test.ts b/test/connection.test.ts index 87c0b957..31818827 100644 --- a/test/connection.test.ts +++ b/test/connection.test.ts @@ -1,96 +1,117 @@ import { describe, test, expect, vi } from "vitest"; +import type BottleneckBase from "../src/Bottleneck"; +import type { IORedisClient, NodeRedisClient } from "../src/cluster/redis-types"; import type { IORedisConnectionOptions, RedisConnectionOptions } from "../src/types"; import IORedisConnection from "../src/cluster/IORedisConnection"; import RedisConnection from "../src/cluster/RedisConnection"; import * as Scripts from "../src/cluster/Scripts"; +import { wrongType } from "./helpers/wrong-type"; // These are datastore-independent unit tests: both connection classes are // driven with fake clients, so the file is deterministic in every test // project (local + redis) without touching the shared Redis container. -const makeNodeClient = ( - overrides: Record = {}, - dupOverrides: Record = {}, -) => { - const handlers: Record = {}; - const client: any = { - isOpen: true, - on(name: string, cb: (...args: any[]) => void) { +type Handler = (...args: unknown[]) => void; +type Overrides = Record; + +const fakes = new WeakSet(); + +type FakeNodeClient = ReturnType; +type FakeIOClient = ReturnType; + +/** Retype a client the connection created from one of the fake factories. */ +function fake(client: NodeRedisClient): FakeNodeClient; +function fake(client: IORedisClient): FakeIOClient; +function fake(client: object): FakeNodeClient | FakeIOClient { + if (!fakes.has(client)) throw new Error("Expected a fake client"); + return wrongType(client); +} + +const makeEmitter = () => { + const handlers: Record = {}; + return { + on(name: string, cb: Handler) { (handlers[name] ??= []).push(cb); }, - emit(name: string, ...args: any[]) { + emit(name: string, ...args: unknown[]) { for (const cb of handlers[name] ?? []) cb(...args); }, removeAllListeners(name?: string) { if (name != null) delete handlers[name]; }, + }; +}; + +const makeNodeClient = (overrides: Overrides = {}, dupOverrides: Overrides = {}) => { + const subscriptions: Record = {}; + const client = { + ...makeEmitter(), + _subscriptions: subscriptions, + isOpen: true, scriptLoad: vi.fn<(payload: string) => Promise>( async (payload) => `sha-${payload.length}`, ), - evalSha: vi.fn<() => Promise>(async () => 1), + evalSha: vi.fn< + (sha: string, options: { keys: string[]; arguments: string[] }) => Promise + >(async () => 1), sendCommand: vi.fn<() => Promise>(async () => "OK"), - subscribe: vi.fn<(channel: string, cb: (...args: any[]) => void) => Promise>( - async (channel, cb) => { - (handlers.__subscriptions ??= {})[channel] = cb; - }, - ), + subscribe: vi.fn<(channel: string, cb: Handler) => Promise>(async (channel, cb) => { + subscriptions[channel] = cb; + }), unsubscribe: vi.fn<() => Promise>(async () => {}), close: vi.fn<() => Promise>(async () => {}), quit: vi.fn<() => Promise>(async () => {}), destroy: vi.fn<() => Promise>(async () => {}), disconnect: vi.fn<() => void>(), - ...overrides, + duplicate: (): NodeRedisClient => makeNodeClient(dupOverrides), }; - client._handlers = handlers; - client.duplicate = () => makeNodeClient(dupOverrides); - return client; + Object.assign(client, overrides); + fakes.add(client); + // The fake implements only what RedisConnection calls. + return wrongType(client); }; -const makeIOClient = (overrides: Record = {}) => { - const handlers: Record = {}; - const client: any = { +const makeIOClient = (overrides: Overrides = {}) => { + const client = { + ...makeEmitter(), status: "connect", - on(name: string, cb: (...args: any[]) => void) { - (handlers[name] ??= []).push(cb); - }, - once(name: string, cb: (...args: any[]) => void) { - (handlers[name] ??= []).push(cb); - }, - emit(name: string, ...args: any[]) { - for (const cb of handlers[name] ?? []) cb(...args); + once(name: string, cb: Handler) { + client.on(name, cb); }, setMaxListeners() {}, - defineCommand: vi.fn<(...args: any[]) => void>(), - subscribe: vi.fn<(...args: any[]) => Promise>(async () => 1), - unsubscribe: vi.fn<(...args: any[]) => Promise>(async () => 1), - ...overrides, + defineCommand: vi.fn<(...args: unknown[]) => void>(), + subscribe: vi.fn<(...args: unknown[]) => Promise>(async () => 1), + unsubscribe: vi.fn<(...args: unknown[]) => Promise>(async () => 1), }; - client._handlers = handlers; - return client; + Object.assign(client, overrides); + fakes.add(client); + // The fake implements only what IORedisConnection calls. + return wrongType(client); }; -const makeFakeRedis = ( - mainOverrides: Record = {}, - subOverrides: Record = {}, -) => { +const makeFakeRedis = (mainOverrides: Overrides = {}, subOverrides: Overrides = {}) => { // Plain functions, not vi.fn(): `new FakeRedis(...)` must return the fake // client object the factory builds. - const FakeRedis: any = function () { - return makeIOClient({ ...mainOverrides, duplicate: () => makeIOClient(subOverrides) }); - }; - FakeRedis.Cluster = function (nodes: any, options: any) { - FakeRedis.Cluster.calls.push({ nodes, options }); + const calls: { nodes: unknown; options: unknown }[] = []; + function Cluster(nodes: unknown, options: unknown) { + calls.push({ nodes, options }); return makeIOClient({ status: "ready" }); - }; - FakeRedis.Cluster.calls = []; - return FakeRedis; + } + function FakeRedis() { + return makeIOClient({ ...mainOverrides, duplicate: () => makeIOClient(subOverrides) }); + } + return Object.assign(FakeRedis, { Cluster: Object.assign(Cluster, { calls }) }); }; -const makeLimiterInstance = () => ({ - channel: () => "ch-one", - channel_client: () => "ch-two", - _store: { onMessage: vi.fn<(...args: any[]) => void>() }, -}); +/** Just the limiter surface the connections touch when (un)registering. */ +const makeLimiterInstance = () => { + const instance = { + channel: () => "ch-one", + channel_client: () => "ch-two", + _store: { onMessage: vi.fn<(...args: unknown[]) => void>() }, + }; + return { instance, limiter: wrongType(instance) }; +}; describe("RedisConnection (node-redis)", () => { test("Should refuse to build without a Redis reference or a pre-built client", () => { @@ -120,7 +141,7 @@ describe("RedisConnection (node-redis)", () => { await expect(conn.__runScript__("register", "id", [null, 5])).resolves.toBe(7); expect(client.scriptLoad).toHaveBeenCalledTimes(Scripts.names.length + 1); - expect(client.evalSha.mock.calls[1][1].arguments[0]).toBe(""); + expect(client.evalSha.mock.calls[1]?.[1].arguments[0]).toBe(""); }); test("Should reject ready when script loading fails", async () => { @@ -152,15 +173,15 @@ describe("RedisConnection (node-redis)", () => { await conn.ready; const errors: unknown[] = []; - (conn as any).on("error", (e: unknown) => errors.push(e)); + conn.on("error", (e: unknown) => errors.push(e)); client.emit("error", new Error("boom")); - (conn.subscriber as any).emit("error", new Error("boom")); + fake(conn.subscriber).emit("error", new Error("boom")); expect(errors.length).toBe(2); conn.terminated = true; client.emit("error", new Error("ignored")); - (conn.subscriber as any).emit("error", new Error("ignored")); + fake(conn.subscriber).emit("error", new Error("ignored")); expect(errors.length).toBe(2); }); @@ -169,15 +190,15 @@ describe("RedisConnection (node-redis)", () => { const conn = new RedisConnection({ client }); await conn.ready; - const instance = makeLimiterInstance() as any; - await conn.__addLimiter__(instance); + const { instance, limiter } = makeLimiterInstance(); + await conn.__addLimiter__(limiter); expect(conn.subscriber.subscribe).toHaveBeenCalledTimes(2); - const onMessage = (conn.subscriber as any)._handlers.__subscriptions["ch-one"]; - onMessage("hello"); + const onMessage = fake(conn.subscriber)._subscriptions["ch-one"]; + onMessage?.("hello"); expect(instance._store.onMessage).toHaveBeenCalledWith("ch-one", "hello"); - await conn.__removeLimiter__(instance); + await conn.__removeLimiter__(limiter); expect(conn.subscriber.unsubscribe).toHaveBeenCalledTimes(2); expect(conn.limiters["ch-one"]).toBeUndefined(); }); @@ -188,21 +209,21 @@ describe("RedisConnection (node-redis)", () => { await conn1.ready; await conn1.disconnect(true); expect(client1.close).toHaveBeenCalledTimes(1); - expect(client1.subscriber).toBeUndefined(); - expect((conn1.subscriber as any).quit).toHaveBeenCalledTimes(1); - expect((conn1.subscriber as any).close).toBeUndefined(); + expect(client1).not.toHaveProperty("subscriber"); + expect(fake(conn1.subscriber).quit).toHaveBeenCalledTimes(1); + expect(conn1.subscriber.close).toBeUndefined(); // The no-op error handlers installed by disconnect() must absorb late // client error events. - (conn1.client as any).emit("error", new Error("late")); - (conn1.subscriber as any).emit("error", new Error("late")); + fake(conn1.client).emit("error", new Error("late")); + fake(conn1.subscriber).emit("error", new Error("late")); const client2 = makeNodeClient({}, { close: undefined, destroy: undefined }); const conn2 = new RedisConnection({ client: client2 }); await conn2.ready; await conn2.disconnect(false); expect(client2.destroy).toHaveBeenCalledTimes(1); - expect((conn2.subscriber as any).disconnect).toHaveBeenCalledTimes(1); + expect(fake(conn2.subscriber).disconnect).toHaveBeenCalledTimes(1); }); }); @@ -235,19 +256,23 @@ describe("IORedisConnection", () => { await Promise.resolve(); expect(resolved).toBe(false); - (conn.subscriber as any).emit("ready"); + fake(conn.subscriber).emit("ready"); await conn.ready; expect(resolved).toBe(true); }); test("Should build a Cluster subscriber when the client cannot duplicate", async () => { const Redis = makeFakeRedis(); - const client = makeIOClient({ status: "ready" }); - client.startupNodes = ["node-1"]; - client.options = { keyPrefix: "x" }; - delete client.duplicate; + const client = makeIOClient({ + status: "ready", + startupNodes: ["node-1"], + options: { keyPrefix: "x" }, + duplicate: undefined, + }); - const conn = new IORedisConnection({ Redis, client }); + // The options type forbids `Redis` alongside `client`, but the Cluster + // fallback for a non-duplicating client reads it. + const conn = new IORedisConnection(wrongType({ Redis, client })); await conn.ready; expect(Redis.Cluster.calls).toEqual([{ nodes: client.startupNodes, options: client.options }]); @@ -258,14 +283,14 @@ describe("IORedisConnection", () => { const conn = new IORedisConnection({ Redis, clientOptions: {} }); await conn.ready; - const instance = makeLimiterInstance() as any; - await conn.__addLimiter__(instance); + const { instance, limiter } = makeLimiterInstance(); + await conn.__addLimiter__(limiter); expect(conn.subscriber.subscribe).toHaveBeenCalledTimes(2); - (conn.subscriber as any).emit("message", "ch-one", "hello"); + fake(conn.subscriber).emit("message", "ch-one", "hello"); expect(instance._store.onMessage).toHaveBeenCalledWith("ch-one", "hello"); - await conn.__removeLimiter__(instance); + await conn.__removeLimiter__(limiter); expect(conn.subscriber.unsubscribe).toHaveBeenCalledTimes(2); expect(conn.limiters["ch-one"]).toBeUndefined(); }); @@ -276,14 +301,14 @@ describe("IORedisConnection", () => { await conn.ready; const errors = []; - (conn as any).on("error", (e: unknown) => errors.push(e)); + conn.on("error", (e: unknown) => errors.push(e)); - (conn.client as any).emit("error", new Error("boom")); - (conn.subscriber as any).emit("error", new Error("boom")); + fake(conn.client).emit("error", new Error("boom")); + fake(conn.subscriber).emit("error", new Error("boom")); expect(errors.length).toBe(2); conn.terminated = true; - (conn.client as any).emit("error", new Error("ignored")); + fake(conn.client).emit("error", new Error("ignored")); expect(errors.length).toBe(2); }); @@ -291,23 +316,23 @@ describe("IORedisConnection", () => { const Redis1 = makeFakeRedis({ status: "ready" }, { status: "ready" }); const conn1 = new IORedisConnection({ Redis: Redis1, clientOptions: {} }); await conn1.ready; - (conn1.client as any).quit = vi.fn<() => Promise>(async () => "OK"); - (conn1.subscriber as any).quit = vi.fn<() => Promise>(async () => "OK"); + conn1.client.quit = vi.fn<() => Promise>(async () => "OK"); + conn1.subscriber.quit = vi.fn<() => Promise>(async () => "OK"); await conn1.disconnect(true); - expect((conn1.client as any).quit).toHaveBeenCalledTimes(1); - expect((conn1.subscriber as any).quit).toHaveBeenCalledTimes(1); + expect(conn1.client.quit).toHaveBeenCalledTimes(1); + expect(conn1.subscriber.quit).toHaveBeenCalledTimes(1); // Late errors are absorbed by the no-op handlers disconnect() installs. - (conn1.client as any).emit("error", new Error("late")); - (conn1.subscriber as any).emit("error", new Error("late")); + fake(conn1.client).emit("error", new Error("late")); + fake(conn1.subscriber).emit("error", new Error("late")); const Redis2 = makeFakeRedis({ status: "ready" }, { status: "ready" }); const conn2 = new IORedisConnection({ Redis: Redis2, clientOptions: {} }); await conn2.ready; - (conn2.client as any).disconnect = vi.fn<() => void>(); - (conn2.subscriber as any).disconnect = vi.fn<() => void>(); + conn2.client.disconnect = vi.fn<() => void>(); + conn2.subscriber.disconnect = vi.fn<() => void>(); await conn2.disconnect(false); - expect((conn2.client as any).disconnect).toHaveBeenCalledTimes(1); + expect(conn2.client.disconnect).toHaveBeenCalledTimes(1); expect(conn2.subscriber.disconnect).toHaveBeenCalledTimes(1); }); }); diff --git a/test/events.test.ts b/test/events.test.ts index 3088118c..00aec204 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -1,10 +1,18 @@ import { describe, test, expect, vi } from "vitest"; import Events from "../src/Events"; +import { wrongType } from "./helpers/wrong-type"; const noop = () => {}; // Events installs on/once/removeAllListeners onto the target instance. -type EmitterTarget = Record; +type EmitterTarget = { + on(name: string, cb: (...args: never[]) => unknown): unknown; + once(name: string, cb: (...args: never[]) => unknown): unknown; + removeAllListeners(name?: string | null): void; +}; + +/** An empty object, typed as the emitter surface `new Events(target)` installs. */ +const emptyTarget = () => wrongType({}); describe("Events", () => { test("Should refuse an object that already has an emitter", () => { @@ -16,7 +24,7 @@ describe("Events", () => { }); test("removeAllListeners(name) removes one event, removeAllListeners() removes all", () => { - const target: EmitterTarget = {}; + const target = emptyTarget(); const events = new Events(target); const cb = noop; target.on("a", cb); @@ -31,10 +39,10 @@ describe("Events", () => { }); test("A throwing listener triggers the error event and does not break others", async () => { - const target: EmitterTarget = {}; + const target = emptyTarget(); const events = new Events(target); - const onError = vi.fn<(...args: any[]) => void>(); - const good = vi.fn<(...args: any[]) => string>(() => "ok"); + const onError = vi.fn<(...args: unknown[]) => void>(); + const good = vi.fn<(...args: unknown[]) => string>(() => "ok"); target.on("error", onError); target.on("boom", () => { throw new Error("listener exploded"); @@ -48,7 +56,7 @@ describe("Events", () => { }); test("A throwing error-event listener does not recurse", async () => { - const target: EmitterTarget = {}; + const target = emptyTarget(); const events = new Events(target); target.on("error", () => { throw new Error("error handler broke"); diff --git a/test/general-traffic.test.ts b/test/general-traffic.test.ts index 30050000..9945115a 100644 --- a/test/general-traffic.test.ts +++ b/test/general-traffic.test.ts @@ -1,12 +1,14 @@ +import { execFile as execFileCb } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; import { describe, expect } from "vitest"; import type { JobOptions } from "../src/types"; import sleep from "../src/sleep"; import { useFakeClock, useRealClockForThisTest } from "./helpers/clock"; +import { defined } from "./helpers/defined"; import { test, waitForState, deferred } from "./helpers/test-api"; -const path = require("path"); -const util = require("util"); -const execFile = util.promisify(require("child_process").execFile); +const execFile = promisify(execFileCb); useFakeClock(); @@ -388,11 +390,10 @@ describe("General traffic", () => { // sanity check (`nums[2] > nums[0]`) to confirm the second pair // landed strictly after the first, which proves the refresh gate // held without depending on the absolute bucket value. - const matches = stdout.match(/\[(\d+)\]/g); - expect(matches).toBeTruthy(); - expect(matches.length).toEqual(4); - const nums = matches.map((m: string) => Number(m.slice(1, -1))); - expect(nums[2]).toBeGreaterThan(nums[0]); + const matches = stdout.match(/\[(\d+)\]/g) ?? []; + expect(matches).toHaveLength(4); + const nums = matches.map((m) => Number(m.slice(1, -1))); + expect(defined(nums[2])).toBeGreaterThan(defined(nums[0])); expect(stderr).toEqual(""); }); }); @@ -508,11 +509,10 @@ describe("General traffic", () => { // strictly later than jobs 1-2). Tighter bucket assertions flake on // fresh-connection / module-load jitter when the fixture runs // against a Redis datastore inherited from the parent suite. - const matches = stdout.match(/\[(\d+)\]/g); - expect(matches).toBeTruthy(); - expect(matches.length).toEqual(4); - const nums = matches.map((m: string) => Number(m.slice(1, -1))); - expect(nums[2]).toBeGreaterThan(nums[0]); + const matches = stdout.match(/\[(\d+)\]/g) ?? []; + expect(matches).toHaveLength(4); + const nums = matches.map((m) => Number(m.slice(1, -1))); + expect(defined(nums[2])).toBeGreaterThan(defined(nums[0])); expect(stderr).toEqual(""); }); }); diff --git a/test/general.test.ts b/test/general.test.ts index 486f5841..576741b4 100644 --- a/test/general.test.ts +++ b/test/general.test.ts @@ -3,7 +3,9 @@ import type BottleneckBase from "../src/Bottleneck"; import Bottleneck from "./bottleneck"; import { useFakeClock } from "./helpers/clock"; import { defined } from "./helpers/defined"; +import { localStore } from "./helpers/store"; import { test, waitForState, deferred, enqueued } from "./helpers/test-api"; +import { wrongType } from "./helpers/wrong-type"; useFakeClock(); @@ -11,14 +13,14 @@ useFakeClock(); // limiter is shutting down, but surfaces everything else. const disconnectError = () => { const e = new Error("connection is closed"); - e.constructor = { name: "DisconnectsClientError" } as any; + e.constructor = wrongType({ name: "DisconnectsClientError" }); return e; }; describe("General", () => { test("Should prompt to upgrade", () => { expect(() => { - void new (Bottleneck as any)(1, 250); + Reflect.construct(Bottleneck, [1, 250]); }).toThrow(/Bottleneck v2 takes a single object argument/); }); @@ -49,7 +51,7 @@ describe("General", () => { class Hello { emitter: InstanceType; // Installed onto the instance by the Events constructor at runtime. - declare on: (name: string, cb?: any) => unknown; + declare on: (name: string, cb?: unknown) => unknown; constructor() { this.emitter = new Bottleneck.Events(this); @@ -63,7 +65,7 @@ describe("General", () => { const myObject = new Hello(); let sawInfo = false; - myObject.on("info", (...args: any[]) => { + myObject.on("info", (...args: unknown[]) => { expect(args).toEqual(["hello", "world", 123]); sawInfo = true; }); @@ -97,34 +99,40 @@ describe("General", () => { expect(limiter.queued()).toEqual(0); expect(await limiter.clusterQueued()).toEqual(0); - const p1 = limiter.schedule({ id: 1 as any }, h.deferredPromise, hold1.signal, null, 1); + const p1 = limiter.schedule( + { id: wrongType(1) }, + h.deferredPromise, + hold1.signal, + null, + 1, + ); await enqueued(limiter); expect(limiter.queued()).toEqual(0); // It's already running expect(await limiter.check()).toEqual(false); - const p2 = limiter.schedule({ id: 2 as any }, h.slowPromise, 50, null, 2); + const p2 = limiter.schedule({ id: wrongType(2) }, h.slowPromise, 50, null, 2); await enqueued(limiter); expect(limiter.queued()).toEqual(1); expect(await limiter.clusterQueued()).toEqual(1); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(1); - const p3 = limiter.schedule({ id: 3 as any }, h.slowPromise, 50, null, 3); + const p3 = limiter.schedule({ id: wrongType(3) }, h.slowPromise, 50, null, 3); await enqueued(limiter); expect(limiter.queued()).toEqual(2); expect(await limiter.clusterQueued()).toEqual(2); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(2); - const p4 = limiter.schedule({ id: 4 as any }, h.slowPromise, 50, null, 4); + const p4 = limiter.schedule({ id: wrongType(4) }, h.slowPromise, 50, null, 4); await enqueued(limiter); expect(limiter.queued()).toEqual(3); expect(await limiter.clusterQueued()).toEqual(3); expect(limiter.queued(1)).toEqual(0); expect(limiter.queued(5)).toEqual(3); - const p5 = limiter.schedule({ priority: 1, id: 5 as any }, h.promise, null, 5); + const p5 = limiter.schedule({ priority: 1, id: wrongType(5) }, h.promise, null, 5); await enqueued(limiter); expect(limiter.queued()).toEqual(4); expect(await limiter.clusterQueued()).toEqual(4); @@ -163,27 +171,27 @@ describe("General", () => { expect(done0).toEqual(0); const p1 = limiter.schedule( - { weight: 1, id: 1 as any }, + { weight: 1, id: wrongType(1) }, h.deferredPromise, hold1.signal, null, 1, ); const p2 = limiter.schedule( - { weight: 3, id: 2 as any }, + { weight: 3, id: wrongType(2) }, h.deferredPromise, hold2.signal, null, 2, ); const p3 = limiter.schedule( - { weight: 1, id: 3 as any }, + { weight: 1, id: wrongType(3) }, h.deferredPromise, hold3.signal, null, 3, ); - await limiter.schedule({ weight: 0, id: 4 as any }, h.promise, null); + await limiter.schedule({ weight: 0, id: wrongType(4) }, h.promise, null); const [running1, done1] = await Promise.all([limiter.running(), limiter.done()]); expect(running1).toEqual(5); @@ -246,19 +254,32 @@ describe("General", () => { // Job.js), never via a wall-clock poll. let job2StatusAtScheduled = null; limiter.on("scheduled", (info) => { - if ((info.options.id as unknown) === 2) job2StatusAtScheduled = limiter.jobStatus(2 as any); + if ((info.options.id as unknown) === 2) + job2StatusAtScheduled = limiter.jobStatus(wrongType(2)); }); const hold1 = deferred(); const p1 = limiter.schedule( - { weight: 1, id: 1 as any }, + { weight: 1, id: wrongType(1) }, h.deferredPromise, hold1.signal, null, 1, ); - const p2 = limiter.schedule({ weight: 1, id: 2 as any }, h.slowPromise, 200, null, 2); - const p3 = limiter.schedule({ weight: 2, id: 3 as any }, h.slowPromise, 100, null, 3); + const p2 = limiter.schedule( + { weight: 1, id: wrongType(2) }, + h.slowPromise, + 200, + null, + 2, + ); + const p3 = limiter.schedule( + { weight: 2, id: wrongType(3) }, + h.slowPromise, + 100, + null, + 3, + ); expect(limiter.counts()).toEqual({ RECEIVED: 3, QUEUED: 0, RUNNING: 0, EXECUTING: 0 }); // Stable point: job 1 held-EXECUTING, job 3 capacity-blocked (weight @@ -270,8 +291,8 @@ describe("General", () => { expect(counts.QUEUED).toBe(1); expect(counts.EXECUTING).toBeGreaterThanOrEqual(1); }); - expect(limiter.jobStatus(1 as any)).toEqual("EXECUTING"); - expect(limiter.jobStatus(3 as any)).toEqual("QUEUED"); + expect(limiter.jobStatus(wrongType(1))).toEqual("EXECUTING"); + expect(limiter.jobStatus(wrongType(3))).toEqual("QUEUED"); expect(job2StatusAtScheduled).toEqual("RUNNING"); hold1.release(); @@ -300,19 +321,32 @@ describe("General", () => { // statuses" above. let job2StatusAtScheduled = null; limiter.on("scheduled", (info) => { - if ((info.options.id as unknown) === 2) job2StatusAtScheduled = limiter.jobStatus(2 as any); + if ((info.options.id as unknown) === 2) + job2StatusAtScheduled = limiter.jobStatus(wrongType(2)); }); const hold1 = deferred(); const p1 = limiter.schedule( - { weight: 1, id: 1 as any }, + { weight: 1, id: wrongType(1) }, h.deferredPromise, hold1.signal, null, 1, ); - const p2 = limiter.schedule({ weight: 1, id: 2 as any }, h.slowPromise, 200, null, 2); - const p3 = limiter.schedule({ weight: 2, id: 3 as any }, h.slowPromise, 100, null, 3); + const p2 = limiter.schedule( + { weight: 1, id: wrongType(2) }, + h.slowPromise, + 200, + null, + 2, + ); + const p3 = limiter.schedule( + { weight: 2, id: wrongType(3) }, + h.slowPromise, + 100, + null, + 3, + ); expect(limiter.counts()).toEqual({ RECEIVED: 3, QUEUED: 0, @@ -329,8 +363,8 @@ describe("General", () => { expect(counts.EXECUTING).toBeGreaterThanOrEqual(1); expect(counts.DONE).toBe(0); }); - expect(limiter.jobStatus(1 as any)).toEqual("EXECUTING"); - expect(limiter.jobStatus(3 as any)).toEqual("QUEUED"); + expect(limiter.jobStatus(wrongType(1))).toEqual("EXECUTING"); + expect(limiter.jobStatus(wrongType(3))).toEqual("QUEUED"); expect(job2StatusAtScheduled).toEqual("RUNNING"); hold1.release(); @@ -354,9 +388,9 @@ describe("General", () => { EXECUTING: 1, DONE: 1, }); - expect(limiter.jobStatus(1 as any)).toEqual("DONE"); - expect(limiter.jobStatus(2 as any)).toEqual("EXECUTING"); - expect(limiter.jobStatus(3 as any)).toEqual("QUEUED"); + expect(limiter.jobStatus(wrongType(1))).toEqual("DONE"); + expect(limiter.jobStatus(wrongType(2))).toEqual("EXECUTING"); + expect(limiter.jobStatus(wrongType(3))).toEqual("QUEUED"); await h.flushLimiter(limiter); await Promise.all([ @@ -397,7 +431,7 @@ describe("General", () => { // own "scheduled" event: doRun transitions the state BEFORE // triggering the event (Job.js), and doExecute is timer-gated and // cannot have fired inside the handler. - const scheduledRunning: { id: any; running: string[] }[] = []; + const scheduledRunning: { id: unknown; running: string[] }[] = []; limiter.on("scheduled", (info) => { scheduledRunning.push({ id: info.options.id, running: limiter.jobs("RUNNING") }); }); @@ -407,21 +441,21 @@ describe("General", () => { const hold3 = deferred(); const p1 = limiter.schedule( - { weight: 1, id: 1 as any }, + { weight: 1, id: wrongType(1) }, h.deferredPromise, hold1.signal, null, 1, ); const p2 = limiter.schedule( - { weight: 1, id: 2 as any }, + { weight: 1, id: wrongType(2) }, h.deferredPromise, hold2.signal, null, 2, ); const p3 = limiter.schedule( - { weight: 2, id: 3 as any }, + { weight: 2, id: wrongType(3) }, h.deferredPromise, hold3.signal, null, @@ -553,21 +587,21 @@ describe("General", () => { const hold2 = deferred(); const hold3 = deferred(); const p1 = limiter.schedule( - { weight: 1, id: 1 as any }, + { weight: 1, id: wrongType(1) }, h.deferredPromise, hold1.signal, null, 1, ); const p2 = limiter.schedule( - { weight: 1, id: 2 as any }, + { weight: 1, id: wrongType(2) }, h.deferredPromise, hold2.signal, null, 2, ); const p3 = limiter.schedule( - { weight: 2, id: 3 as any }, + { weight: 2, id: wrongType(3) }, h.deferredPromise, hold3.signal, null, @@ -671,25 +705,25 @@ describe("General", () => { calledDepleted++; }); - await expect(limiter.schedule({ id: 1 as any }, h.slowPromise, 50, null, 1)).resolves.toEqual( - [1], - ); + await expect( + limiter.schedule({ id: wrongType(1) }, h.slowPromise, 50, null, 1), + ).resolves.toEqual([1]); expect(calledEmpty).toEqual(1); expect(calledIdle).toEqual(1); await Promise.all([ - expect(limiter.schedule({ id: 2 as any }, h.slowPromise, 50, null, 2)).resolves.toEqual([ - 2, - ]), - expect(limiter.schedule({ id: 3 as any }, h.slowPromise, 50, null, 3)).resolves.toEqual([ - 3, - ]), + expect( + limiter.schedule({ id: wrongType(2) }, h.slowPromise, 50, null, 2), + ).resolves.toEqual([2]), + expect( + limiter.schedule({ id: wrongType(3) }, h.slowPromise, 50, null, 3), + ).resolves.toEqual([3]), ]); // Fire job 4 and wait for its enqueue to trigger the third "empty" — // the counters below must be observed while job 4 is still pending. // An enqueued() barrier cannot be used here: the empty() check requires // the submit lock to be idle, so a pending barrier task would // suppress the very event under test. - const p4 = limiter.schedule({ id: 4 as any }, h.slowPromise, 50, null, 4); + const p4 = limiter.schedule({ id: wrongType(4) }, h.slowPromise, 50, null, 4); await thirdEmpty.signal; expect(h).toHaveFinalCallAt(250); expect(h.log).toHaveCallOrder([[1], [2], [3]]); @@ -806,7 +840,7 @@ describe("General", () => { makeLimiter, }) => { const limiter = makeLimiter({ maxConcurrent: 1 }); - const errors: any[] = []; + const errors: unknown[] = []; limiter.on("error", (e) => errors.push(e)); limiter._store._disconnecting = true; @@ -843,7 +877,7 @@ describe("General", () => { makeLimiter, }) => { const limiter = makeLimiter({ maxConcurrent: 1 }); - const errors: any[] = []; + const errors: unknown[] = []; limiter.on("error", (e) => errors.push(e)); limiter._store._disconnecting = true; @@ -858,7 +892,7 @@ describe("General", () => { test("Should surface drain errors while not disconnecting", async ({ makeLimiter }) => { // Fires an expected "error" event; expectErrors silences the harness watchdog log for it. const limiter = makeLimiter({ maxConcurrent: 1 }, { expectErrors: true }); - const errors: any[] = []; + const errors: unknown[] = []; limiter.on("error", (e) => errors.push(e)); limiter._drainOne = async () => { @@ -873,12 +907,12 @@ describe("General", () => { describe("LocalDatastore", () => { test("computePenalty honors an explicit penalty", () => { const limiter = new Bottleneck({ penalty: 123 }); - expect((limiter._store as any).computePenalty()).toBe(123); + expect(localStore(limiter).computePenalty()).toBe(123); }); test("computePenalty falls back to 5000 ms when minTime is 0", () => { const limiter = new Bottleneck({ minTime: 0 }); - expect((limiter._store as any).computePenalty()).toBe(5000); + expect(localStore(limiter).computePenalty()).toBe(5000); }); test("Restarting the heartbeat clears the previous interval", () => { diff --git a/test/group.test.ts b/test/group.test.ts index 1952ced3..513ab04b 100644 --- a/test/group.test.ts +++ b/test/group.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "vitest"; import sleep from "../src/sleep"; import Bottleneck from "./bottleneck"; import { useFakeClock } from "./helpers/clock"; +import { defined } from "./helpers/defined"; import { test, waitForState } from "./helpers/test-api"; useFakeClock(); @@ -19,9 +20,9 @@ describe("Group", () => { minTime: 100, }); - const results: any[][] = []; + const results: number[][] = []; - const job = async (...result: any[]) => { + const job = async (...result: number[]) => { results.push(result); await sleep(50); }; @@ -46,7 +47,7 @@ describe("Group", () => { for (const [v] of results) { const key = v === 1 || v === 3 || v === 4 ? "A" : v === 5 ? "B" : "C"; byGroup[key] = byGroup[key] || []; - byGroup[key].push(v); + byGroup[key].push(defined(v)); } expect(byGroup.A).toStrictEqual([1, 3, 4]); expect(byGroup.B).toStrictEqual([5]); @@ -133,9 +134,9 @@ describe("Group", () => { }); expect(Object.keys(group.limiters)).toStrictEqual([]); - const results: any[][] = []; + const results: unknown[][] = []; - const job = async (...result: any[]) => { + const job = async (...result: unknown[]) => { results.push(result); await sleep(50); }; diff --git a/test/helpers/job-tasks.ts b/test/helpers/job-tasks.ts index 46ebf064..957a4a11 100644 --- a/test/helpers/job-tasks.ts +++ b/test/helpers/job-tasks.ts @@ -20,7 +20,7 @@ export function deferred(): { signal: Promise; release: () => void } { * Bottleneck task functions that record into `log.record(err, result)`. * Expects a `log` with a `record(err, result)` method. */ -export function createTaskFns(log: { record: (err: unknown, result: unknown) => void }) { +export function createTaskFns(log: { record: (err: unknown, result: unknown[]) => void }) { async function promise(err: unknown, ...result: unknown[]) { log.record(err, result); if (err === null) { diff --git a/test/helpers/limiter.ts b/test/helpers/limiter.ts index 84c090d3..0fe0757c 100644 --- a/test/helpers/limiter.ts +++ b/test/helpers/limiter.ts @@ -6,9 +6,9 @@ import type { BottleneckOptions } from "../../src/types"; import Bottleneck from "../bottleneck"; import buildClientOptions from "../redis-client-options"; -function setRedisClientOptions(options: Record) { +function setRedisClientOptions(options: Record) { if (options.clientOptions == null) { - options.clientOptions = buildClientOptions(options.datastore); + options.clientOptions = buildClientOptions(String(options.datastore)); } } @@ -20,10 +20,10 @@ function setRedisClientOptions(options: Record) { * @returns {import("../../src/Bottleneck").default} */ function makeLimiter( - options: Record = {}, + options: Record = {}, meta: { expectErrors?: boolean } = {}, ): BottleneckBase { - const assigned = Object.assign({}, options) as Record; + const assigned = Object.assign({}, options) as Record; options = assigned; if (options.datastore == null) { diff --git a/test/helpers/store.ts b/test/helpers/store.ts new file mode 100644 index 00000000..3271c113 --- /dev/null +++ b/test/helpers/store.ts @@ -0,0 +1,62 @@ +import type BottleneckBase from "../../src/Bottleneck"; +import type RedisDatastore from "../../src/cluster/RedisDatastore"; +import type LocalDatastore from "../../src/LocalDatastore"; +import * as Scripts from "../../src/cluster/Scripts"; + +/** The limiter's datastore, narrowed to the Redis implementation. */ +export function redisStore(limiter: BottleneckBase): RedisDatastore { + const store = limiter._store; + if (!("originalId" in store)) throw new Error("Expected a Redis-backed limiter"); + return store; +} + +/** The limiter's datastore, narrowed to the local implementation. */ +export function localStore(limiter: BottleneckBase): LocalDatastore { + const store = limiter._store; + if ("originalId" in store) throw new Error("Expected a local limiter"); + return store; +} + +type LimiterKeys = [ + settings: string, + jobWeights: string, + jobExpirations: string, + jobClients: string, + clientRunning: string, + clientNumQueued: string, + clientLastRegistered: string, + clientLastSeen: string, +]; + +/** The limiter's Redis keys, in `Scripts.allKeys` order. */ +export function limiterKeys(limiter: BottleneckBase): LimiterKeys { + const keys = Scripts.allKeys(redisStore(limiter).originalId); + if (keys.length !== 8) throw new Error(`Expected 8 limiter keys, got ${keys.length}`); + return keys as LimiterKeys; +} + +/** Reply shapes (after normalizeReply) for the commands tests read. */ +type Replies = { + exists: number; + hget: string | null; + hgetall: Record; + hkeys: string[]; + hlen: number; + hmget: (string | null)[]; + hvals: string[]; + ttl: number; + zcard: number; + zrange: string[]; + zscore: string | null; +}; + +/** Run a raw Redis command on the limiter's connection. */ +export function runCommand( + limiter: BottleneckBase, + command: C, + args: string[], +): Promise { + return redisStore(limiter).connection.__runCommand__([command, ...args]) as Promise< + C extends keyof Replies ? Replies[C] : unknown + >; +} diff --git a/test/helpers/test-api.ts b/test/helpers/test-api.ts index adc5ed0f..7358caf3 100644 --- a/test/helpers/test-api.ts +++ b/test/helpers/test-api.ts @@ -5,6 +5,7 @@ import Bottleneck from "../bottleneck"; import { isFakeClock } from "./clock"; import { createTaskFns } from "./job-tasks"; import makeLimiterHelper from "./limiter"; +import { wrongType } from "./wrong-type"; export { waitForState } from "./wait-for-state"; export { deferred } from "./job-tasks"; @@ -14,7 +15,7 @@ type Group = InstanceType<(typeof Bottleneck)["Group"]>; type Connection = | InstanceType<(typeof BottleneckBase)["RedisConnection"]> | InstanceType<(typeof BottleneckBase)["IORedisConnection"]>; -type LimiterOptions = Record; +type LimiterOptions = Record; type Disconnectable = { disconnect(flush?: boolean): unknown }; type Track = (resource: T) => T; @@ -51,7 +52,7 @@ function createJobHarness() { const start = Date.now(); const callTimes: number[] = []; - const record = vi.fn<(_err: unknown, _result: unknown) => void>((_err, _result) => { + const record = vi.fn<(_err: unknown, _result: unknown[]) => void>((_err, _result) => { callTimes.push(Date.now() - start); }); @@ -66,7 +67,7 @@ function createJobHarness() { return { elapsed: Date.now() - start, callsDuration: callTimes.length > 0 ? callTimes.at(-1) : null, - calls: record.mock.calls.map((call: unknown[], i: number) => { + calls: record.mock.calls.map((call, i) => { return { err: call[0], result: call[1], time: callTimes[i] }; }), }; @@ -239,7 +240,7 @@ export const test = baseTest.extend<{ process.env.DATASTORE === "ioredis" ? Bottleneck.IORedisConnection : Bottleneck.RedisConnection; - return track(new Connection(opts as any) as unknown as Connection); + return track(new Connection(wrongType(opts)) as unknown as Connection); }); }, async limiter({ makeLimiter, limiterOptions, limiterMeta }, use) { diff --git a/test/helpers/wrong-type.ts b/test/helpers/wrong-type.ts new file mode 100644 index 00000000..0cea633a --- /dev/null +++ b/test/helpers/wrong-type.ts @@ -0,0 +1,8 @@ +/** + * Pass a deliberately wrong-shaped value where `T` is expected, for tests of + * invalid input. `T` is usually inferred from the call site, e.g. + * `limiter.jobStatus(wrongType(3))`. + */ +export function wrongType(value: unknown): T { + return value as T; +} diff --git a/test/ioredis.test.ts b/test/ioredis.test.ts index b0ffd9a4..3110d617 100644 --- a/test/ioredis.test.ts +++ b/test/ioredis.test.ts @@ -1,6 +1,8 @@ +import Redis from "ioredis"; +import ioredisPkg from "ioredis/package.json" with { type: "json" }; import { describe, expect } from "vitest"; +import { redisStore } from "./helpers/store"; import { test } from "./helpers/test-api"; -const Redis = require("ioredis"); import buildClientOptions from "./redis-client-options"; describe("ioredis-only", () => { @@ -33,14 +35,14 @@ describe("ioredis-only", () => { }); expect(limiter.datastore).toStrictEqual("ioredis"); - expect((limiter._store as any).connection.client.nodes().length).toBeGreaterThanOrEqual(0); + expect(redisStore(limiter).connection.client).toBeInstanceOf(Redis.Cluster); }); test("Should connect in Redis Cluster mode with premade client", ({ makeLimiter, makeConnection, }) => { - const client = new Redis.Cluster(""); + const client = new Redis.Cluster([]); makeConnection({ client }); const limiter = makeLimiter({ maxConcurrent: 2, @@ -54,7 +56,7 @@ describe("ioredis-only", () => { }); expect(limiter.datastore).toStrictEqual("ioredis"); - expect((limiter._store as any).connection.client.nodes().length).toBeGreaterThanOrEqual(0); + expect(redisStore(limiter).connection.client).toBeInstanceOf(Redis.Cluster); }); test("Should accept existing connections", async ({ @@ -65,8 +67,7 @@ describe("ioredis-only", () => { const connection = makeConnection({ Redis, clientOptions: buildClientOptions("ioredis"), - }) as any; - connection.id = "super-connection"; + }); const limiter = makeLimiter({ minTime: 50, connection, @@ -78,11 +79,11 @@ describe("ioredis-only", () => { await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1], [2]]); expect(h).toHaveFinalCallAt(50); - expect((limiter.connection as any).id).toStrictEqual("super-connection"); + expect(limiter.connection).toBe(connection); expect(limiter.datastore).toStrictEqual("ioredis"); await limiter.disconnect(); - expect(limiter.clients().client.status).toStrictEqual("ready"); + expect(limiter.clients().client).toHaveProperty("status", "ready"); await Promise.all([expect(p1).resolves.toEqual([1]), expect(p2).resolves.toEqual([2])]); }); @@ -92,10 +93,8 @@ describe("ioredis-only", () => { makeConnection, }) => { const client = new Redis(buildClientOptions("ioredis")); - client.id = "super-client"; - const connection = makeConnection({ client }) as any; - connection.id = "super-connection"; + const connection = makeConnection({ client }); const limiter = makeLimiter({ minTime: 50, connection, @@ -107,12 +106,12 @@ describe("ioredis-only", () => { await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1], [2]]); expect(h).toHaveFinalCallAt(50); - expect(limiter.clients().client.id).toStrictEqual("super-client"); - expect((limiter.connection as any).id).toStrictEqual("super-connection"); + expect(limiter.clients().client).toBe(client); + expect(limiter.connection).toBe(connection); expect(limiter.datastore).toStrictEqual("ioredis"); await limiter.disconnect(); - expect(limiter.clients().client.status).toStrictEqual("ready"); + expect(limiter.clients().client).toHaveProperty("status", "ready"); await Promise.all([expect(p1).resolves.toEqual([1]), expect(p2).resolves.toEqual([2])]); }); @@ -130,7 +129,7 @@ describe("ioredis-only", () => { }); let fired = false; const limiter = makeLimiter({ connection }); - (connection as any).on("error", (_err: unknown) => { + connection.on("error", (_err: unknown) => { if (fired) return; fired = true; expect(limiter.datastore).toStrictEqual("ioredis"); @@ -150,7 +149,7 @@ describe("ioredis-only", () => { // 6+. The CI client-matrix runs this suite against ioredis 5 as well, so the // RESP3-specific tests are skipped there; the RESP2 test still applies since // ioredis 5 ignores the unknown `protocol` option and is RESP2-only anyway. -const ioredisMajor = parseInt(Redis.version ?? require("ioredis/package.json").version, 10); +const ioredisMajor = parseInt(ioredisPkg.version, 10); const describeResp3 = ioredisMajor >= 6 ? describe : describe.skip; // ioredis 6 negotiates RESP3 by default but keeps RESP2-compatible reply @@ -192,8 +191,8 @@ describeResp3("ioredis RESP3", () => { // Guard against silently testing RESP2: if this ever fails on an ioredis // upgrade, the option name or default protocol changed and these tests // are no longer covering the RESP3 path. - expect((connection.client as any).options.protocol).toStrictEqual(3); - expect((connection.client as any).options.replyMapping).toStrictEqual("resp3"); + expect(connection.client).toHaveProperty("options.protocol", 3); + expect(connection.client).toHaveProperty("options.replyMapping", "resp3"); const prefix = process.env.BOTTLENECK_TEST_PREFIX; const hashKey = `b_${prefix}resp3-hash`; const zsetKey = `b_${prefix}resp3-zset`; @@ -223,7 +222,7 @@ describeResp3("ioredis RESP3", () => { await connection.ready; // ioredis 5 has no protocol option (RESP2-only); only assert under v6+. if (ioredisMajor >= 6) { - expect((connection.client as any).options.protocol).toStrictEqual(2); + expect(connection.client).toHaveProperty("options.protocol", 2); } const prefix = process.env.BOTTLENECK_TEST_PREFIX; const hashKey = `b_${prefix}resp2-hash`; diff --git a/test/leakage.ts b/test/leakage.ts index c1eddbe6..dc098466 100644 --- a/test/leakage.ts +++ b/test/leakage.ts @@ -1,4 +1,4 @@ -const v8 = require("node:v8"); +import v8 from "node:v8"; function forceGc() { if (typeof global.gc !== "function") { diff --git a/test/memory/limiter-memory.test.ts b/test/memory/limiter-memory.test.ts index b22e8f49..0ac36cab 100644 --- a/test/memory/limiter-memory.test.ts +++ b/test/memory/limiter-memory.test.ts @@ -1,5 +1,5 @@ +import assert from "node:assert"; import { describe, it } from "vitest"; -const assert = require("assert"); import Bottleneck from "../bottleneck"; import { iterateAsync } from "../leakage"; diff --git a/test/node_redis.test.ts b/test/node_redis.test.ts index ecae376a..cf7dc77b 100644 --- a/test/node_redis.test.ts +++ b/test/node_redis.test.ts @@ -1,6 +1,6 @@ +import * as Redis from "redis"; import { describe, expect } from "vitest"; import { test } from "./helpers/test-api"; -const Redis = require("redis"); import buildClientOptions from "./redis-client-options"; describe("node_redis-only", () => { @@ -21,8 +21,7 @@ describe("node_redis-only", () => { const connection = makeConnection({ Redis, clientOptions: buildClientOptions("redis"), - }) as any; - connection.id = "super-connection"; + }); const limiter = makeLimiter({ minTime: 50, connection, @@ -34,11 +33,11 @@ describe("node_redis-only", () => { await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1], [2]]); expect(h).toHaveFinalCallAt(50); - expect((limiter.connection as any).id).toStrictEqual("super-connection"); + expect(limiter.connection).toBe(connection); expect(limiter.datastore).toStrictEqual("redis"); await limiter.disconnect(); - expect(limiter.clients().client.isReady).toStrictEqual(true); + expect(limiter.clients().client).toHaveProperty("isReady", true); await Promise.all([expect(p1).resolves.toEqual([1]), expect(p2).resolves.toEqual([2])]); }); @@ -48,11 +47,9 @@ describe("node_redis-only", () => { makeConnection, }) => { const client = Redis.createClient(buildClientOptions("redis")); - client.id = "super-client"; await client.connect(); - const connection = makeConnection({ client }) as any; - connection.id = "super-connection"; + const connection = makeConnection({ client }); const limiter = makeLimiter({ minTime: 50, connection, @@ -64,12 +61,12 @@ describe("node_redis-only", () => { await h.flushLimiter(limiter); expect(h.log).toHaveCallOrder([[1], [2]]); expect(h).toHaveFinalCallAt(50); - expect(limiter.clients().client.id).toStrictEqual("super-client"); - expect((limiter.connection as any).id).toStrictEqual("super-connection"); + expect(limiter.clients().client).toBe(client); + expect(limiter.connection).toBe(connection); expect(limiter.datastore).toStrictEqual("redis"); await limiter.disconnect(); - expect(limiter.clients().client.isReady).toStrictEqual(true); + expect(limiter.clients().client).toHaveProperty("isReady", true); await Promise.all([expect(p1).resolves.toEqual([1]), expect(p2).resolves.toEqual([2])]); }); @@ -91,7 +88,7 @@ describe("node_redis-only", () => { connection.ready.catch(() => {}); let fired = false; const limiter = makeLimiter({ connection }, { expectErrors: true }); - (connection as any).on("error", (_err: unknown) => { + connection.on("error", (_err: unknown) => { if (fired) return; fired = true; expect(limiter.datastore).toStrictEqual("redis"); diff --git a/test/promises.test.ts b/test/promises.test.ts index 5c8f7034..1539c913 100644 --- a/test/promises.test.ts +++ b/test/promises.test.ts @@ -2,6 +2,7 @@ import { describe, expect } from "vitest"; import Bottleneck from "./bottleneck"; import { useFakeClock } from "./helpers/clock"; import { test, waitForState, deferred } from "./helpers/test-api"; +import { wrongType } from "./helpers/wrong-type"; useFakeClock(); @@ -59,13 +60,13 @@ describe("Promises", () => { dropped++; }); - p1 = limiter.schedule({ id: 1 } as any, h.slowPromise, 50, null, 1); - p2 = limiter.schedule({ id: 2 } as any, h.slowPromise, 50, null, 2); + p1 = limiter.schedule({ id: wrongType(1) }, h.slowPromise, 50, null, 1); + p2 = limiter.schedule({ id: wrongType(2) }, h.slowPromise, 50, null, 2); try { - await limiter.schedule({ id: 3 } as any, h.slowPromise, 50, null, 3); - } catch (err: any) { - expect(err.message).toEqual("This job has been dropped by Bottleneck"); + await limiter.schedule({ id: wrongType(3) }, h.slowPromise, 50, null, 3); + } catch (err) { + expect(err).toHaveProperty("message", "This job has been dropped by Bottleneck"); expect(err).toBeInstanceOf(Bottleneck.BottleneckError); caught++; await Promise.all([expect(p1).resolves.toEqual([1]), expect(p2).resolves.toEqual([2])]); @@ -91,7 +92,7 @@ describe("Promises", () => { }); describe("Wrap", () => { - let fn: any; + let fn: () => Promise; test.override({ limiterOptions: { maxConcurrent: 1, minTime: 100 } }); test("Should wrap", async ({ harness: h, limiter }) => { @@ -153,7 +154,7 @@ describe("Promises", () => { } } - Animal.prototype.getName = limiter.wrap(Animal.prototype.getName) as any; + Animal.prototype.getName = wrongType(limiter.wrap(Animal.prototype.getName)); let elephant = new Animal("Dumbo"); const result = await elephant.getName(); diff --git a/test/retries.test.ts b/test/retries.test.ts index 4c9f9b60..d0188a78 100644 --- a/test/retries.test.ts +++ b/test/retries.test.ts @@ -114,16 +114,16 @@ describe("Retries", () => { retryEvents++; }); - limiter.on("error", (error: any, _info: any) => { - expect(error.message).toStrictEqual("Nope"); + limiter.on("error", (error) => { + expect(error).toHaveProperty("message", "Nope"); errorEvents++; }); try { await limiter.schedule(badJob); throw new Error("Should not reach"); - } catch (error: any) { - expect(error.message).toStrictEqual("boom"); + } catch (error) { + expect(error).toHaveProperty("message", "boom"); caught = true; } expect(failedEvents).toStrictEqual(1); @@ -152,16 +152,16 @@ describe("Retries", () => { retryEvents++; }); - limiter.on("error", (error: any, _info: any) => { - expect(error.message).toStrictEqual("Nope"); + limiter.on("error", (error) => { + expect(error).toHaveProperty("message", "Nope"); errorEvents++; }); try { await limiter.schedule(badJob); throw new Error("Should not reach"); - } catch (error: any) { - expect(error.message).toStrictEqual("boom"); + } catch (error) { + expect(error).toHaveProperty("message", "boom"); caught = true; } expect(failedEvents).toStrictEqual(1); @@ -192,8 +192,8 @@ describe("Retries", () => { try { await limiter.schedule(badJob); throw new Error("Should not reach"); - } catch (error: any) { - expect(error.message).toStrictEqual("boom"); + } catch (error) { + expect(error).toHaveProperty("message", "boom"); caught = true; } expect(failedEvents).toStrictEqual(1); @@ -223,8 +223,8 @@ describe("Retries", () => { try { await limiter.schedule(badJob); throw new Error("Should not reach"); - } catch (error: any) { - expect(error.message).toStrictEqual("boom"); + } catch (error) { + expect(error).toHaveProperty("message", "boom"); caught = true; } expect(failedEvents).toStrictEqual(1);