diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4cc385d..b108837 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 with: - version: 8 + version: 9 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb258f..1887eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,21 +13,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.2.3] - 2026-03-29 -### Changed +### Added -- No changes yet. +- **Pipeline** (`bytekit/pipeline`): Typed functional data-pipeline builder with lazy evaluation. + - `Pipeline` class — immutable `.pipe(op)` builder, lazy `.process(input)` executor. + - `pipe()` factory with 7 typed overloads (1-op to 7-op) plus variadic escape hatch. + - `map()`, `filter()`, `reduce()` operator factories with full JSDoc and `@example` blocks. + - `map` / `filter`: concurrent via `Promise.all` (order preserved). + - `reduce`: sequential for deterministic accumulation. + - New `bytekit/pipeline` package export entry. + - `ApiClient.RequestOptions` extended with optional `pipeline` field (non-breaking). + - 20 new tests — 100% statement/branch/function/line coverage on `pipeline.ts`. ## [2.2.2] - 2026-03-29 -### Changed +### Added -- No changes yet. +- **WebSocketHelper** — advanced reconnection and validation features: + - Configurable back-off strategies: `"linear"`, `"exponential"`, or a custom `(attempt) => number` function. + - Full Jitter option for exponential back-off (`jitter: true`). + - `maxReconnectDelayMs` cap for exponential delays. + - Per-message-type schema validation via `schemas: Record` — invalid messages are dropped and `onValidationError` fires. + - Pong / heartbeat timeout detection: forces reconnect if no message arrives within `heartbeatTimeoutMs` after a ping. + - New event handlers: `onReconnect()`, `onMaxRetriesReached()`, `onValidationError()`. + - 15 new tests (30 total for WebSocketHelper). +- **RequestQueue** (`bytekit/async`): Priority-aware concurrency-limited task queue. + - Three fixed priority lanes: `high > normal > low`. + - `AbortSignal`-based task cancellation. + - `onError` callback for per-task error isolation. + - `size`, `running`, `pending` observable state getters. + - `flush()` waiter that resolves when all queued tasks settle. +- **RequestBatcher** (`bytekit/async`): Time-window HTTP request deduplication. + - Fixed and sliding window modes. + - `maxSize` early-flush trigger. + - Custom `keyFn` override for request identity. + - Shared result delivery to all same-key callers within a window. +- `ApiClient` extended with `queue?: RequestQueueOptions` and `batch?: BatchOptions` (non-breaking; legacy `pool` option continues to work). +- 47 new tests (request-queue, request-batcher, ApiClient integration). ## [2.2.1] - 2026-03-28 -### Changed +### Added -- No changes yet. +- **FileUploadHelper** — resumable and concurrent chunked uploads: + - `resumeFrom?: number` option: 0-based chunk index to start from, skipping all prior chunks. Pass `uploadedChunks` from a previous failed response to resume without re-uploading. + - `concurrency?: number` option: upload up to N chunks in parallel (windowed `Promise.all` batching). Defaults to `1` (sequential, fully backward-compatible). + - `UploadResponse.uploadedChunks`: absolute count of chunks successfully sent; safe to use as `resumeFrom` on retry. + - `UploadResponse.totalChunks`: total chunk count for the file at the given `chunkSize`. + - Edge-case clamping: `chunkSize ≤ 0` → 5 MB default; `concurrency < 1` → 1; `resumeFrom < 0` → 0; `resumeFrom ≥ totalChunks` → immediate success with zero fetch calls. + - `onProgress` baseline pre-initialized from skipped chunks so `percentage` reflects total-file progress when resuming. + - 12 new tests (21 total for FileUploadHelper). +- **PromisePool** (`bytekit/async`): Concurrency-limited async task pool with configurable limits and timeout handling. + +### Fixed + +- **ApiClient**: `post()`, `put()`, and `patch()` methods now accept `RequestBody` type for `bodyOrOptions`, resolving a TypeScript type narrowing regression. ## [2.1.3] - 2026-03-20 diff --git a/specs/008-fix-ci-changelog/spec.md b/specs/008-fix-ci-changelog/spec.md new file mode 100644 index 0000000..60e9117 --- /dev/null +++ b/specs/008-fix-ci-changelog/spec.md @@ -0,0 +1,75 @@ +# Feature Specification: Fix CI format:check failures and generate CHANGELOG v2.2.0→v2.2.3 + +**Feature Branch**: `008-fix-ci-changelog` +**Created**: 2026-03-29 +**Status**: Draft +**Input**: Fix CI workflow failures (format:check) and write CHANGELOG entries from v2.2.0 to v2.2.3 + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Fix CI green on every PR (Priority: P1) + +As a developer, I want every PR to pass CI without needing to manually run prettier +before pushing, so I stop losing time investigating "red CI" that has nothing to do +with my actual code change. + +**Why this priority**: CI is failing on every PR due to format:check reporting 19 files +with prettier violations. Nothing else in CI is broken (typecheck passes, lint is warnings +only, tests pass). This blocks contributors and wastes review time. + +**Independent Test**: Run `pnpm run format:check` locally — must exit 0 with no warnings. +Run `pnpm run lint` — must exit 0. Run `pnpm test` — must pass. All three pass in CI. + +**Acceptance Scenarios**: + +1. **Given** the current codebase, **When** `pnpm run format:check` runs, **Then** exits 0 + with the message "All matched files use Prettier code style!" +2. **Given** a developer pushes a branch, **When** CI runs, **Then** the `Lint`, + `Format check`, `Type check`, `Build`, and `Test` steps all pass (green). + +--- + +### User Story 2 - Readable CHANGELOG for v2.2.1, v2.2.2, v2.2.3 (Priority: P2) + +As a library consumer or contributor, I want to read what changed in each release, +so I can decide whether to upgrade and understand what new APIs are available. + +**Why this priority**: CHANGELOG.md currently has "No changes yet." for v2.2.1–v2.2.3 +even though three significant features shipped. This makes it impossible to audit +what changed without reading raw git log. + +**Independent Test**: Open CHANGELOG.md — each of v2.2.1, v2.2.2, v2.2.3 must have +at least one `### Added` or `### Changed` section with a non-placeholder entry. + +**Acceptance Scenarios**: + +1. **Given** CHANGELOG.md, **When** reading the v2.2.3 section, **Then** it documents + the Pipeline feature (pipe(), map(), filter(), reduce(), Pipeline class, bytekit/pipeline export). +2. **Given** CHANGELOG.md, **When** reading the v2.2.2 section, **Then** it documents + WebSocket advanced features (backoff strategies, schema validation, pong detection) + and RequestQueue/RequestBatcher (batching system, priority lanes, concurrency). +3. **Given** CHANGELOG.md, **When** reading the v2.2.1 section, **Then** it documents + FileUploadHelper resume support (resumeFrom, concurrency, uploadedChunks/totalChunks) + and the ApiClient RequestBody type fix. + +--- + +## Technical Requirements + +### CI Fix + +- Run `pnpm run format` to auto-fix all 19 files with prettier violations +- Verify `pnpm run format:check` exits 0 after fix +- Note: ci.yml `build` job uses pnpm v8 throughout; `coverage` and `security` jobs + use v9. This version mismatch is a secondary CI risk — standardize to v9. + +### CHANGELOG + +- Format: Keep a Changelog (https://keepachangelog.com/en/1.0.0/) +- Sections per release: Added / Changed / Fixed (only non-empty sections) +- Content sourced from PR merge commit bodies (#15–#18) + +## Success Metrics + +- `pnpm run format:check` exits 0 locally and in CI +- CHANGELOG entries are accurate and non-empty for v2.2.1–v2.2.3 diff --git a/src/cli/ddd-boilerplate.ts b/src/cli/ddd-boilerplate.ts index 85919cf..5801edc 100644 --- a/src/cli/ddd-boilerplate.ts +++ b/src/cli/ddd-boilerplate.ts @@ -93,10 +93,7 @@ function inferHttpVerb(action: string): HttpVerb { return "GET"; } -function resolveActionConfig( - action: string, - pascal: string -): ActionConfig { +function resolveActionConfig(action: string, pascal: string): ActionConfig { const verb = inferHttpVerb(action); const isList = /all|many|list|search/.test(action.toLowerCase()); @@ -274,7 +271,10 @@ function generateHttpRepoSource( const needsProps = configs.some((c) => c.params.some((p) => p.type.includes("Props")) ); - const entityImportParts = [`${pascal}Entity`, needsProps ? `${pascal}EntityProps` : ""] + const entityImportParts = [ + `${pascal}Entity`, + needsProps ? `${pascal}EntityProps` : "", + ] .filter(Boolean) .join(", "); @@ -343,10 +343,7 @@ export async function generateDddBoilerplate( ); } - const rootDir = path.resolve( - process.cwd(), - options.outDir?.trim() || slug - ); + const rootDir = path.resolve(process.cwd(), options.outDir?.trim() || slug); const contextPascal = pascalFromKebabSlug(slug); const outboundSlug = slugifyDomain(portLabel); @@ -434,7 +431,11 @@ export interface ${outboundPascal} { "entities", `${slug}.entity.ts` ); - await fs.writeFile(entityFile, generateEntitySource(slug, contextPascal), "utf8"); + await fs.writeFile( + entityFile, + generateEntitySource(slug, contextPascal), + "utf8" + ); // 2. Repository interface const repoInterfaceFile = path.join( diff --git a/src/cli/index.ts b/src/cli/index.ts index 7dd08dc..0f8c841 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -46,7 +46,10 @@ function parseDddArgs(argv: string[]): DddCliArgs | null { outDir = arg.slice("--out=".length); } else if (arg.startsWith("--actions=")) { const raw = arg.slice("--actions=".length); - actions = raw.split(",").map((a) => a.trim()).filter(Boolean); + actions = raw + .split(",") + .map((a) => a.trim()) + .filter(Boolean); } } diff --git a/src/utils/async/index.ts b/src/utils/async/index.ts index 7dcac86..8bb6868 100644 --- a/src/utils/async/index.ts +++ b/src/utils/async/index.ts @@ -37,10 +37,7 @@ export { debounceAsync } from "./debounce.js"; export { throttleAsync } from "./throttle.js"; export { PromisePool, PoolTimeoutError } from "./promise-pool.js"; export type { PromisePoolOptions } from "./promise-pool.js"; -export { - RequestQueue, - QueueAbortError, -} from "./request-queue.js"; +export { RequestQueue, QueueAbortError } from "./request-queue.js"; export type { QueuePriority, RequestQueueOptions, diff --git a/src/utils/async/pipeline.ts b/src/utils/async/pipeline.ts index 1165c9b..d5c8f11 100644 --- a/src/utils/async/pipeline.ts +++ b/src/utils/async/pipeline.ts @@ -101,9 +101,7 @@ export class Pipeline { * ); * // Inferred: Pipeline */ -export function pipe( - op1: PipelineOp -): Pipeline; +export function pipe(op1: PipelineOp): Pipeline; export function pipe( op1: PipelineOp, diff --git a/src/utils/async/promise-pool.ts b/src/utils/async/promise-pool.ts index 4368d0f..197a1b1 100644 --- a/src/utils/async/promise-pool.ts +++ b/src/utils/async/promise-pool.ts @@ -84,7 +84,9 @@ export class PromisePool { } const results: T[] = new Array(tasks.length); - await Promise.all(tasks.map((task, index) => this.addTask(task, index, results))); + await Promise.all( + tasks.map((task, index) => this.addTask(task, index, results)) + ); return results; } @@ -105,7 +107,10 @@ export class PromisePool { } private processQueue(results: unknown[]): void { - while (this.running < this.options.concurrency && this.queue.length > 0) { + while ( + this.running < this.options.concurrency && + this.queue.length > 0 + ) { const item = this.queue.shift()!; this.running++; this.executeTask(item, results); @@ -113,7 +118,12 @@ export class PromisePool { } private async executeTask( - item: { task: () => Promise; resolve: (v: unknown) => void; reject: (r: unknown) => void; index: number }, + item: { + task: () => Promise; + resolve: (v: unknown) => void; + reject: (r: unknown) => void; + index: number; + }, results: unknown[] ): Promise { try { diff --git a/src/utils/core/ApiClient.ts b/src/utils/core/ApiClient.ts index d58b51c..fec3547 100644 --- a/src/utils/core/ApiClient.ts +++ b/src/utils/core/ApiClient.ts @@ -1,4 +1,3 @@ - import { Logger } from "#core/Logger.js"; import { UrlHelper } from "#helpers/UrlHelper.js"; import { retry as retryFn } from "../async/retry.js"; @@ -330,7 +329,10 @@ export class ApiClient { * headers: { "X-Custom": "value" } * }) */ - async post(path: string | URL, bodyOrOptions?: RequestOptions | RequestBody) { + async post( + path: string | URL, + bodyOrOptions?: RequestOptions | RequestBody + ) { const options = this.normalizeBodyOrOptions(bodyOrOptions); return this.request(path, { ...options, method: "POST" }); } @@ -338,7 +340,10 @@ export class ApiClient { /** * PUT request - Acepta body directamente o RequestOptions */ - async put(path: string | URL, bodyOrOptions?: RequestOptions | RequestBody) { + async put( + path: string | URL, + bodyOrOptions?: RequestOptions | RequestBody + ) { const options = this.normalizeBodyOrOptions(bodyOrOptions); return this.request(path, { ...options, method: "PUT" }); } @@ -346,7 +351,10 @@ export class ApiClient { /** * PATCH request - Acepta body directamente o RequestOptions */ - async patch(path: string | URL, bodyOrOptions?: RequestOptions | RequestBody) { + async patch( + path: string | URL, + bodyOrOptions?: RequestOptions | RequestBody + ) { const options = this.normalizeBodyOrOptions(bodyOrOptions); return this.request(path, { ...options, method: "PATCH" }); } @@ -571,7 +579,9 @@ export class ApiClient { ): Promise { // US4: RequestQueue — concurrency-limited, priority-aware queue if (this._queue) { - return this._queue.add((_signal) => this.executeRequest(path, options)); + return this._queue.add((_signal) => + this.executeRequest(path, options) + ); } // US4: RequestBatcher — time-window deduplication @@ -579,16 +589,16 @@ export class ApiClient { const pathStr = String(path); const method = (options.method ?? "GET").toUpperCase(); const proxyInit: RequestInit = { method }; - return this._batcher.add( - pathStr, - proxyInit, - (_url, _init) => this.executeRequest(path, options) + return this._batcher.add(pathStr, proxyInit, (_url, _init) => + this.executeRequest(path, options) ); } // T026: legacy pool support (003) — if a pool is configured, route through it if (this.pool) { - const results = await this.pool.run([() => this.executeRequest(path, options)]); + const results = await this.pool.run([ + () => this.executeRequest(path, options), + ]); return results[0]; } return this.executeRequest(path, options); diff --git a/src/utils/helpers/StreamingHelper.ts b/src/utils/helpers/StreamingHelper.ts index acef898..64f5930 100644 --- a/src/utils/helpers/StreamingHelper.ts +++ b/src/utils/helpers/StreamingHelper.ts @@ -105,16 +105,16 @@ export class StreamingHelper { /** * Stream JSON lines from an endpoint (NDJSON). - * + * * Use this method to process large JSON datasets without loading the entire response into memory. * Each line in the response must be a valid JSON object. - * + * * @example * await StreamingHelper.streamJsonLines("https://api.example.com/large-data", { * onChunk: (item) => console.log("Processed item:", item), * onComplete: () => console.log("Done!") * }); - * + * * @param endpoint The URL to fetch from * @param options Stream options including callbacks and timeout */ @@ -192,19 +192,19 @@ export class StreamingHelper { /** * Stream Server-Sent Events (SSE). - * + * * Establishes a persistent connection to receive real-time updates from the server. * Automatically parses incoming data as JSON. - * + * * @example * const stream = StreamingHelper.streamSSE("https://api.example.com/events"); * const unsubscribe = stream.subscribe((data) => { * console.log("New real-time data:", data); * }); - * + * * // To close: * // stream.close(); - * + * * @param endpoint The SSE endpoint URL * @param options Subscription options and custom event type */ @@ -318,7 +318,8 @@ export class StreamingHelper { body instanceof FormData || body instanceof URLSearchParams || body instanceof Blob || - (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) + (typeof ReadableStream !== "undefined" && + body instanceof ReadableStream) ) { requestBody = body as BodyInit; } else { @@ -423,9 +424,10 @@ export class StreamingHelper { } else { field = cleaned.slice(0, colonIdx); // The spec says: if the first character after ':' is a space, strip it - val = cleaned[colonIdx + 1] === " " - ? cleaned.slice(colonIdx + 2) - : cleaned.slice(colonIdx + 1); + val = + cleaned[colonIdx + 1] === " " + ? cleaned.slice(colonIdx + 2) + : cleaned.slice(colonIdx + 1); } switch (field) { @@ -470,7 +472,11 @@ export class StreamingHelper { const type = eventType || "message"; // Filter by allowed event types - if (allowedTypes && allowedTypes.length > 0 && !allowedTypes.includes(type)) { + if ( + allowedTypes && + allowedTypes.length > 0 && + !allowedTypes.includes(type) + ) { return null; } diff --git a/src/utils/helpers/UrlHelper.ts b/src/utils/helpers/UrlHelper.ts index 5803b9b..7e98a9e 100644 --- a/src/utils/helpers/UrlHelper.ts +++ b/src/utils/helpers/UrlHelper.ts @@ -137,11 +137,12 @@ export class UrlHelper { options: string | SlugifyOptions = {} ): string { if (!params) return ""; - + // Mantener compatibilidad con el parámetro 'separator' como string si se envía así - const opts: SlugifyOptions = typeof options === "string" ? { separator: options } : options; + const opts: SlugifyOptions = + typeof options === "string" ? { separator: options } : options; const { separator = "-", lowercase = true } = opts; - + let rawString = ""; if (typeof params === "string") { @@ -150,11 +151,17 @@ export class UrlHelper { const queryOptions = { ...DEFAULT_QUERY_OPTIONS }; const pairs: QueryPair[] = []; const entries = Object.entries(params); - if (queryOptions.sortKeys) entries.sort(([a], [b]) => a.localeCompare(b)); + if (queryOptions.sortKeys) + entries.sort(([a], [b]) => a.localeCompare(b)); for (const [key, value] of entries) { // We use the same deep flattening logic as stringify but we skip encoding - buildQueryPairs(key, value, { ...queryOptions, encode: false }, pairs); + buildQueryPairs( + key, + value, + { ...queryOptions, encode: false }, + pairs + ); } // Combine keys and values diff --git a/tests/async/promise-pool-api-client.test.ts b/tests/async/promise-pool-api-client.test.ts index 7044eae..415bcbf 100644 --- a/tests/async/promise-pool-api-client.test.ts +++ b/tests/async/promise-pool-api-client.test.ts @@ -55,10 +55,13 @@ function makeFetchWithErrors( setTimeout(() => { if (errorPaths.has(path)) { resolve( - new Response(JSON.stringify({ error: "server error" }), { - status: errorStatus, - headers: { "Content-Type": "application/json" }, - }) + new Response( + JSON.stringify({ error: "server error" }), + { + status: errorStatus, + headers: { "Content-Type": "application/json" }, + } + ) ); } else { resolve( @@ -77,7 +80,6 @@ function makeFetchWithErrors( // ───────────────────────────────────────────────────────────────────────────── describe("ApiClient — PromisePool integration (US3)", () => { - // T022 — pool limits concurrent in-flight fetch calls it("T022 — limits concurrent requests when pool option is configured", async () => { const { fetchImpl, getMaxConcurrent } = makeMockFetch(30); @@ -97,7 +99,8 @@ describe("ApiClient — PromisePool integration (US3)", () => { // T023 — no pool option = no restriction (regression guard) it("T023 — ApiClient without pool option works as before (no regression)", async () => { - const { fetchImpl, getTotalCalls, getMaxConcurrent } = makeMockFetch(10); + const { fetchImpl, getTotalCalls, getMaxConcurrent } = + makeMockFetch(10); const client = new ApiClient({ baseUrl: "http://example.com", fetchImpl, @@ -115,7 +118,8 @@ describe("ApiClient — PromisePool integration (US3)", () => { // pool: { concurrency: 1 } — fully sequential it("pool concurrency=1 — all requests run sequentially (maxConcurrent === 1)", async () => { - const { fetchImpl, getMaxConcurrent, getTotalCalls } = makeMockFetch(20); + const { fetchImpl, getMaxConcurrent, getTotalCalls } = + makeMockFetch(20); const client = new ApiClient({ baseUrl: "http://example.com", fetchImpl, @@ -197,6 +201,8 @@ describe("ApiClient — PromisePool integration (US3)", () => { ]); expect(goodResult.status).toBe("fulfilled"); - expect((goodResult as PromiseFulfilledResult<{ ok: boolean }>).value.ok).toBe(true); + expect( + (goodResult as PromiseFulfilledResult<{ ok: boolean }>).value.ok + ).toBe(true); }); }); diff --git a/tests/async/promise-pool.test.ts b/tests/async/promise-pool.test.ts index 2be4556..d65bcad 100644 --- a/tests/async/promise-pool.test.ts +++ b/tests/async/promise-pool.test.ts @@ -1,26 +1,36 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { PromisePool, PoolTimeoutError } from "../../src/utils/async/promise-pool"; +import { + PromisePool, + PoolTimeoutError, +} from "../../src/utils/async/promise-pool"; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Task that resolves with `value` after `ms` milliseconds. */ function delayed(value: T, ms: number): () => Promise { - return () => new Promise((resolve) => setTimeout(() => resolve(value), ms)); + return () => + new Promise((resolve) => setTimeout(() => resolve(value), ms)); } /** Task that rejects with `error` after `ms` milliseconds (default: 0). */ function failing(error: Error, ms = 0): () => Promise { - return () => new Promise((_, reject) => setTimeout(() => reject(error), ms)); + return () => + new Promise((_, reject) => setTimeout(() => reject(error), ms)); } /** Task that throws synchronously before returning a Promise. */ function syncThrowing(error: Error): () => Promise { - return () => { throw error; }; + return () => { + throw error; + }; } /** Task that never resolves (use with fake timers). */ function hanging(): () => Promise { - return () => new Promise(() => { /* intentionally hangs */ }); + return () => + new Promise(() => { + /* intentionally hangs */ + }); } /** @@ -52,15 +62,18 @@ function withConcurrencyTracker(tasks: Array<() => Promise>) { function countdownLatch(n: number) { let remaining = n; let resolve!: () => void; - const latch = new Promise((r) => { resolve = r; }); - const tick = () => { if (--remaining <= 0) resolve(); }; + const latch = new Promise((r) => { + resolve = r; + }); + const tick = () => { + if (--remaining <= 0) resolve(); + }; return { latch, tick }; } // ───────────────────────────────────────────────────────────────────────────── describe("PromisePool", () => { - // ========================================================================= // 1. Constructor validation // ========================================================================= @@ -75,25 +88,39 @@ describe("PromisePool", () => { }); it("throws TypeError when concurrency = 0", () => { - expect(() => new PromisePool({ concurrency: 0 })).toThrow(TypeError); + expect(() => new PromisePool({ concurrency: 0 })).toThrow( + TypeError + ); }); it("throws TypeError when concurrency is negative", () => { - expect(() => new PromisePool({ concurrency: -1 })).toThrow(TypeError); - expect(() => new PromisePool({ concurrency: -999 })).toThrow(TypeError); + expect(() => new PromisePool({ concurrency: -1 })).toThrow( + TypeError + ); + expect(() => new PromisePool({ concurrency: -999 })).toThrow( + TypeError + ); }); it("accepts timeout = Infinity (valid: Infinity > 0)", () => { - expect(() => new PromisePool({ concurrency: 1, timeout: Infinity })).not.toThrow(); + expect( + () => new PromisePool({ concurrency: 1, timeout: Infinity }) + ).not.toThrow(); }); it("throws TypeError when timeout = 0", () => { - expect(() => new PromisePool({ concurrency: 1, timeout: 0 })).toThrow(TypeError); + expect( + () => new PromisePool({ concurrency: 1, timeout: 0 }) + ).toThrow(TypeError); }); it("throws TypeError when timeout is negative", () => { - expect(() => new PromisePool({ concurrency: 1, timeout: -1 })).toThrow(TypeError); - expect(() => new PromisePool({ concurrency: 1, timeout: -100 })).toThrow(TypeError); + expect( + () => new PromisePool({ concurrency: 1, timeout: -1 }) + ).toThrow(TypeError); + expect( + () => new PromisePool({ concurrency: 1, timeout: -100 }) + ).toThrow(TypeError); }); it("onError is optional — omitting it does not throw", () => { @@ -126,7 +153,9 @@ describe("PromisePool", () => { }); it("throws TypeError for a string input", async () => { - await expect(pool.run("not-array" as never)).rejects.toThrow(TypeError); + await expect(pool.run("not-array" as never)).rejects.toThrow( + TypeError + ); }); it("throws TypeError for null input", async () => { @@ -134,7 +163,9 @@ describe("PromisePool", () => { }); it("throws TypeError for undefined input", async () => { - await expect(pool.run(undefined as never)).rejects.toThrow(TypeError); + await expect(pool.run(undefined as never)).rejects.toThrow( + TypeError + ); }); it("throws TypeError when a number appears at index 0", async () => { @@ -142,7 +173,9 @@ describe("PromisePool", () => { }); it("throws TypeError when a non-function appears at a non-zero index", async () => { - await expect(pool.run([delayed(1, 0), "bad" as never])).rejects.toThrow(TypeError); + await expect( + pool.run([delayed(1, 0), "bad" as never]) + ).rejects.toThrow(TypeError); }); it("TypeError message includes the offending element's index", async () => { @@ -173,9 +206,16 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: 1 }); await pool.run([ - async () => { order.push(0); await new Promise((r) => setTimeout(r, 20)); }, - async () => { order.push(1); }, - async () => { order.push(2); }, + async () => { + order.push(0); + await new Promise((r) => setTimeout(r, 20)); + }, + async () => { + order.push(1); + }, + async () => { + order.push(2); + }, ]); expect(order).toEqual([0, 1, 2]); @@ -214,9 +254,17 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: 1 }); await pool.run([ - async () => { starts.push(0); await new Promise((r) => setTimeout(r, 20)); }, - async () => { starts.push(1); await new Promise((r) => setTimeout(r, 20)); }, - async () => { starts.push(2); }, + async () => { + starts.push(0); + await new Promise((r) => setTimeout(r, 20)); + }, + async () => { + starts.push(1); + await new Promise((r) => setTimeout(r, 20)); + }, + async () => { + starts.push(2); + }, ]); expect(starts).toEqual([0, 1, 2]); @@ -280,7 +328,9 @@ describe("PromisePool", () => { it("preserves undefined as a result value", async () => { const pool = new PromisePool({ concurrency: 1 }); - const results = await pool.run([async () => undefined as unknown as string]); + const results = await pool.run([ + async () => undefined as unknown as string, + ]); expect(results).toEqual([undefined]); }); @@ -318,8 +368,11 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: 1 }); const original = new TypeError("original"); let caught: unknown; - try { await pool.run([failing(original)]); } - catch (e) { caught = e; } + try { + await pool.run([failing(original)]); + } catch (e) { + caught = e; + } expect(caught).toBe(original); }); @@ -357,7 +410,9 @@ describe("PromisePool", () => { onError: (error, index) => calls.push({ error, index }), }); const err = new Error("boom"); - await pool.run([async () => "ok", failing(err), async () => "ok"]).catch(() => { }); + await pool + .run([async () => "ok", failing(err), async () => "ok"]) + .catch(() => {}); expect(calls).toHaveLength(1); expect(calls[0].error).toBe(err); expect(calls[0].index).toBe(1); @@ -366,12 +421,18 @@ describe("PromisePool", () => { // T018 it("T018 — pool continues executing remaining tasks after onError fires", async () => { const completed: number[] = []; - const pool = new PromisePool({ concurrency: 1, onError: () => { } }); - await pool.run([ - async () => { completed.push(0); }, - failing(new Error("fail")), - async () => { completed.push(2); }, - ]).catch(() => { }); + const pool = new PromisePool({ concurrency: 1, onError: () => {} }); + await pool + .run([ + async () => { + completed.push(0); + }, + failing(new Error("fail")), + async () => { + completed.push(2); + }, + ]) + .catch(() => {}); expect(completed).toContain(0); expect(completed).toContain(2); }); @@ -380,33 +441,48 @@ describe("PromisePool", () => { it("T020 — onError receives the original error reference (not a wrapper)", async () => { const original = new TypeError("original"); const received: Error[] = []; - const pool = new PromisePool({ concurrency: 2, onError: (err) => received.push(err) }); - await pool.run([failing(original)]).catch(() => { }); + const pool = new PromisePool({ + concurrency: 2, + onError: (err) => received.push(err), + }); + await pool.run([failing(original)]).catch(() => {}); expect(received[0]).toBe(original); expect(received[0]).toBeInstanceOf(TypeError); }); it("run() STILL rejects even when onError is configured", async () => { - const pool = new PromisePool({ concurrency: 1, onError: () => { } }); + const pool = new PromisePool({ concurrency: 1, onError: () => {} }); const err = new Error("fail"); await expect(pool.run([failing(err)])).rejects.toBe(err); }); it("onError is NOT called for successful tasks", async () => { let callCount = 0; - const pool = new PromisePool({ concurrency: 3, onError: () => callCount++ }); - await pool.run([delayed("a", 10), delayed("b", 10), delayed("c", 10)]); + const pool = new PromisePool({ + concurrency: 3, + onError: () => callCount++, + }); + await pool.run([ + delayed("a", 10), + delayed("b", 10), + delayed("c", 10), + ]); expect(callCount).toBe(0); }); it("onError index matches original array position, not execution order", async () => { const indices: number[] = []; - const pool = new PromisePool({ concurrency: 3, onError: (_, i) => indices.push(i) }); - await pool.run([ - delayed("slow", 50), - delayed("medium", 25), - failing(new Error("fast fail"), 0), // index 2, but fails first - ]).catch(() => { }); + const pool = new PromisePool({ + concurrency: 3, + onError: (_, i) => indices.push(i), + }); + await pool + .run([ + delayed("slow", 50), + delayed("medium", 25), + failing(new Error("fast fail"), 0), // index 2, but fails first + ]) + .catch(() => {}); expect(indices).toContain(2); }); @@ -418,7 +494,10 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: N, // all tasks start simultaneously - onError: (_, i) => { erroredIndices.push(i); tick(); }, + onError: (_, i) => { + erroredIndices.push(i); + tick(); + }, }); await Promise.allSettled([ @@ -435,17 +514,27 @@ describe("PromisePool", () => { it("all tasks succeed — onError never called, run() resolves", async () => { let callCount = 0; - const pool = new PromisePool({ concurrency: 5, onError: () => callCount++ }); - const results = await pool.run([delayed(1, 5), delayed(2, 10), delayed(3, 5)]); + const pool = new PromisePool({ + concurrency: 5, + onError: () => callCount++, + }); + const results = await pool.run([ + delayed(1, 5), + delayed(2, 10), + delayed(3, 5), + ]); expect(callCount).toBe(0); expect(results).toEqual([1, 2, 3]); }); it("synchronously throwing task also triggers onError", async () => { const errors: Error[] = []; - const pool = new PromisePool({ concurrency: 2, onError: (err) => errors.push(err) }); + const pool = new PromisePool({ + concurrency: 2, + onError: (err) => errors.push(err), + }); const syncErr = new Error("sync"); - await pool.run([syncThrowing(syncErr)]).catch(() => { }); + await pool.run([syncThrowing(syncErr)]).catch(() => {}); expect(errors).toHaveLength(1); expect(errors[0]).toBe(syncErr); }); @@ -461,22 +550,30 @@ describe("PromisePool", () => { // T015 it("T015 — task exceeding timeout rejects with PoolTimeoutError", async () => { const pool = new PromisePool({ concurrency: 1, timeout: 50 }); - await expect(pool.run([delayed("slow", 500)])).rejects.toBeInstanceOf(PoolTimeoutError); + await expect( + pool.run([delayed("slow", 500)]) + ).rejects.toBeInstanceOf(PoolTimeoutError); }); it("PoolTimeoutError.name === 'PoolTimeoutError'", async () => { const pool = new PromisePool({ concurrency: 1, timeout: 50 }); let name = ""; - try { await pool.run([delayed("slow", 500)]); } - catch (err) { name = (err as Error).name; } + try { + await pool.run([delayed("slow", 500)]); + } catch (err) { + name = (err as Error).name; + } expect(name).toBe("PoolTimeoutError"); }); it("PoolTimeoutError is an instance of Error", async () => { const pool = new PromisePool({ concurrency: 1, timeout: 50 }); let caught: unknown; - try { await pool.run([delayed("slow", 500)]); } - catch (err) { caught = err; } + try { + await pool.run([delayed("slow", 500)]); + } catch (err) { + caught = err; + } expect(caught).toBeInstanceOf(Error); expect(caught).toBeInstanceOf(PoolTimeoutError); }); @@ -484,8 +581,11 @@ describe("PromisePool", () => { it("PoolTimeoutError.message contains the configured timeout value", async () => { const pool = new PromisePool({ concurrency: 1, timeout: 123 }); let message = ""; - try { await pool.run([delayed("slow", 500)]); } - catch (err) { message = (err as Error).message; } + try { + await pool.run([delayed("slow", 500)]); + } catch (err) { + message = (err as Error).message; + } expect(message).toContain("123"); }); @@ -506,12 +606,25 @@ describe("PromisePool", () => { // T016 it("T016 — pool continues executing after a task times out (with onError)", async () => { const completed: number[] = []; - const pool = new PromisePool({ concurrency: 2, timeout: 50, onError: () => { } }); - await pool.run([ - async () => { await new Promise((r) => setTimeout(r, 500)); completed.push(0); }, - async () => { completed.push(1); }, - async () => { completed.push(2); }, - ]).catch(() => { }); + const pool = new PromisePool({ + concurrency: 2, + timeout: 50, + onError: () => {}, + }); + await pool + .run([ + async () => { + await new Promise((r) => setTimeout(r, 500)); + completed.push(0); + }, + async () => { + completed.push(1); + }, + async () => { + completed.push(2); + }, + ]) + .catch(() => {}); expect(completed).toContain(1); expect(completed).toContain(2); }); @@ -526,11 +639,15 @@ describe("PromisePool", () => { }); it("US2 guard — timeout=0 throws TypeError in constructor", () => { - expect(() => new PromisePool({ concurrency: 1, timeout: 0 })).toThrow(TypeError); + expect( + () => new PromisePool({ concurrency: 1, timeout: 0 }) + ).toThrow(TypeError); }); it("US2 guard — timeout=-1 throws TypeError in constructor", () => { - expect(() => new PromisePool({ concurrency: 1, timeout: -1 })).toThrow(TypeError); + expect( + () => new PromisePool({ concurrency: 1, timeout: -1 }) + ).toThrow(TypeError); }); it("[fake timers] never-resolving task times out at exactly the configured ms", async () => { @@ -571,7 +688,9 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: 3 }); for (let batch = 0; batch < 5; batch++) { const expected = [0, 1, 2].map((i) => batch * 10 + i); - const results = await pool.run(expected.map((v) => delayed(v, 5))); + const results = await pool.run( + expected.map((v) => delayed(v, 5)) + ); expect(results).toEqual(expected); } }); @@ -584,8 +703,8 @@ describe("PromisePool", () => { }); it("pool is reusable after a single-task failure", async () => { - const pool = new PromisePool({ concurrency: 2, onError: () => { } }); - await pool.run([failing(new Error("fail"))]).catch(() => { }); + const pool = new PromisePool({ concurrency: 2, onError: () => {} }); + await pool.run([failing(new Error("fail"))]).catch(() => {}); // Allow the event loop to drain any background tasks from the failed run await new Promise((r) => setTimeout(r, 30)); const results = await pool.run([delayed("recovered", 10)]); @@ -611,13 +730,17 @@ describe("PromisePool", () => { describe("edge cases", () => { it("single task resolving immediately with Promise.resolve()", async () => { const pool = new PromisePool({ concurrency: 1 }); - await expect(pool.run([() => Promise.resolve(42)])).resolves.toEqual([42]); + await expect( + pool.run([() => Promise.resolve(42)]) + ).resolves.toEqual([42]); }); it("single task rejecting immediately with Promise.reject()", async () => { const pool = new PromisePool({ concurrency: 1 }); const err = new Error("immediate reject"); - await expect(pool.run([() => Promise.reject(err)])).rejects.toBe(err); + await expect(pool.run([() => Promise.reject(err)])).rejects.toBe( + err + ); }); it("concurrency=100 with 100 tasks — all start simultaneously, results ordered", async () => { @@ -638,15 +761,24 @@ describe("PromisePool", () => { const pool = new PromisePool({ concurrency: 4, - onError: (_, i) => { errorIndices.push(i); tick(); }, + onError: (_, i) => { + errorIndices.push(i); + tick(); + }, }); await Promise.allSettled([ pool.run([ - async () => { successIndices.push(0); return "ok"; }, // success - failing(new Error("async reject"), 5), // index 1: async fail - syncThrowing(new Error("sync throw")), // index 2: sync fail - async () => { successIndices.push(3); return "also ok"; }, // success + async () => { + successIndices.push(0); + return "ok"; + }, // success + failing(new Error("async reject"), 5), // index 1: async fail + syncThrowing(new Error("sync throw")), // index 2: sync fail + async () => { + successIndices.push(3); + return "also ok"; + }, // success ]), latch, ]); diff --git a/tests/async/request-batcher.test.ts b/tests/async/request-batcher.test.ts index af00db2..4f272a8 100644 --- a/tests/async/request-batcher.test.ts +++ b/tests/async/request-batcher.test.ts @@ -8,8 +8,9 @@ function makeFetcher(result: T) { } function makeFailingFetcher(error: Error) { - return vi.fn((_url: string, _init: RequestInit) => - Promise.reject(error) as Promise + return vi.fn( + (_url: string, _init: RequestInit) => + Promise.reject(error) as Promise ); } @@ -74,7 +75,10 @@ describe("RequestBatcher", () => { it("T025: sliding=true resets the timer on each new request", async () => { vi.useFakeTimers(); const fetcher = makeFetcher("slid"); - const batcher = new RequestBatcher({ windowMs: 200, sliding: true }); + const batcher = new RequestBatcher({ + windowMs: 200, + sliding: true, + }); const p1 = batcher.add("/api", { method: "GET" }, fetcher); // 150ms in — timer hasn't fired yet @@ -89,7 +93,10 @@ describe("RequestBatcher", () => { // Now 200ms from last request → fires await vi.advanceTimersByTimeAsync(50); expect(fetcher).toHaveBeenCalledTimes(1); - await expect(Promise.all([p1, p2])).resolves.toEqual(["slid", "slid"]); + await expect(Promise.all([p1, p2])).resolves.toEqual([ + "slid", + "slid", + ]); }); it("T026: requests with different keys dispatch as independent buckets", async () => { @@ -166,8 +173,12 @@ describe("RequestBatcher", () => { }); it("T030: windowMs <= 0 throws TypeError; maxSize < 1 throws TypeError", () => { - expect(() => new RequestBatcher({ windowMs: 0 })).toThrow(TypeError); - expect(() => new RequestBatcher({ windowMs: -1 })).toThrow(TypeError); + expect(() => new RequestBatcher({ windowMs: 0 })).toThrow( + TypeError + ); + expect(() => new RequestBatcher({ windowMs: -1 })).toThrow( + TypeError + ); expect( () => new RequestBatcher({ windowMs: 100, maxSize: 0 }) ).toThrow(TypeError); @@ -186,8 +197,16 @@ describe("RequestBatcher", () => { const err = new Error("fetch failed"); const batcher = new RequestBatcher({ windowMs: 100 }); - const p1 = batcher.add("/api", { method: "GET" }, makeFailingFetcher(err)); - const p2 = batcher.add("/api", { method: "GET" }, makeFailingFetcher(err)); + const p1 = batcher.add( + "/api", + { method: "GET" }, + makeFailingFetcher(err) + ); + const p2 = batcher.add( + "/api", + { method: "GET" }, + makeFailingFetcher(err) + ); // Attach handlers before timer fires so rejections are never unhandled const settlement = Promise.allSettled([p1, p2]); @@ -205,8 +224,16 @@ describe("RequestBatcher", () => { const fetcher = makeFetcher("ok"); const batcher = new RequestBatcher({ windowMs: 100 }); - const p1 = batcher.add("/api", { method: "POST", body: '{"a":1}' }, fetcher); - const p2 = batcher.add("/api", { method: "POST", body: '{"b":2}' }, fetcher); + const p1 = batcher.add( + "/api", + { method: "POST", body: '{"a":1}' }, + fetcher + ); + const p2 = batcher.add( + "/api", + { method: "POST", body: '{"b":2}' }, + fetcher + ); await vi.advanceTimersByTimeAsync(100); @@ -222,12 +249,23 @@ describe("RequestBatcher", () => { // Pass a number as body (unconventional but exercises the stableSerialize number branch) const batcher = new RequestBatcher({ windowMs: 100 }); - const p1 = batcher.add("/api", { method: "POST", body: 42 as unknown as BodyInit }, fetcher); - const p2 = batcher.add("/api", { method: "POST", body: 42 as unknown as BodyInit }, fetcher); + const p1 = batcher.add( + "/api", + { method: "POST", body: 42 as unknown as BodyInit }, + fetcher + ); + const p2 = batcher.add( + "/api", + { method: "POST", body: 42 as unknown as BodyInit }, + fetcher + ); // Same numeric body → same key → coalesced await vi.advanceTimersByTimeAsync(100); expect(fetcher).toHaveBeenCalledTimes(1); - await expect(Promise.all([p1, p2])).resolves.toEqual(["num", "num"]); + await expect(Promise.all([p1, p2])).resolves.toEqual([ + "num", + "num", + ]); }); it("stableSerialize: boolean body produces distinct key (covers boolean branch)", async () => { @@ -236,8 +274,16 @@ describe("RequestBatcher", () => { const fetcherF = makeFetcher("false-result"); const batcher = new RequestBatcher({ windowMs: 100 }); - const p1 = batcher.add("/api", { method: "POST", body: true as unknown as BodyInit }, fetcherT); - const p2 = batcher.add("/api", { method: "POST", body: false as unknown as BodyInit }, fetcherF); + const p1 = batcher.add( + "/api", + { method: "POST", body: true as unknown as BodyInit }, + fetcherT + ); + const p2 = batcher.add( + "/api", + { method: "POST", body: false as unknown as BodyInit }, + fetcherF + ); // true vs false → different keys → dispatch independently await vi.advanceTimersByTimeAsync(100); expect(fetcherT).toHaveBeenCalledTimes(1); diff --git a/tests/async/request-queue.test.ts b/tests/async/request-queue.test.ts index e7e8f5e..ca16070 100644 --- a/tests/async/request-queue.test.ts +++ b/tests/async/request-queue.test.ts @@ -42,16 +42,15 @@ describe("RequestQueue", () => { const tasks = Array.from( { length: 20 }, - () => - (_signal: AbortSignal) => - new Promise((resolve) => { - running++; - maxObserved = Math.max(maxObserved, running); - setTimeout(() => { - running--; - resolve(); - }, 5); - }) + () => (_signal: AbortSignal) => + new Promise((resolve) => { + running++; + maxObserved = Math.max(maxObserved, running); + setTimeout(() => { + running--; + resolve(); + }, 5); + }) ); await Promise.all(tasks.map((t) => queue.add(t))); @@ -64,9 +63,21 @@ describe("RequestQueue", () => { await Promise.allSettled([ queue.add(failing()), - queue.add((_s) => Promise.resolve().then(() => { completed++; })), - queue.add((_s) => Promise.resolve().then(() => { completed++; })), - queue.add((_s) => Promise.resolve().then(() => { completed++; })), + queue.add((_s) => + Promise.resolve().then(() => { + completed++; + }) + ), + queue.add((_s) => + Promise.resolve().then(() => { + completed++; + }) + ), + queue.add((_s) => + Promise.resolve().then(() => { + completed++; + }) + ), ]); expect(completed).toBe(3); @@ -76,19 +87,34 @@ describe("RequestQueue", () => { const queue = new RequestQueue({ concurrency: 2 }); let settled = 0; - const p1 = queue.add((_s) => - new Promise((r) => - setTimeout(() => { settled++; r(); }, 20) - )); - const p2 = queue.add((_s) => - new Promise((r) => - setTimeout(() => { settled++; r(); }, 10) - )); + const p1 = queue.add( + (_s) => + new Promise((r) => + setTimeout(() => { + settled++; + r(); + }, 20) + ) + ); + const p2 = queue.add( + (_s) => + new Promise((r) => + setTimeout(() => { + settled++; + r(); + }, 10) + ) + ); // Third task queued (concurrency=2, first two are running) - const p3 = queue.add((_s) => - new Promise((_, j) => - setTimeout(() => { settled++; j(new Error("boom")); }, 15) - )); + const p3 = queue.add( + (_s) => + new Promise((_, j) => + setTimeout(() => { + settled++; + j(new Error("boom")); + }, 15) + ) + ); // Attach handlers before tasks settle to prevent unhandled rejection warnings const settlement = Promise.allSettled([p1, p2, p3]); @@ -106,7 +132,10 @@ describe("RequestQueue", () => { let resolveTask!: () => void; queue.add( - (_s) => new Promise((r) => { resolveTask = r; }) + (_s) => + new Promise((r) => { + resolveTask = r; + }) ); // _drain() runs synchronously inside add() → task starts immediately @@ -132,9 +161,15 @@ describe("RequestQueue", () => { }); it("T009: constructor throws TypeError when concurrency < 1", () => { - expect(() => new RequestQueue({ concurrency: 0 })).toThrow(TypeError); - expect(() => new RequestQueue({ concurrency: -1 })).toThrow(TypeError); - expect(() => new RequestQueue({ concurrency: 0.5 })).toThrow(TypeError); + expect(() => new RequestQueue({ concurrency: 0 })).toThrow( + TypeError + ); + expect(() => new RequestQueue({ concurrency: -1 })).toThrow( + TypeError + ); + expect(() => new RequestQueue({ concurrency: 0.5 })).toThrow( + TypeError + ); expect(() => new RequestQueue({ concurrency: 1 })).not.toThrow(); }); @@ -180,20 +215,32 @@ describe("RequestQueue", () => { // Block the queue with an initial task let releaseBlocker!: () => void; const blocker = queue.add( - (_s) => new Promise((r) => { releaseBlocker = r; }) + (_s) => + new Promise((r) => { + releaseBlocker = r; + }) ); // Enqueue out-of-priority order; high should win queue.add( - (_s) => Promise.resolve().then(() => { order.push("low"); }), + (_s) => + Promise.resolve().then(() => { + order.push("low"); + }), { priority: "low" } ); queue.add( - (_s) => Promise.resolve().then(() => { order.push("normal"); }), + (_s) => + Promise.resolve().then(() => { + order.push("normal"); + }), { priority: "normal" } ); queue.add( - (_s) => Promise.resolve().then(() => { order.push("high"); }), + (_s) => + Promise.resolve().then(() => { + order.push("high"); + }), { priority: "high" } ); @@ -209,7 +256,10 @@ describe("RequestQueue", () => { let releaseBlocker!: () => void; const blocker = queue.add( - (_s) => new Promise((r) => { releaseBlocker = r; }) + (_s) => + new Promise((r) => { + releaseBlocker = r; + }) ); const taskSpy = vi.fn((_s: AbortSignal) => Promise.resolve()); @@ -239,7 +289,9 @@ describe("RequestQueue", () => { queue.add((signal) => { capturedSignal = signal; - return new Promise((r) => { resolveTask = r; }); + return new Promise((r) => { + resolveTask = r; + }); }); // Task is running — obtain its ID from the active set @@ -265,12 +317,17 @@ describe("RequestQueue", () => { // Block the queue so the second task is queued let releaseBlocker!: () => void; const blocker = queue.add( - (_s) => new Promise((r) => { releaseBlocker = r; }) + (_s) => + new Promise((r) => { + releaseBlocker = r; + }) ); const controller = new AbortController(); const taskSpy = vi.fn((_s: AbortSignal) => Promise.resolve()); - const taskPromise = queue.add(taskSpy, { signal: controller.signal }); + const taskPromise = queue.add(taskSpy, { + signal: controller.signal, + }); // Abort via external signal before task starts controller.abort(); @@ -296,7 +353,12 @@ describe("RequestQueue", () => { const queue = new RequestQueue({ concurrency: 1 }); let resolveTask!: () => void; - queue.add((_s) => new Promise((r) => { resolveTask = r; })); + queue.add( + (_s) => + new Promise((r) => { + resolveTask = r; + }) + ); const [runningId] = queue._runningIds(); expect(queue.cancel(runningId)).toBe(true); diff --git a/tests/bytekit-cli.test.ts b/tests/bytekit-cli.test.ts index 6ce07d8..60f7775 100644 --- a/tests/bytekit-cli.test.ts +++ b/tests/bytekit-cli.test.ts @@ -156,7 +156,13 @@ describe("bytekit CLI Integration", () => { // Entity const entityFile = await fs.readFile( - path.join(tempDir, "product", "domain", "entities", "product.entity.ts"), + path.join( + tempDir, + "product", + "domain", + "entities", + "product.entity.ts" + ), "utf8" ); expect(entityFile).toContain("export class ProductEntity"); diff --git a/tests/ddd-boilerplate.test.ts b/tests/ddd-boilerplate.test.ts index 6bb2401..db16e54 100644 --- a/tests/ddd-boilerplate.test.ts +++ b/tests/ddd-boilerplate.test.ts @@ -50,7 +50,13 @@ describe("generateDddBoilerplate", () => { expect(slug).toBe("billing"); const inbound = await fs.readFile( - path.join(tmp, "application", "ports", "inbound", "billing-primary.port.ts"), + path.join( + tmp, + "application", + "ports", + "inbound", + "billing-primary.port.ts" + ), "utf8" ); expect(inbound).toContain("export interface BillingPrimaryPort"); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index 50d9ec9..ea2612b 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -1,4 +1,10 @@ -import { Pipeline, pipe, map, filter, reduce } from "../src/utils/async/pipeline"; +import { + Pipeline, + pipe, + map, + filter, + reduce, +} from "../src/utils/async/pipeline"; import { ApiClient } from "../src/utils/core/ApiClient"; // ============================================================================ @@ -21,17 +27,15 @@ test("US1 map transforms each element and preserves order", async () => { // T010: filter — sync, retains matches in order; empty array → [] test("US1 filter retains matching items in original order", async () => { - const result = await pipe( - filter((n) => n % 2 === 0) - ).process([1, 2, 3, 4, 5]); + const result = await pipe(filter((n) => n % 2 === 0)).process([ + 1, 2, 3, 4, 5, + ]); assert.deepEqual(result, [2, 4]); }); test("US1 filter on empty array returns []", async () => { - const result = await pipe( - filter((n) => n > 0) - ).process([]); + const result = await pipe(filter((n) => n > 0)).process([]); assert.deepEqual(result, []); }); @@ -72,8 +76,8 @@ test("US1 Pipeline is immutable — .pipe() does not mutate original", async () const baseResult = await base.process([1, -2, 3]); const extResult = await extended.process([1, -2, 3]); - assert.deepEqual(baseResult, [1, 3]); // base unchanged - assert.deepEqual(extResult, [10, 30]); // extended works independently + assert.deepEqual(baseResult, [1, 3]); // base unchanged + assert.deepEqual(extResult, [10, 30]); // extended works independently }); // T013: empty Pipeline returns input unchanged @@ -167,8 +171,8 @@ test("US2 reduce with async reducer accumulates sequentially", async () => { // T018: mixed sync and async ops test("US2 mixed sync and async ops execute correctly end-to-end", async () => { const result = await pipe( - filter((n) => n > 0), // sync - map(async (n) => String(n)), // async + filter((n) => n > 0), // sync + map(async (n) => String(n)), // async reduce(async (acc, s) => acc + s, "") // async ).process([1, -2, 3, -4, 5]); @@ -208,9 +212,7 @@ test("US2 error in reduce fn rejects process() with the original error", async ( // T020: .pipe(op) builder — chains, returns new instance, original unaffected test("US2 .pipe(op) builder chains additional op and returns new instance", async () => { - const base = pipe( - map((n) => n * 2) - ); + const base = pipe(map((n) => n * 2)); const chained = base.pipe(map((n) => `${n}!`)); assert.notStrictEqual(base, chained); // different instances @@ -242,9 +244,7 @@ test("US3 GET with pipeline transforms the response body", async () => { const client = makeMockClient([1, 2, 3]); const result = await client.get("/items", { - pipeline: pipe( - map((n) => String(n)) - ), + pipeline: pipe(map((n) => String(n))), }); assert.deepEqual(result, ["1", "2", "3"]); diff --git a/tests/request-queue-api-client.test.ts b/tests/request-queue-api-client.test.ts index 46e6a60..a8d9f0f 100644 --- a/tests/request-queue-api-client.test.ts +++ b/tests/request-queue-api-client.test.ts @@ -55,7 +55,9 @@ describe("ApiClient — RequestQueue & RequestBatcher integration (US4)", () => // Fire 10 concurrent requests await Promise.all( - Array.from({ length: 10 }, () => client.get<{ id: number }>("/items")) + Array.from({ length: 10 }, () => + client.get<{ id: number }>("/items") + ) ); expect(mock.getMaxConcurrent()).toBeLessThanOrEqual(3); @@ -123,7 +125,9 @@ describe("ApiClient — RequestQueue & RequestBatcher integration (US4)", () => }); await Promise.all( - Array.from({ length: 6 }, () => client.get<{ ok: boolean }>("/ping")) + Array.from({ length: 6 }, () => + client.get<{ ok: boolean }>("/ping") + ) ); expect(mock.getMaxConcurrent()).toBeLessThanOrEqual(2); diff --git a/tests/streaming-helper.test.ts b/tests/streaming-helper.test.ts index 238ed75..551a4f9 100644 --- a/tests/streaming-helper.test.ts +++ b/tests/streaming-helper.test.ts @@ -397,8 +397,8 @@ test("fetchSSE: basic GET with default event type ('message')", async () => { status: 200, statusText: "OK", body: createSSEStream([ - "data: {\"token\":\"hello\"}\n\n", - "data: {\"token\":\" world\"}\n\n", + 'data: {"token":"hello"}\n\n', + 'data: {"token":" world"}\n\n', ]), }); @@ -426,7 +426,7 @@ test("fetchSSE: POST with JSON body", async () => { ok: true, status: 200, statusText: "OK", - body: createSSEStream(["data: {\"ok\":true}\n\n"]), + body: createSSEStream(['data: {"ok":true}\n\n']), }; }; @@ -515,10 +515,10 @@ test("fetchSSE: multiple event types (data, log, heartbeat)", async () => { status: 200, statusText: "OK", body: createSSEStream([ - "event: data\ndata: {\"token\":\"hi\"}\n\n", - "event: log\ndata: {\"level\":\"info\"}\n\n", + 'event: data\ndata: {"token":"hi"}\n\n', + 'event: log\ndata: {"level":"info"}\n\n', "event: heartbeat\ndata: {}\n\n", - "event: data\ndata: {\"token\":\"bye\"}\n\n", + 'event: data\ndata: {"token":"bye"}\n\n', ]), }); @@ -544,10 +544,10 @@ test("fetchSSE: eventTypes filter restricts yielded events", async () => { status: 200, statusText: "OK", body: createSSEStream([ - "event: data\ndata: {\"token\":\"hi\"}\n\n", - "event: log\ndata: {\"msg\":\"debug info\"}\n\n", + 'event: data\ndata: {"token":"hi"}\n\n', + 'event: log\ndata: {"msg":"debug info"}\n\n', "event: heartbeat\ndata: {}\n\n", - "event: data\ndata: {\"token\":\"bye\"}\n\n", + 'event: data\ndata: {"token":"bye"}\n\n', ]), }); @@ -571,7 +571,7 @@ test("fetchSSE: parses id and retry fields", async () => { status: 200, statusText: "OK", body: createSSEStream([ - "id: evt-42\nretry: 5000\nevent: update\ndata: {\"v\":1}\n\n", + 'id: evt-42\nretry: 5000\nevent: update\ndata: {"v":1}\n\n', ]), }); @@ -595,7 +595,7 @@ test("fetchSSE: raw mode returns unparsed strings", async () => { ok: true, status: 200, statusText: "OK", - body: createSSEStream(["data: {\"token\":\"hi\"}\n\n"]), + body: createSSEStream(['data: {"token":"hi"}\n\n']), }); const events: SSEEvent[] = []; @@ -621,16 +621,13 @@ test("fetchSSE: throws on non-ok response", async () => { body: null, }); - await assert.rejects( - async () => { - for await (const _ev of StreamingHelper.fetchSSE( - "https://api.example.com/stream" - )) { - // should not reach here - } - }, - /status 401/ - ); + await assert.rejects(async () => { + for await (const _ev of StreamingHelper.fetchSSE( + "https://api.example.com/stream" + )) { + // should not reach here + } + }, /status 401/); globalThis.fetch = originalFetch; }); @@ -643,16 +640,13 @@ test("fetchSSE: throws on empty body", async () => { body: null, }); - await assert.rejects( - async () => { - for await (const _ev of StreamingHelper.fetchSSE( - "https://api.example.com/stream" - )) { - // should not reach here - } - }, - /Response body is empty/ - ); + await assert.rejects(async () => { + for await (const _ev of StreamingHelper.fetchSSE( + "https://api.example.com/stream" + )) { + // should not reach here + } + }, /Response body is empty/); globalThis.fetch = originalFetch; }); @@ -688,7 +682,7 @@ test("fetchSSE: skips SSE comment lines (starting with ':')", async () => { statusText: "OK", body: createSSEStream([ ": this is a keep-alive comment\n", - "data: {\"ok\":true}\n\n", + 'data: {"ok":true}\n\n', ]), }); diff --git a/tests/url-helper.test.ts b/tests/url-helper.test.ts index c154db6..899595d 100644 --- a/tests/url-helper.test.ts +++ b/tests/url-helper.test.ts @@ -24,31 +24,33 @@ describe("UrlHelper", () => { const qs = UrlHelper.stringify(params); expect(qs).toBe("a=1&b=2&b=3&c%5Bd%5D=4&f="); - expect( - UrlHelper.stringify(params, { arrayFormat: "bracket" }) - ).toBe("a=1&b%5B%5D=2&b%5B%5D=3&c%5Bd%5D=4&f="); + expect(UrlHelper.stringify(params, { arrayFormat: "bracket" })).toBe( + "a=1&b%5B%5D=2&b%5B%5D=3&c%5Bd%5D=4&f=" + ); - expect( - UrlHelper.stringify(params, { arrayFormat: "comma" }) - ).toBe("a=1&b=2%2C3&c%5Bd%5D=4&f="); + expect(UrlHelper.stringify(params, { arrayFormat: "comma" })).toBe( + "a=1&b=2%2C3&c%5Bd%5D=4&f=" + ); - expect( - UrlHelper.stringify(params, { skipEmptyString: true }) - ).toBe("a=1&b=2&b=3&c%5Bd%5D=4"); + expect(UrlHelper.stringify(params, { skipEmptyString: true })).toBe( + "a=1&b=2&b=3&c%5Bd%5D=4" + ); }); it("should return empty string with no params", () => { expect(UrlHelper.stringify(null)).toBe(""); expect(UrlHelper.stringify({})).toBe(""); }); - + describe("slugify", () => { it("should convert an object into an SEO string", () => { - const slug = UrlHelper.slugify({ - category: "Zapatos Deporte", - brand: ["Nike", "Adidas"] + const slug = UrlHelper.slugify({ + category: "Zapatos Deporte", + brand: ["Nike", "Adidas"], }); - expect(slug).toBe("brand-nike-brand-adidas-category-zapatos-deporte"); + expect(slug).toBe( + "brand-nike-brand-adidas-category-zapatos-deporte" + ); }); it("should handle raw strings correctly", () => { diff --git a/tests/websocket-helper.test.ts b/tests/websocket-helper.test.ts index c028d9b..99bbbde 100644 --- a/tests/websocket-helper.test.ts +++ b/tests/websocket-helper.test.ts @@ -347,7 +347,7 @@ test("US1 [T009] linear backoff — onReconnect receives correct (attempt, delay wsh.ws.close(); // fires onReconnect synchronously before setTimeout assert.equal(pairs.length, 1); - assert.equal(pairs[0][0], 1); // attempt 1 + assert.equal(pairs[0][0], 1); // attempt 1 assert.equal(pairs[0][1], 500); // delay = 500 × 1 wsh.close(); @@ -442,7 +442,7 @@ test("US1 [T012] custom backoffStrategy function — receives 1-based attempt, r wsh.ws.close(); assert.equal(customFn.mock.calls.length, 1); assert.equal(customFn.mock.calls[0][0], 1); // 1-based attempt - assert.equal(capturedDelay, 50); // 1 × 50 + assert.equal(capturedDelay, 50); // 1 × 50 wsh.close(); });