From 3c836ddfd5358db193f014c803e028fa891034e3 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 19:16:59 +0200 Subject: [PATCH 1/3] test: use specific length assertions Replace `expect(x.length).toBe(n)` with `expect(x).toHaveLength(n)` in the 64 places Sonar reports as typescript:S5906. Same semantics; a failing assertion now reports the collection instead of a bare number. Co-Authored-By: Claude Sonnet 5.5 --- .../arduino-simulator-codechange.test.tsx | 2 +- .../client/hooks/use-backend-health.test.tsx | 2 +- tests/client/hooks/use-toast.test.ts | 10 ++++----- .../output-panel-auto-behavior.test.tsx | 2 +- tests/client/output-panel.test.tsx | 2 +- tests/client/serial-monitor.ui.test.tsx | 2 +- .../utils/serial-character-renderer.test.ts | 2 +- tests/client/websocket-manager.test.ts | 22 +++++++++---------- tests/core/sandbox-stress.test.ts | 4 ++-- tests/server/cache-optimization.test.ts | 2 +- tests/server/cli-label-isolation.test.ts | 4 ++-- tests/server/control-characters.test.ts | 2 +- tests/server/load-suite.test.ts | 2 +- .../routes/serial-output-batching.test.ts | 14 ++++++------ .../arduino-compiler-parser-messages.test.ts | 4 ++-- .../parser-messages-integration.test.ts | 4 ++-- .../registry-manager-telemetry.test.ts | 4 ++-- .../services/serial-backpressure.test.ts | 2 +- .../services/serial-output-batcher.test.ts | 4 ++-- .../services/serial-stop-pause-resume.test.ts | 12 +++++----- .../unified-gatekeeper-performance.test.ts | 8 +++---- tests/server/storage.test.ts | 2 +- tests/server/telemetry-integration.test.ts | 4 ++-- .../server/websocket-telemetry-format.test.ts | 2 +- tests/shared/reserved-names-validator.test.ts | 10 ++++----- 25 files changed, 64 insertions(+), 64 deletions(-) diff --git a/tests/client/arduino-simulator-codechange.test.tsx b/tests/client/arduino-simulator-codechange.test.tsx index dec66014d..0cc8b368d 100644 --- a/tests/client/arduino-simulator-codechange.test.tsx +++ b/tests/client/arduino-simulator-codechange.test.tsx @@ -76,7 +76,7 @@ test("handles simulation_status message", async () => { // Sanity-check: hook exposes empty queue initially const wsMock = (await import("@/hooks/use-websocket")).useWebSocket(); - expect(wsMock.messageQueue.length).toBe(0); + expect(wsMock.messageQueue).toHaveLength(0); // Push message AFTER mount and trigger re-render in act() await act(async () => { diff --git a/tests/client/hooks/use-backend-health.test.tsx b/tests/client/hooks/use-backend-health.test.tsx index a1ac5002e..6da059621 100644 --- a/tests/client/hooks/use-backend-health.test.tsx +++ b/tests/client/hooks/use-backend-health.test.tsx @@ -451,7 +451,7 @@ describe("useBackendHealth", () => { }); // No new calls after unmount - expect(fetchSpy.mock.calls.length).toBe(callsBefore); + expect(fetchSpy.mock.calls).toHaveLength(callsBefore); }); it("should not update state after unmount", async () => { diff --git a/tests/client/hooks/use-toast.test.ts b/tests/client/hooks/use-toast.test.ts index c3966aa2d..e0cfdb502 100644 --- a/tests/client/hooks/use-toast.test.ts +++ b/tests/client/hooks/use-toast.test.ts @@ -57,7 +57,7 @@ describe("use-toast", () => { expect(t).toHaveProperty("update"); }); - expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts).toHaveLength(1); expect(result.current.toasts[0].title).toBe("Hello"); expect(result.current.toasts[0].open).toBe(true); }); @@ -87,7 +87,7 @@ describe("use-toast", () => { }); // TOAST_LIMIT = 1, so only the newest toast should be present - expect(result.current.toasts.length).toBe(1); + expect(result.current.toasts).toHaveLength(1); expect(result.current.toasts[0].title).toBe("Second"); }); @@ -138,7 +138,7 @@ describe("use-toast", () => { vi.advanceTimersByTime(4000); }); - expect(result.current.toasts.length).toBe(0); + expect(result.current.toasts).toHaveLength(0); }); it("dismiss without toastId dismisses all toasts", () => { @@ -173,7 +173,7 @@ describe("use-toast", () => { vi.advanceTimersByTime(4000); }); - expect(result.current.toasts.length).toBe(0); + expect(result.current.toasts).toHaveLength(0); }); it("onOpenChange(false) triggers dismiss", () => { @@ -255,6 +255,6 @@ describe("use-toast", () => { vi.advanceTimersByTime(4000); }); - expect(result.current.toasts.length).toBe(0); + expect(result.current.toasts).toHaveLength(0); }); }); diff --git a/tests/client/output-panel-auto-behavior.test.tsx b/tests/client/output-panel-auto-behavior.test.tsx index 8f08ad133..a91e6be15 100644 --- a/tests/client/output-panel-auto-behavior.test.tsx +++ b/tests/client/output-panel-auto-behavior.test.tsx @@ -380,7 +380,7 @@ error: missing semicolon on line 5`; // Verify the condition logic expect(successCondition.lastCompilationResult).toBe("success"); expect(successCondition.hasCompilationErrors).toBe(false); - expect(successCondition.parserMessages.length).toBe(0); + expect(successCondition.parserMessages).toHaveLength(0); // Panel should be minimized to 3% const shouldMinimize = diff --git a/tests/client/output-panel.test.tsx b/tests/client/output-panel.test.tsx index a29d85e82..e677d623c 100644 --- a/tests/client/output-panel.test.tsx +++ b/tests/client/output-panel.test.tsx @@ -103,7 +103,7 @@ describe("OutputPanel — callback reference stability", () => { return new Promise((resolve) => { requestAnimationFrame(() => { observer.disconnect(); - expect(records.length).toBe(0); + expect(records).toHaveLength(0); resolve(); }); }); diff --git a/tests/client/serial-monitor.ui.test.tsx b/tests/client/serial-monitor.ui.test.tsx index a40f1a978..6dc6fc2fd 100644 --- a/tests/client/serial-monitor.ui.test.tsx +++ b/tests/client/serial-monitor.ui.test.tsx @@ -417,7 +417,7 @@ describe("applyBackspaceAcrossLines", () => { const lines: Array<{ text: string; incomplete: boolean }> = []; const result = applyBackspaceAcrossLines(lines, "\b\bHello", true); expect(result).toBe("\b\bHello"); - expect(lines.length).toBe(0); + expect(lines).toHaveLength(0); }); it("processes backspaces in middle of text via processAnsiCodes", () => { diff --git a/tests/client/utils/serial-character-renderer.test.ts b/tests/client/utils/serial-character-renderer.test.ts index 8d710e322..be482a3e0 100644 --- a/tests/client/utils/serial-character-renderer.test.ts +++ b/tests/client/utils/serial-character-renderer.test.ts @@ -232,7 +232,7 @@ describe("SerialCharacterRenderer - Unit Tests", () => { vi.runAllTimers(); // Should render all due to high baudrate batching - expect(capturedOutput.join("").length).toBe(10000); + expect(capturedOutput.join("")).toHaveLength(10000); }); it("should not crash when cleared multiple times", () => { diff --git a/tests/client/websocket-manager.test.ts b/tests/client/websocket-manager.test.ts index d3a670b72..8dd0266cd 100644 --- a/tests/client/websocket-manager.test.ts +++ b/tests/client/websocket-manager.test.ts @@ -125,7 +125,7 @@ describe("WebSocketManager", () => { it("creates a WebSocket on connect()", () => { const mgr = getWebSocketManager(); mgr.connect(); - expect(wsInstances.length).toBe(1); + expect(wsInstances).toHaveLength(1); expect(wsInstances[0].url).toBe("ws://localhost:3000/ws"); expect(mgr.getState()).toBe("connecting"); }); @@ -135,7 +135,7 @@ describe("WebSocketManager", () => { mgr.connect(); wsInstances[0]._simulateOpen(); mgr.connect(); // should be no-op - expect(wsInstances.length).toBe(1); + expect(wsInstances).toHaveLength(1); }); it("handles successful connection", () => { @@ -163,11 +163,11 @@ describe("WebSocketManager", () => { expect(result).toBe(true); // Not sent yet (buffered) - expect(wsInstances[0].sentMessages.length).toBe(0); + expect(wsInstances[0].sentMessages).toHaveLength(0); // Advance past buffer flush interval (30ms) vi.advanceTimersByTime(35); - expect(wsInstances[0].sentMessages.length).toBe(1); + expect(wsInstances[0].sentMessages).toHaveLength(1); expect(JSON.parse(wsInstances[0].sentMessages[0])).toEqual(msg); }); @@ -182,7 +182,7 @@ describe("WebSocketManager", () => { } // Should be flushed immediately - expect(wsInstances[0].sentMessages.length).toBe(100); + expect(wsInstances[0].sentMessages).toHaveLength(100); }); it("returns false when WebSocket is not open", () => { @@ -202,7 +202,7 @@ describe("WebSocketManager", () => { const msg = { type: "serial_input" as const, data: "urgent" }; const result = mgr.sendImmediate(msg); expect(result).toBe(true); - expect(wsInstances[0].sentMessages.length).toBe(1); + expect(wsInstances[0].sentMessages).toHaveLength(1); }); it("sendImmediate returns false when not connected", () => { @@ -221,7 +221,7 @@ describe("WebSocketManager", () => { wsInstances[0]._simulateOpen(); wsInstances[0]._simulateMessage({ type: "serial_output", data: "hello" }); - expect(received.length).toBe(1); + expect(received).toHaveLength(1); expect((received[0] as any).type).toBe("serial_output"); }); @@ -292,7 +292,7 @@ describe("WebSocketManager", () => { // Advance timers to trigger reconnect vi.advanceTimersByTime(2000); - expect(wsInstances.length).toBe(2); // new WS created + expect(wsInstances).toHaveLength(2); // new WS created }); it("does not reconnect after explicit disconnect", () => { @@ -304,7 +304,7 @@ describe("WebSocketManager", () => { // Close should not trigger reconnect expect(mgr.getState()).toBe("disconnected"); vi.advanceTimersByTime(60000); - expect(wsInstances.length).toBe(1); // no new WS + expect(wsInstances).toHaveLength(1); // no new WS }); it("retries indefinitely – does not give up after many reconnect failures", () => { @@ -361,7 +361,7 @@ describe("WebSocketManager", () => { mgr.disconnect(); vi.advanceTimersByTime(50); // No error thrown, messages dropped - expect(wsInstances[0].sentMessages.length).toBe(0); + expect(wsInstances[0].sentMessages).toHaveLength(0); }); // ---- Error in event handler ---- @@ -385,7 +385,7 @@ describe("WebSocketManager", () => { mgr.send({ type: "serial_input" as const, data: "c" }); vi.advanceTimersByTime(35); - expect(wsInstances[0].sentMessages.length).toBe(3); + expect(wsInstances[0].sentMessages).toHaveLength(3); }); // ---- sendImmediate error handling ---- diff --git a/tests/core/sandbox-stress.test.ts b/tests/core/sandbox-stress.test.ts index 9c37899cf..efb0bc15d 100644 --- a/tests/core/sandbox-stress.test.ts +++ b/tests/core/sandbox-stress.test.ts @@ -365,7 +365,7 @@ void loop() { // State errors acceptable in local mode due to timing differences if (dockerAvailable) { - expect(stateErrors.length).toBe(0); // No state transition errors + expect(stateErrors).toHaveLength(0); // No state transition errors expect(successfulPauses).toBeGreaterThan(15); // Most pauses should succeed expect(successfulResumes).toBeGreaterThan(15); // Most resumes should succeed expect(tickCount).toBeGreaterThan(10); // Some execution happened @@ -594,7 +594,7 @@ void loop() { return !e.endsWith(".cleanup") && existsSync(join(fullPath, "sketch.ino")); }); - expect(remainingSketchDirs.length).toBe(0); // All sketch dirs should be cleaned/renamed + expect(remainingSketchDirs).toHaveLength(0); // All sketch dirs should be cleaned/renamed }, 120000); // Needs time for 2 real Arduino compilations in parallel it("should cleanup temp directory after rapid start/stop cycles", async () => { diff --git a/tests/server/cache-optimization.test.ts b/tests/server/cache-optimization.test.ts index e5ec99572..adf87fd52 100644 --- a/tests/server/cache-optimization.test.ts +++ b/tests/server/cache-optimization.test.ts @@ -203,7 +203,7 @@ void loop() { delay(100); } expect(resultDiff.cached).toBe(false); // Different code → miss // Validate cache hits were returned consistently - expect(cacheHitTimes.length).toBe(5); + expect(cacheHitTimes).toHaveLength(5); }, 10000); it("should cache properly with identical headers", async () => { diff --git a/tests/server/cli-label-isolation.test.ts b/tests/server/cli-label-isolation.test.ts index 822f91c38..de30d9c44 100644 --- a/tests/server/cli-label-isolation.test.ts +++ b/tests/server/cli-label-isolation.test.ts @@ -145,8 +145,8 @@ describe("CLI Label Session Isolation", () => { const session1Hashes = sessionCompilations.get("session-1") || []; const session2Hashes = sessionCompilations.get("session-2") || []; - expect(session1Hashes.length).toBe(1); - expect(session2Hashes.length).toBe(1); + expect(session1Hashes).toHaveLength(1); + expect(session2Hashes).toHaveLength(1); expect(session1Hashes[0]).not.toBe(session2Hashes[0]); expect(result1.cached).toBe(false); diff --git a/tests/server/control-characters.test.ts b/tests/server/control-characters.test.ts index 73c8cf0cf..bcd9b7d95 100644 --- a/tests/server/control-characters.test.ts +++ b/tests/server/control-characters.test.ts @@ -132,7 +132,7 @@ describe("Control Characters Examples and Handling", () => { // After first chunk, X is still there expect(snapshots[0]).toBe("X"); // Simulate "waiting" - snapshots don't change without new chunks - expect(snapshots.length).toBe(1); + expect(snapshots).toHaveLength(1); }); it("Phase 3: backspace+Y replaces X with Y at same position", () => { diff --git a/tests/server/load-suite.test.ts b/tests/server/load-suite.test.ts index e73f21708..e9b9dab55 100644 --- a/tests/server/load-suite.test.ts +++ b/tests/server/load-suite.test.ts @@ -431,7 +431,7 @@ function createLoadTestSuite( testResults.push(stats); } - expect(testSizes.length).toBe(5); + expect(testSizes).toHaveLength(5); }, 240000, ); diff --git a/tests/server/routes/serial-output-batching.test.ts b/tests/server/routes/serial-output-batching.test.ts index 52af9d1c1..b08a47451 100644 --- a/tests/server/routes/serial-output-batching.test.ts +++ b/tests/server/routes/serial-output-batching.test.ts @@ -61,11 +61,11 @@ describe("serial_output WebSocket Batching (WsOutputBuffer)", () => { send("World", true); send("Test", true); - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); vi.advanceTimersByTime(50); // All lines have newlines after them (newline AFTER every complete line) - expect(messages.length).toBe(1); + expect(messages).toHaveLength(1); expect(messages[0].type).toBe("serial_output"); expect(messages[0].data).toBe("Hello\nWorld\nTest\n"); expect(messages[0].isComplete).toBe(true); @@ -78,14 +78,14 @@ describe("serial_output WebSocket Batching (WsOutputBuffer)", () => { send("Line2", true); vi.advanceTimersByTime(50); - expect(messages.length).toBe(1); + expect(messages).toHaveLength(1); expect(messages[0].data).toBe("Line1\nLine2\n"); expect(messages[0].isComplete).toBe(true); send("Line3", true); vi.advanceTimersByTime(50); - expect(messages.length).toBe(2); + expect(messages).toHaveLength(2); expect(messages[1].data).toBe("Line3\n"); expect(messages[1].isComplete).toBe(true); }); @@ -100,7 +100,7 @@ describe("serial_output WebSocket Batching (WsOutputBuffer)", () => { vi.advanceTimersByTime(50); // Incomplete lines don't get newlines, complete ones do - expect(messages.length).toBe(1); + expect(messages).toHaveLength(1); expect(messages[0].data).toBe("Partial Output\n"); expect(messages[0].isComplete).toBe(true); }); @@ -113,7 +113,7 @@ describe("serial_output WebSocket Batching (WsOutputBuffer)", () => { vi.advanceTimersByTime(50); // Incomplete line has no newline - expect(messages.length).toBe(1); + expect(messages).toHaveLength(1); expect(messages[0].data).toBe("Still typing"); expect(messages[0].isComplete).toBe(false); }); @@ -127,7 +127,7 @@ describe("serial_output WebSocket Batching (WsOutputBuffer)", () => { vi.advanceTimersByTime(50); - expect(messages.length).toBe(1); + expect(messages).toHaveLength(1); let expectedData = ""; for (let i = 0; i < 10; i++) { expectedData += `Line${i}\n`; diff --git a/tests/server/services/arduino-compiler-parser-messages.test.ts b/tests/server/services/arduino-compiler-parser-messages.test.ts index de80a4129..36855ca62 100644 --- a/tests/server/services/arduino-compiler-parser-messages.test.ts +++ b/tests/server/services/arduino-compiler-parser-messages.test.ts @@ -188,7 +188,7 @@ describe("ArduinoCompiler - Parser Messages Tests", () => { const serialMessages = (result.parserMessages ?? []).filter( (m) => m.category === "serial", ); - expect(serialMessages.length).toBe(0); + expect(serialMessages).toHaveLength(0); // Output is clean (only compiler info) expect(result.output).toContain("Board: Arduino UNO"); @@ -273,7 +273,7 @@ describe("ArduinoCompiler - Parser Messages Tests", () => { const serialMessages = (result.parserMessages ?? []).filter( (m) => m.category === "serial", ); - expect(serialMessages.length).toBe(0); + expect(serialMessages).toHaveLength(0); // Output is clean expect(result.output).toContain("Board: Arduino UNO"); diff --git a/tests/server/services/parser-messages-integration.test.ts b/tests/server/services/parser-messages-integration.test.ts index 13d059948..168718764 100644 --- a/tests/server/services/parser-messages-integration.test.ts +++ b/tests/server/services/parser-messages-integration.test.ts @@ -140,7 +140,7 @@ describe("Parser Messages Integration", () => { // Should have NO Serial messages const messages = result.parserMessages as ParserMessage[]; const serialMessages = messages.filter((m) => m.category === "serial"); - expect(serialMessages.length).toBe(0); + expect(serialMessages).toHaveLength(0); // Should NOT be in output expect(result.output).not.toContain("Serial.begin"); @@ -208,7 +208,7 @@ describe("Parser Messages Integration", () => { // Should have NO Serial warnings const messages = result.parserMessages as ParserMessage[]; const serialMessages = messages.filter((m) => m.category === "serial"); - expect(serialMessages.length).toBe(0); + expect(serialMessages).toHaveLength(0); // Should NOT be in output expect(result.output).not.toContain("Serial.begin"); diff --git a/tests/server/services/registry-manager-telemetry.test.ts b/tests/server/services/registry-manager-telemetry.test.ts index eb1272e27..416423e08 100644 --- a/tests/server/services/registry-manager-telemetry.test.ts +++ b/tests/server/services/registry-manager-telemetry.test.ts @@ -283,7 +283,7 @@ describe("RegistryManager - Telemetry Metrics", () => { vi.advanceTimersByTime(3000); - expect(callbacks.length).toBe(0); + expect(callbacks).toHaveLength(0); localManager.destroy(); }); @@ -306,7 +306,7 @@ describe("RegistryManager - Telemetry Metrics", () => { localManager.pauseTelemetry(); vi.advanceTimersByTime(2000); - expect(callbacks.length).toBe(beforePauseCount); + expect(callbacks).toHaveLength(beforePauseCount); localManager.destroy(); }); diff --git a/tests/server/services/serial-backpressure.test.ts b/tests/server/services/serial-backpressure.test.ts index dd0a005a6..fc0336776 100644 --- a/tests/server/services/serial-backpressure.test.ts +++ b/tests/server/services/serial-backpressure.test.ts @@ -101,7 +101,7 @@ void loop() { const lineMatches = output.match(/Line \d{6}/g) || []; log(`[T-BP-01] Lines received: ${lineMatches.length}`); - expect(lineMatches.length).toBe(10); + expect(lineMatches).toHaveLength(10); // Extract timing deltas const deltas: number[] = []; diff --git a/tests/server/services/serial-output-batcher.test.ts b/tests/server/services/serial-output-batcher.test.ts index f86c7a7c7..8a4f30e54 100644 --- a/tests/server/services/serial-output-batcher.test.ts +++ b/tests/server/services/serial-output-batcher.test.ts @@ -82,7 +82,7 @@ describe("SerialOutputBatcher", () => { expect(chunks).toHaveLength(1); expect(chunks[0]).toBe(largeData); - expect(chunks[0].length).toBe(2001); + expect(chunks[0]).toHaveLength(2001); }); it("T04: Pause stoppt Timer, Resume startet Timer neu", () => { @@ -615,7 +615,7 @@ describe("SerialOutputBatcher", () => { // Collect all chunks and verify total output const totalOutput = chunks.join(""); expect(totalOutput).toBe(message); - expect(totalOutput.length).toBe(13); + expect(totalOutput).toHaveLength(13); }); it("T25: Multiple consecutive messages at low baudrate should all arrive", () => { diff --git a/tests/server/services/serial-stop-pause-resume.test.ts b/tests/server/services/serial-stop-pause-resume.test.ts index 2c167a1c3..a20452d12 100644 --- a/tests/server/services/serial-stop-pause-resume.test.ts +++ b/tests/server/services/serial-stop-pause-resume.test.ts @@ -55,7 +55,7 @@ describe('Serial Output Stop/Pause/Resume', () => { // After destroy, no more data should arrive even after many ticks vi.advanceTimersByTime(5000); - expect(chunks.length).toBe(chunksBeforeStop); + expect(chunks).toHaveLength(chunksBeforeStop); }); it('T-SPR-02: destroy() completes in <1ms (no flush delay)', () => { @@ -79,7 +79,7 @@ describe('Serial Output Stop/Pause/Resume', () => { vi.advanceTimersByTime(10000); const chunksAfter = chunks.length; vi.advanceTimersByTime(10000); - expect(chunks.length).toBe(chunksAfter); + expect(chunks).toHaveLength(chunksAfter); }); it('T-SPR-03: stop() flushes remaining data (natural completion)', () => { @@ -111,7 +111,7 @@ describe('Serial Output Stop/Pause/Resume', () => { // Enqueue after destroy should not cause any output batcher.enqueue('Should not appear\n'); vi.advanceTimersByTime(1000); - expect(chunks.length).toBe(0); + expect(chunks).toHaveLength(0); }); }); @@ -140,7 +140,7 @@ describe('Serial Output Stop/Pause/Resume', () => { // No more output after pause, even after many ticks vi.advanceTimersByTime(5000); - expect(chunks.length).toBe(chunksBeforePause); + expect(chunks).toHaveLength(chunksBeforePause); // Buffer should still hold remaining data const telemetry = batcher.getTelemetryAndReset(); @@ -218,7 +218,7 @@ describe('Serial Output Stop/Pause/Resume', () => { // No output during pause vi.advanceTimersByTime(500); - expect(chunks.length).toBe(0); + expect(chunks).toHaveLength(0); // Resume batcher.resume(); @@ -244,7 +244,7 @@ describe('Serial Output Stop/Pause/Resume', () => { batcher.enqueue('During pause\n'); vi.advanceTimersByTime(500); - expect(chunks.length).toBe(0); // nothing sent during pause + expect(chunks).toHaveLength(0); // nothing sent during pause // Resume batcher.resume(); diff --git a/tests/server/services/unified-gatekeeper-performance.test.ts b/tests/server/services/unified-gatekeeper-performance.test.ts index 65e457b3f..3a968eb91 100644 --- a/tests/server/services/unified-gatekeeper-performance.test.ts +++ b/tests/server/services/unified-gatekeeper-performance.test.ts @@ -45,7 +45,7 @@ describe("UnifiedGatekeeper - Performance Benchmarks", () => { // With event-driven approach, this should complete in ~100-500ms // With polling (25ms intervals), this would take much longer expect(elapsedMs).toBeLessThan(1000); - expect(readers.length).toBe(numWaiters); + expect(readers).toHaveLength(numWaiters); // Cleanup for (const reader of readers) { @@ -90,7 +90,7 @@ describe("UnifiedGatekeeper - Performance Benchmarks", () => { await write1(); const readers1 = await Promise.all(waitersKey1); - expect(readers1.length).toBe(50); + expect(readers1).toHaveLength(50); // key2 waiters should still be waiting let key2Resolved = false; @@ -104,7 +104,7 @@ describe("UnifiedGatekeeper - Performance Benchmarks", () => { // Release key2 await write2(); const readers2 = await Promise.all(waitersKey2); - expect(readers2.length).toBe(50); + expect(readers2).toHaveLength(50); // Cleanup for (const r of readers1) await r(); @@ -228,7 +228,7 @@ describe("UnifiedGatekeeper - Performance Benchmarks", () => { // Event-driven notification should be nearly instant // Polling would require multiple 25ms cycles expect(notificationTime).toBeLessThan(1000); - expect(readers.length).toBe(500); + expect(readers).toHaveLength(500); // Cleanup for (const r of readers) await r(); diff --git a/tests/server/storage.test.ts b/tests/server/storage.test.ts index 018901f5c..07572af95 100644 --- a/tests/server/storage.test.ts +++ b/tests/server/storage.test.ts @@ -11,7 +11,7 @@ describe("DefaultSketchStore", () => { describe("constructor", () => { it("should initialize with a default sketch", async () => { const sketches = await storage.getAllSketches(); - expect(sketches.length).toBe(1); + expect(sketches).toHaveLength(1); expect(sketches[0].name).toBe("sketch.ino"); expect(sketches[0].content).toContain("void setup()"); expect(sketches[0].content).toContain("void loop()"); diff --git a/tests/server/telemetry-integration.test.ts b/tests/server/telemetry-integration.test.ts index 40a30db44..78113f128 100644 --- a/tests/server/telemetry-integration.test.ts +++ b/tests/server/telemetry-integration.test.ts @@ -327,7 +327,7 @@ describe("Telemetry - E2E Integration Pipeline", () => { } const history = client.getHistory(); - expect(history.length).toBe(3); + expect(history).toHaveLength(3); // Verify timestamps are increasing for (let i = 1; i < history.length; i++) { @@ -476,7 +476,7 @@ describe("Telemetry - E2E Integration Pipeline", () => { // Verify history captured all bursts const history = client.getHistory(); - expect(history.length).toBe(3); + expect(history).toHaveLength(3); expect(history[0].pinChangesPerSecond).toBe(30); expect(history[0].serialOutputPerSecond).toBe(20); expect(history[1].pinChangesPerSecond).toBe(2); diff --git a/tests/server/websocket-telemetry-format.test.ts b/tests/server/websocket-telemetry-format.test.ts index a3bc9a585..641111234 100644 --- a/tests/server/websocket-telemetry-format.test.ts +++ b/tests/server/websocket-telemetry-format.test.ts @@ -76,7 +76,7 @@ describe('WebSocket Telemetry Message Format', () => { pushTelemetry(incomingMessage.metrics); // Verify frontend state would update - expect(telemetryHistory.length).toBe(1); + expect(telemetryHistory).toHaveLength(1); expect(lastHeartbeatAt).toBeDefined(); expect(lastHeartbeatAt && lastHeartbeatAt > 0).toBe(true); diff --git a/tests/shared/reserved-names-validator.test.ts b/tests/shared/reserved-names-validator.test.ts index ee737d5f4..2e7b3d26a 100644 --- a/tests/shared/reserved-names-validator.test.ts +++ b/tests/shared/reserved-names-validator.test.ts @@ -99,7 +99,7 @@ void setup() { void loop() { }`; const messages = reservedNamesValidator.validateReservedNames(code); - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); }); it("should allow setup and loop function names", () => { @@ -266,7 +266,7 @@ void loop() { it("should handle empty code", () => { const code = ""; const messages = reservedNamesValidator.validateReservedNames(code); - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); }); it("should handle code with only comments", () => { @@ -275,7 +275,7 @@ void loop() { /* and another pause */ `; const messages = reservedNamesValidator.validateReservedNames(code); - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); }); it("should handle multiline block comments", () => { @@ -292,7 +292,7 @@ void setup() { void loop() { }`; const messages = reservedNamesValidator.validateReservedNames(code); - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); }); it("should handle unterminated block comment gracefully", () => { @@ -305,7 +305,7 @@ void loop() { }`; const messages = reservedNamesValidator.validateReservedNames(code); // Everything after /* is treated as comment, so no reserved name detected - expect(messages.length).toBe(0); + expect(messages).toHaveLength(0); }); }); }); From b72b5cd6bd552c136a282f19fdf97ccd992ff6c3 Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 19:19:27 +0200 Subject: [PATCH 2/3] test: simplify maintainability findings Address the remaining Sonar test findings where the change is also an improvement: - font-scale-utils: the increase and decrease transitions are plain data tables; use it.each with the original titles (typescript:S5976). - arduino-output-parser: the four uniform "ignore protocol fragment" cases become one it.each (typescript:S5976). - logger sanitization: two tables, one with input / marker / leaked plaintext (secret must be replaced and not logged), one with input / marker; every case keeps its title (typescript:S5976). - arduino-simulator-codechange: drop the redundant act() around waitFor, which already flushes its own updates (typescript:S8980). parser-output-pinmode: Sonar's S5976 groups were not worth parametrizing. The tests exercised a local copy of the legacy operations -> mode logic, a local counting loop and a regex literal, none of which reaches production code. The legacy fallback in IoRegistryRow (usedAt "pinMode:N" when pinModeModes is absent) is still live, so replace them with tests that render ParserOutput with legacy records and assert the displayed modes, the conflict marker and the pinModeModes precedence. Confirmed they fail when the fallback regex is broken. serial-monitor.ui is intentionally left as is: its three tests share only the setup and assert different things. Co-Authored-By: Claude Sonnet 5.5 --- .../arduino-simulator-codechange.test.tsx | 8 +- tests/client/font-scale-utils.test.ts | 100 ++------- tests/client/parser-output-pinmode.test.tsx | 204 ++++-------------- .../services/arduino-output-parser.test.ts | 24 +-- tests/shared/logger.test.ts | 146 +++++-------- 5 files changed, 125 insertions(+), 357 deletions(-) diff --git a/tests/client/arduino-simulator-codechange.test.tsx b/tests/client/arduino-simulator-codechange.test.tsx index 0cc8b368d..496f60896 100644 --- a/tests/client/arduino-simulator-codechange.test.tsx +++ b/tests/client/arduino-simulator-codechange.test.tsx @@ -98,11 +98,9 @@ test("handles simulation_status message", async () => { vi.runOnlyPendingTimers(); }); - // Test the assertion - await act(async () => { - await waitFor(() => { - expect(document.querySelector('[data-testid="sim-status"]')?.textContent).toBe("running"); - }); + // waitFor flushes its own updates; no surrounding act() needed + await waitFor(() => { + expect(document.querySelector('[data-testid="sim-status"]')?.textContent).toBe("running"); }); // Final timer flush diff --git a/tests/client/font-scale-utils.test.ts b/tests/client/font-scale-utils.test.ts index 35cd7b89f..ba1fc0615 100644 --- a/tests/client/font-scale-utils.test.ts +++ b/tests/client/font-scale-utils.test.ts @@ -110,90 +110,34 @@ describe("Font Scale Utils", () => { }); describe("increaseFontScale", () => { - it("should increase from S to M", () => { - setFontScale(0.875); // S + it.each([ + { title: "should increase from S to M", from: 0.875, changed: true, to: 1.0 }, + { title: "should increase from M to L", from: 1.0, changed: true, to: 1.125 }, + { title: "should increase from L to XL", from: 1.125, changed: true, to: 1.25 }, + { title: "should increase from XL to XXL", from: 1.25, changed: true, to: 1.5 }, + { title: "should not increase beyond XXL", from: 1.5, changed: false, to: 1.5 }, + { title: "should handle custom scale values (0.9 jumps to M)", from: 0.9, changed: true, to: 1.0 }, + ])("$title", ({ from, changed, to }) => { + setFontScale(from); const result = increaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.0); // M - }); - - it("should increase from M to L", () => { - setFontScale(1.0); // M - const result = increaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.125); // L - }); - - it("should increase from L to XL", () => { - setFontScale(1.125); // L - const result = increaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.25); // XL - }); - - it("should increase from XL to XXL", () => { - setFontScale(1.25); // XL - const result = increaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.5); // XXL - }); - - it("should not increase beyond XXL", () => { - setFontScale(1.5); // XXL - const result = increaseFontScale(); - expect(result).toBe(false); - expect(getCurrentFontScale()).toBe(1.5); // Still XXL - }); - - it("should handle custom scale values", () => { - setFontScale(0.9); // Between S and M - const result = increaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.0); // Jumps to M + expect(result).toBe(changed); + expect(getCurrentFontScale()).toBe(to); }); }); describe("decreaseFontScale", () => { - it("should decrease from XXL to XL", () => { - setFontScale(1.5); // XXL - const result = decreaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.25); // XL - }); - - it("should decrease from XL to L", () => { - setFontScale(1.25); // XL - const result = decreaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.125); // L - }); - - it("should decrease from L to M", () => { - setFontScale(1.125); // L - const result = decreaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.0); // M - }); - - it("should decrease from M to S", () => { - setFontScale(1.0); // M - const result = decreaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(0.875); // S - }); - - it("should not decrease below S", () => { - setFontScale(0.875); // S - const result = decreaseFontScale(); - expect(result).toBe(false); - expect(getCurrentFontScale()).toBe(0.875); // Still S - }); - - it("should handle custom scale values", () => { - setFontScale(1.1); // Between M and L + it.each([ + { title: "should decrease from XXL to XL", from: 1.5, changed: true, to: 1.25 }, + { title: "should decrease from XL to L", from: 1.25, changed: true, to: 1.125 }, + { title: "should decrease from L to M", from: 1.125, changed: true, to: 1.0 }, + { title: "should decrease from M to S", from: 1.0, changed: true, to: 0.875 }, + { title: "should not decrease below S", from: 0.875, changed: false, to: 0.875 }, + { title: "should handle custom scale values (1.1 jumps to M)", from: 1.1, changed: true, to: 1.0 }, + ])("$title", ({ from, changed, to }) => { + setFontScale(from); const result = decreaseFontScale(); - expect(result).toBe(true); - expect(getCurrentFontScale()).toBe(1.0); // Jumps to M + expect(result).toBe(changed); + expect(getCurrentFontScale()).toBe(to); }); }); diff --git a/tests/client/parser-output-pinmode.test.tsx b/tests/client/parser-output-pinmode.test.tsx index cea0065f4..d5642ab94 100644 --- a/tests/client/parser-output-pinmode.test.tsx +++ b/tests/client/parser-output-pinmode.test.tsx @@ -5,189 +5,59 @@ import type { SourceLocation } from "@shared/source-project"; import userEvent from "@testing-library/user-event"; import { describe, it, expect, vi } from "vitest"; -// Helper function to extract pinMode data (extracted from parser-output.tsx) -const PIN_MODE_LABELS: Record = { - 0: "INPUT", - 1: "OUTPUT", - 2: "INPUT_PULLUP", -}; - -function extractPinModeData( - operations: Array<{ line: number; operation: string }>, -) { - const pinModes = operations - .filter((u) => u.operation.includes("pinMode")) - .map((u) => { - const match = /pinMode:(\d+)/.exec(u.operation); - const mode = match ? Number.parseInt(match[1]) : -1; - return PIN_MODE_LABELS[mode] ?? "UNKNOWN"; - }); - - const uniqueModes = [...new Set(pinModes)]; - const hasMultipleModes = uniqueModes.length > 1; - - return { pinModes, uniqueModes, hasMultipleModes }; +// Registry records of the legacy format carry only `usedAt` operations such as +// "pinMode:1"; the pin mode is derived from them when `pinModeModes` is absent. +function legacyRecord(pin: string, operations: string[]): IOPinRecord { + return { pin, defined: true, usedAt: operations.map((operation) => ({ line: 0, operation })) }; } -describe("ParserOutput - pinMode Detection", () => { - describe("extractPinModeData", () => { - it("should parse single pinMode:0 as INPUT", () => { - const operations = [{ line: 0, operation: "pinMode:0" }]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["INPUT"]); - expect(result.uniqueModes).toEqual(["INPUT"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should parse single pinMode:1 as OUTPUT", () => { - const operations = [{ line: 0, operation: "pinMode:1" }]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["OUTPUT"]); - expect(result.uniqueModes).toEqual(["OUTPUT"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should parse single pinMode:2 as INPUT_PULLUP", () => { - const operations = [{ line: 0, operation: "pinMode:2" }]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["INPUT_PULLUP"]); - expect(result.uniqueModes).toEqual(["INPUT_PULLUP"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should detect multiple different modes (conflict)", () => { - const operations = [ - { line: 0, operation: "pinMode:1" }, - { line: 0, operation: "pinMode:0" }, - ]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["OUTPUT", "INPUT"]); - expect(result.uniqueModes).toEqual(["OUTPUT", "INPUT"]); - expect(result.hasMultipleModes).toBe(true); - }); - - it("should detect same mode repeated", () => { - const operations = [ - { line: 0, operation: "pinMode:0" }, - { line: 0, operation: "pinMode:0" }, - { line: 0, operation: "pinMode:0" }, - ]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["INPUT", "INPUT", "INPUT"]); - expect(result.uniqueModes).toEqual(["INPUT"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should handle mixed operations and extract only pinMode", () => { - const operations = [ - { line: 5, operation: "digitalWrite" }, - { line: 0, operation: "pinMode:1" }, - { line: 8, operation: "digitalRead" }, - { line: 0, operation: "pinMode:0" }, - ]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["OUTPUT", "INPUT"]); - expect(result.uniqueModes).toEqual(["OUTPUT", "INPUT"]); - expect(result.hasMultipleModes).toBe(true); - }); - - it("should return UNKNOWN for invalid mode numbers", () => { - const operations = [{ line: 0, operation: "pinMode:99" }]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["UNKNOWN"]); - expect(result.uniqueModes).toEqual(["UNKNOWN"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should return UNKNOWN for malformed pinMode operation", () => { - const operations = [{ line: 0, operation: "pinMode" }]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["UNKNOWN"]); - expect(result.uniqueModes).toEqual(["UNKNOWN"]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should handle empty operations array", () => { - const operations: Array<{ line: number; operation: string }> = []; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual([]); - expect(result.uniqueModes).toEqual([]); - expect(result.hasMultipleModes).toBe(false); - }); - - it("should handle complex scenario with conflicts and repeats", () => { - const operations = [ - { line: 0, operation: "pinMode:1" }, - { line: 0, operation: "pinMode:1" }, - { line: 0, operation: "pinMode:0" }, - { line: 0, operation: "pinMode:1" }, - ]; - const result = extractPinModeData(operations); - - expect(result.pinModes).toEqual(["OUTPUT", "OUTPUT", "INPUT", "OUTPUT"]); - expect(result.uniqueModes).toEqual(["OUTPUT", "INPUT"]); - expect(result.hasMultipleModes).toBe(true); - }); - }); - - describe("pinMode count logic", () => { - it("should correctly count occurrences of each mode", () => { - const pinModes = ["INPUT", "INPUT", "OUTPUT", "INPUT"]; - const uniqueModes = [...new Set(pinModes)]; - - const inputCount = pinModes.filter((m) => m === "INPUT").length; - const outputCount = pinModes.filter((m) => m === "OUTPUT").length; - - expect(uniqueModes).toEqual(["INPUT", "OUTPUT"]); - expect(inputCount).toBe(3); - expect(outputCount).toBe(1); - }); - }); -}); +function renderRegistry(...records: IOPinRecord[]) { + return render( + , + ); +} -describe("I/O Registry - pinMode Operation Format", () => { - it("should match pinMode:0 format", () => { - const operation = "pinMode:0"; - const match = /pinMode:(\d+)/.exec(operation); +describe("ParserOutput - pinMode derived from legacy usedAt operations", () => { + it.each([ + ["pinMode:0", "INPUT"], + ["pinMode:1", "OUTPUT"], + ["pinMode:2", "INPUT_PULLUP"], + ["pinMode:99", "UNKNOWN"], + ["pinMode", "UNKNOWN"], + ])("shows %s as %s without a conflict marker", (operation, label) => { + renderRegistry(legacyRecord("7", [operation])); - expect(match?.[1]).toBe("0"); + expect(screen.getAllByText(label)).toHaveLength(1); + expect(screen.queryByText("!")).toBeNull(); }); - it("should match pinMode:1 format", () => { - const operation = "pinMode:1"; - const match = /pinMode:(\d+)/.exec(operation); + it("shows every distinct mode and flags a conflict when modes differ", () => { + renderRegistry(legacyRecord("7", ["pinMode:1", "pinMode:0"])); - expect(match?.[1]).toBe("1"); + expect(screen.getAllByText("OUTPUT")).toHaveLength(1); + expect(screen.getAllByText("INPUT")).toHaveLength(1); + expect(screen.getAllByText("!")).toHaveLength(2); }); - it("should match pinMode:2 format", () => { - const operation = "pinMode:2"; - const match = /pinMode:(\d+)/.exec(operation); + it("shows a repeated mode once without a conflict marker", () => { + renderRegistry(legacyRecord("7", ["pinMode:0", "pinMode:0", "pinMode:0"])); - expect(match?.[1]).toBe("2"); + expect(screen.getAllByText("INPUT")).toHaveLength(1); + expect(screen.queryByText("!")).toBeNull(); }); - it("should not match plain pinMode without colon", () => { - const operation = "pinMode"; - const match = /pinMode:(\d+)/.exec(operation); + it("derives modes only from pinMode operations", () => { + renderRegistry(legacyRecord("7", ["digitalWrite", "pinMode:1", "digitalRead", "pinMode:0"])); - expect(match).toBeNull(); + expect(screen.getAllByText("OUTPUT")).toHaveLength(1); + expect(screen.getAllByText("INPUT")).toHaveLength(1); }); - it("should not match pinMode with non-numeric mode", () => { - const operation = "pinMode:INPUT"; - const match = /pinMode:(\d+)/.exec(operation); + it("prefers pinModeModes over the legacy operations", () => { + renderRegistry({ ...legacyRecord("7", ["pinMode:1"]), pinModeModes: ["INPUT_PULLUP"] }); - expect(match).toBeNull(); + expect(screen.getAllByText("INPUT_PULLUP")).toHaveLength(1); + expect(screen.queryByText("OUTPUT")).toBeNull(); }); }); diff --git a/tests/server/services/arduino-output-parser.test.ts b/tests/server/services/arduino-output-parser.test.ts index 3a05c7ebd..d318badd1 100644 --- a/tests/server/services/arduino-output-parser.test.ts +++ b/tests/server/services/arduino-output-parser.test.ts @@ -194,23 +194,13 @@ describe("ArduinoOutputParser", () => { }); describe("protocol fragment handling", () => { - it("should ignore standalone closing brackets ']]'", () => { - const result = parser.parseStderrLine("]]", processStartTime); - expect(result.type).toBe("ignored"); - }); - - it("should ignore standalone opening brackets '[['", () => { - const result = parser.parseStderrLine("[[", processStartTime); - expect(result.type).toBe("ignored"); - }); - - it("should ignore partial protocol header without closing", () => { - const result = parser.parseStderrLine("[[SERIAL_EVENT:", processStartTime); - expect(result.type).toBe("ignored"); - }); - - it("should ignore short base64 tail with closing brackets", () => { - const result = parser.parseStderrLine("SGVsbG8=]]", processStartTime); + it.each([ + { title: "standalone closing brackets ']]'", line: "]]" }, + { title: "standalone opening brackets '[['", line: "[[" }, + { title: "partial protocol header without closing", line: "[[SERIAL_EVENT:" }, + { title: "short base64 tail with closing brackets", line: "SGVsbG8=]]" }, + ])("should ignore $title", ({ line }) => { + const result = parser.parseStderrLine(line, processStartTime); expect(result.type).toBe("ignored"); }); diff --git a/tests/shared/logger.test.ts b/tests/shared/logger.test.ts index a7e6c620d..3e3501e0e 100644 --- a/tests/shared/logger.test.ts +++ b/tests/shared/logger.test.ts @@ -104,97 +104,63 @@ describe("Logger", () => { }); describe("Logger - Sanitization (sanitize() coverage)", () => { - it("should redact credential key-value pairs in log messages", () => { + function logSanitized(input: string): void { setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("login failed: password=redact-me"); // NOSONAR S2068 - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[REDACTED]"), - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("redact-me"), - ); - }); - - it("should redact abbreviated credential key-value pairs in log messages", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("login: pwd=redact-me"); // NOSONAR S2068 - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[REDACTED]"), - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("redact-me"), - ); - }); - - it("should redact email addresses in log messages", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("User logged in: user@example.com"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[EMAIL_REDACTED]"), - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("user@example.com"), - ); - }); - - it("should redact bearer tokens in log messages", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("bearer [REDACTED_TOKEN]"), - ); - }); - - it("should not redact normal log messages", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("Simulation started successfully"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("Simulation started successfully"), - ); - }); - - it("should redact JSON sensitive fields (password/token/secret in JSON objects)", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info('Request body: {"password": "hunter2", "user": "admin"}'); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[REDACTED]"), - ); - expect(logSpy).not.toHaveBeenCalledWith( - expect.stringContaining("hunter2"), - ); - }); - - it("should redact SSN patterns (XXX-XX-XXXX)", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("Customer SSN: 123-45-6789"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[SSN_REDACTED]"), - ); - }); - - it("should redact phone numbers", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("Call us at 555-123-4567"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[PHONE_REDACTED]"), - ); - }); - - it("should redact credit card numbers", () => { - setLogLevel("INFO"); - const sanitizeLogger = new Logger("SanitizeTest"); - sanitizeLogger.info("Card: 1234 5678 9012 3456"); - expect(logSpy).toHaveBeenCalledWith( - expect.stringContaining("[CARD_REDACTED]"), - ); + new Logger("SanitizeTest").info(input); + } + + // The plaintext secret must be replaced by the marker and must not be logged. + it.each([ + { + title: "should redact credential key-value pairs in log messages", + input: "login failed: password=redact-me", // NOSONAR S2068 + marker: "[REDACTED]", + leaked: "redact-me", + }, + { + title: "should redact abbreviated credential key-value pairs in log messages", + input: "login: pwd=redact-me", // NOSONAR S2068 + marker: "[REDACTED]", + leaked: "redact-me", + }, + { + title: "should redact email addresses in log messages", + input: "User logged in: user@example.com", + marker: "[EMAIL_REDACTED]", + leaked: "user@example.com", + }, + { + title: "should redact JSON sensitive fields (password/token/secret in JSON objects)", + input: 'Request body: {"password": "hunter2", "user": "admin"}', + marker: "[REDACTED]", + leaked: "hunter2", + }, + ])("$title", ({ input, marker, leaked }) => { + logSanitized(input); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(marker)); + expect(logSpy).not.toHaveBeenCalledWith(expect.stringContaining(leaked)); + }); + + // Patterns whose replacement marker is the whole contract; ordinary text passes through. + it.each([ + { + title: "should redact bearer tokens in log messages", + input: "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc", + marker: "bearer [REDACTED_TOKEN]", + }, + { + title: "should not redact normal log messages", + input: "Simulation started successfully", + marker: "Simulation started successfully", + }, + { title: "should redact SSN patterns (XXX-XX-XXXX)", input: "Customer SSN: 123-45-6789", marker: "[SSN_REDACTED]" }, + { title: "should redact phone numbers", input: "Call us at 555-123-4567", marker: "[PHONE_REDACTED]" }, + { title: "should redact credit card numbers", input: "Card: 1234 5678 9012 3456", marker: "[CARD_REDACTED]" }, + ])("$title", ({ input, marker }) => { + logSanitized(input); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(marker)); }); }); From bb57b9f091e5e6657c40e654c2c754748b9ade3d Mon Sep 17 00:00:00 2001 From: ttbombadil Date: Mon, 12 Oct 2026 19:20:49 +0200 Subject: [PATCH 3/3] refactor: share canonical Uno PWM pins The set of PWM-capable Arduino UNO pins (3, 5, 6, 9, 10, 11) was defined three times, as an array in the pin polling engine and as a Set in parser-output and in the hardware-compatibility parser, and spelled out a fourth time in the analogWrite warning text. Sonar flagged the array (typescript:S7776); changing it locally would have kept the duplication. Define UNO_PWM_PINS and isUnoPwmPin() once in shared/utils/arduino-utils.ts (types-only imports, so no cycles) and use them in all three places. The warning text is derived from the same list and stays identical. Iteration order and behaviour are unchanged. Tests: the shared list and predicate for every UNO pin number, and the registry shows the "~" marker only for PWM-capable pins. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/features/parser-output.tsx | 4 ++-- client/src/hooks/usePinPollingEngine.ts | 6 ++--- .../parsers/hardware-compatibility-parser.ts | 6 ++--- shared/utils/arduino-utils.ts | 13 +++++++++++ tests/client/parser-output-pinmode.test.tsx | 10 +++++++++ tests/shared/uno-pwm-pins.test.ts | 22 +++++++++++++++++++ 6 files changed, 53 insertions(+), 8 deletions(-) create mode 100644 tests/shared/uno-pwm-pins.test.ts diff --git a/client/src/components/features/parser-output.tsx b/client/src/components/features/parser-output.tsx index d22c04fdf..2caf42de7 100644 --- a/client/src/components/features/parser-output.tsx +++ b/client/src/components/features/parser-output.tsx @@ -17,6 +17,7 @@ import { useState } from "react"; import * as React from "react"; import type { SeverityLevel } from "@shared/reserved-names-validator"; import type { SourceLocation } from "@shared/source-project"; +import { isUnoPwmPin } from "@shared/utils/arduino-utils"; import { formatSourceNavigationTarget, isSourceLocation, @@ -28,7 +29,6 @@ import { TabBar } from "@/components/ui/tab-bar"; import { getSeverityStatus, getStatusColor, getStatusTextClass } from "@/lib/status-semantics"; // Module-level constants (not re-created on every render) -const PWM_PINS = new Set([3, 5, 6, 9, 10, 11]); const ALL_PIN_RECORDS: IOPinRecord[] = Array.from({ length: 20 }, (_, pinId) => ({ pin: pinId >= 14 ? `A${pinId - 14}` : String(pinId), pinId, @@ -80,7 +80,7 @@ function getRxTxBadge(pin: string): JSX.Element | null { /** Returns a PWM tilde element for PWM-capable pins, or null. Fixes S1940 IIFE anti-pattern. */ function getPwmTilde(pin: string): JSX.Element | null { const n = Number.parseInt(pin, 10); - return !Number.isNaN(n) && PWM_PINS.has(n) + return !Number.isNaN(n) && isUnoPwmPin(n) ? ~ : null; } diff --git a/client/src/hooks/usePinPollingEngine.ts b/client/src/hooks/usePinPollingEngine.ts index 20e00ba80..12670a315 100644 --- a/client/src/hooks/usePinPollingEngine.ts +++ b/client/src/hooks/usePinPollingEngine.ts @@ -1,9 +1,9 @@ import { useEffect } from "react"; import type { PinState } from "@/components/features/arduino-board"; +import { UNO_PWM_PINS, isUnoPwmPin } from "@shared/utils/arduino-utils"; // Constants const VIEWBOX_HEIGHT = 209; -const PWM_PINS = [3, 5, 6, 9, 10, 11]; const FADE_OUT_MS = 200; /** @@ -240,7 +240,7 @@ function pinColorForState(state: PinState, pin: number, brightness: number): str if (brightness <= 0) return "var(--color-black)"; const intensity = Math.round(brightness * 255); if (state.type === "digital") return `rgb(${intensity}, 0, 0)`; - if (PWM_PINS.includes(pin)) { + if (isUnoPwmPin(pin)) { const pwmIntensity = Math.round((state.value / 255) * intensity); return `rgb(${pwmIntensity}, 0, 0)`; } @@ -391,7 +391,7 @@ export function usePinPollingEngine({ return; } - for (const pin of PWM_PINS) { + for (const pin of UNO_PWM_PINS) { const valStr = getPinState(pin)?.value?.toString() ?? ""; updateLabelForPin(svgEl, `pin-${pin}-state`, `pin-${pin}-frame`, `pin-${pin}-val`, valStr, showPWMValues); } diff --git a/shared/parsers/hardware-compatibility-parser.ts b/shared/parsers/hardware-compatibility-parser.ts index b371253de..97ea727b3 100644 --- a/shared/parsers/hardware-compatibility-parser.ts +++ b/shared/parsers/hardware-compatibility-parser.ts @@ -6,8 +6,8 @@ import { type StaticIOOperation, type UnresolvedStaticIOCall, } from "@shared/io-registry-parser"; +import { UNO_PWM_PINS, isUnoPwmPin } from "@shared/utils/arduino-utils"; -const PWM_PINS = new Set([3, 5, 6, 9, 10, 11]); const DIGITAL_OPERATIONS = new Set([ "digitalRead", "digitalWrite", @@ -93,13 +93,13 @@ export class HardwareCompatibilityParser { private checkAnalogWritePWM(): ParserMessage[] { return resolvedCalls(this.analysis, "analogWrite") - .filter(({ pinId }) => !PWM_PINS.has(pinId)) + .filter(({ pinId }) => !isUnoPwmPin(pinId)) .map((call) => ({ id: randomUUID(), type: "warning" as const, category: "hardware" as const, severity: 2 as const, - message: `analogWrite(${call.sourceExpression}, ...) used on pin ${call.pinId}, which doesn't support PWM on Arduino UNO. PWM pins: 3, 5, 6, 9, 10, 11.`, + message: `analogWrite(${call.sourceExpression}, ...) used on pin ${call.pinId}, which doesn't support PWM on Arduino UNO. PWM pins: ${UNO_PWM_PINS.join(", ")}.`, suggestion: "// Use PWM pin instead: analogWrite(3, value);", ...sourceLocation(call), })); diff --git a/shared/utils/arduino-utils.ts b/shared/utils/arduino-utils.ts index f79c1b51a..d80103fc7 100644 --- a/shared/utils/arduino-utils.ts +++ b/shared/utils/arduino-utils.ts @@ -9,3 +9,16 @@ const PIN_MODE_NAMES: Record = { export function pinModeToString(mode: number): PinMode { return PIN_MODE_NAMES[mode] ?? "INPUT"; // Default to INPUT for invalid modes } + +/** + * PWM-capable digital pins of the Arduino UNO (the pins marked "~"), in board + * order. The single definition of this hardware property: UI, polling engine + * and hardware-compatibility checks all derive from it. + */ +export const UNO_PWM_PINS: readonly number[] = [3, 5, 6, 9, 10, 11]; + +const UNO_PWM_PIN_SET: ReadonlySet = new Set(UNO_PWM_PINS); + +export function isUnoPwmPin(pin: number): boolean { + return UNO_PWM_PIN_SET.has(pin); +} diff --git a/tests/client/parser-output-pinmode.test.tsx b/tests/client/parser-output-pinmode.test.tsx index d5642ab94..de35b2ac6 100644 --- a/tests/client/parser-output-pinmode.test.tsx +++ b/tests/client/parser-output-pinmode.test.tsx @@ -53,6 +53,16 @@ describe("ParserOutput - pinMode derived from legacy usedAt operations", () => { expect(screen.getAllByText("INPUT")).toHaveLength(1); }); + it("marks only PWM-capable pins with a tilde", () => { + renderRegistry( + legacyRecord("3", ["pinMode:1"]), + legacyRecord("4", ["pinMode:1"]), + legacyRecord("A0", ["pinMode:0"]), + ); + + expect(screen.getAllByText("~")).toHaveLength(1); + }); + it("prefers pinModeModes over the legacy operations", () => { renderRegistry({ ...legacyRecord("7", ["pinMode:1"]), pinModeModes: ["INPUT_PULLUP"] }); diff --git a/tests/shared/uno-pwm-pins.test.ts b/tests/shared/uno-pwm-pins.test.ts new file mode 100644 index 000000000..cb492e723 --- /dev/null +++ b/tests/shared/uno-pwm-pins.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { UNO_PWM_PINS, isUnoPwmPin } from "@shared/utils/arduino-utils"; + +describe("Arduino UNO PWM pins", () => { + it("lists the PWM-capable pins in board order", () => { + expect([...UNO_PWM_PINS]).toEqual([3, 5, 6, 9, 10, 11]); + }); + + it.each([3, 5, 6, 9, 10, 11])("treats pin %i as PWM-capable", (pin) => { + expect(isUnoPwmPin(pin)).toBe(true); + }); + + it.each([0, 1, 2, 4, 7, 8, 12, 13, 14, 19, 20, -1, 3.5])("treats pin %s as not PWM-capable", (pin) => { + expect(isUnoPwmPin(pin)).toBe(false); + }); + + it("agrees with the list for every pin number the UNO exposes", () => { + for (let pin = 0; pin <= 19; pin += 1) { + expect(isUnoPwmPin(pin), `pin ${pin}`).toBe(UNO_PWM_PINS.includes(pin)); + } + }); +});