diff --git a/docs/plans/state.json b/docs/plans/state.json index b47a7f3..6551803 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -368,6 +368,20 @@ ], "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-runtime-and-loop-option-validation": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added constructor option validation guards for companion runtime utilities (`requestTimeoutMs > 0`, `intervalMs > 0`, and valid `maxConsecutiveFailures` bounds) with regression tests.", + "files_modified": [ + "src/companion/runtimeClient.ts", + "src/companion/runtimeClient.test.ts", + "src/companion/heartbeatLoop.ts", + "src/companion/heartbeatLoop.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/heartbeatLoop.test.ts b/src/companion/heartbeatLoop.test.ts index 3532cde..3f4928d 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -37,6 +37,16 @@ describe('CompanionHeartbeatLoop', () => { loop.stop(); }); + it('validates constructor options', () => { + const publisher = { publishHeartbeat: vi.fn(async () => buildStatusResult()) }; + expect(() => new CompanionHeartbeatLoop(publisher, { intervalMs: 0 })).toThrow( + 'intervalMs must be a positive number', + ); + expect(() => new CompanionHeartbeatLoop(publisher, { maxConsecutiveFailures: 0 })).toThrow( + 'maxConsecutiveFailures must be >= 1 when specified', + ); + }); + it('supports delayed first run when runImmediately=false', async () => { const publishHeartbeat = vi.fn(async () => buildStatusResult()); const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 500 }); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index 2e2c889..04c1469 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -31,11 +31,23 @@ export class CompanionHeartbeatLoop { private consecutiveFailures = 0; constructor(publisher: HeartbeatPublisher, options: CompanionHeartbeatLoopOptions = {}) { + const intervalMs = options.intervalMs ?? 30_000; + if (!Number.isFinite(intervalMs) || intervalMs <= 0) { + throw new Error('intervalMs must be a positive number'); + } + const maxConsecutiveFailures = options.maxConsecutiveFailures ?? Number.POSITIVE_INFINITY; + if (!Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures !== Number.POSITIVE_INFINITY) { + throw new Error('maxConsecutiveFailures must be a positive number or Infinity'); + } + if (Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures < 1) { + throw new Error('maxConsecutiveFailures must be >= 1 when specified'); + } + this.publisher = publisher; - this.intervalMs = options.intervalMs ?? 30_000; + this.intervalMs = intervalMs; this.buildHeartbeat = options.buildHeartbeat; this.onError = options.onError; - this.maxConsecutiveFailures = options.maxConsecutiveFailures ?? Number.POSITIVE_INFINITY; + this.maxConsecutiveFailures = maxConsecutiveFailures; this.onFailureLimitReached = options.onFailureLimitReached; } diff --git a/src/companion/runtimeClient.test.ts b/src/companion/runtimeClient.test.ts index f8a44b2..2fad71c 100644 --- a/src/companion/runtimeClient.test.ts +++ b/src/companion/runtimeClient.test.ts @@ -96,6 +96,15 @@ afterAll(async () => { }); describe('CompanionRuntimeClient', () => { + it('validates requestTimeoutMs option', () => { + expect(() => { + new CompanionRuntimeClient({ + url: 'ws://127.0.0.1:1', + requestTimeoutMs: 0, + }); + }).toThrow('requestTimeoutMs must be a positive number'); + }); + it('dispatches gateway events to subscribed handlers and supports unsubscribe', () => { const client = new CompanionRuntimeClient({ url: 'ws://127.0.0.1:1', diff --git a/src/companion/runtimeClient.ts b/src/companion/runtimeClient.ts index 321dec7..53c304e 100644 --- a/src/companion/runtimeClient.ts +++ b/src/companion/runtimeClient.ts @@ -270,9 +270,13 @@ export class CompanionRuntimeClient { private readonly eventHandlers = new Set(); constructor(options: CompanionRuntimeClientOptions) { + const requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) { + throw new Error('requestTimeoutMs must be a positive number'); + } this.url = options.url; this.token = options.token; - this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000; + this.requestTimeoutMs = requestTimeoutMs; this.autoConnect = options.autoConnect ?? false; this.websocketFactory = options.websocketFactory ?? ((url) => new WebSocket(url)); }