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/arduino-simulator-codechange.test.tsx b/tests/client/arduino-simulator-codechange.test.tsx
index dec66014d..496f60896 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 () => {
@@ -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/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/parser-output-pinmode.test.tsx b/tests/client/parser-output-pinmode.test.tsx
index cea0065f4..de35b2ac6 100644
--- a/tests/client/parser-output-pinmode.test.tsx
+++ b/tests/client/parser-output-pinmode.test.tsx
@@ -5,189 +5,69 @@ 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)];
+function renderRegistry(...records: IOPinRecord[]) {
+ return render(
+ ,
+ );
+}
- const inputCount = pinModes.filter((m) => m === "INPUT").length;
- const outputCount = pinModes.filter((m) => m === "OUTPUT").length;
+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(uniqueModes).toEqual(["INPUT", "OUTPUT"]);
- expect(inputCount).toBe(3);
- expect(outputCount).toBe(1);
- });
+ expect(screen.getAllByText(label)).toHaveLength(1);
+ expect(screen.queryByText("!")).toBeNull();
});
-});
-describe("I/O Registry - pinMode Operation Format", () => {
- it("should match pinMode:0 format", () => {
- const operation = "pinMode:0";
- 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("0");
+ expect(screen.getAllByText("OUTPUT")).toHaveLength(1);
+ expect(screen.getAllByText("INPUT")).toHaveLength(1);
+ expect(screen.getAllByText("!")).toHaveLength(2);
});
- it("should match pinMode:1 format", () => {
- const operation = "pinMode:1";
- 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("1");
+ expect(screen.getAllByText("INPUT")).toHaveLength(1);
+ expect(screen.queryByText("!")).toBeNull();
});
- it("should match pinMode:2 format", () => {
- const operation = "pinMode:2";
- const match = /pinMode:(\d+)/.exec(operation);
+ it("derives modes only from pinMode operations", () => {
+ renderRegistry(legacyRecord("7", ["digitalWrite", "pinMode:1", "digitalRead", "pinMode:0"]));
- expect(match?.[1]).toBe("2");
+ expect(screen.getAllByText("OUTPUT")).toHaveLength(1);
+ expect(screen.getAllByText("INPUT")).toHaveLength(1);
});
- it("should not match plain pinMode without colon", () => {
- const operation = "pinMode";
- const match = /pinMode:(\d+)/.exec(operation);
+ it("marks only PWM-capable pins with a tilde", () => {
+ renderRegistry(
+ legacyRecord("3", ["pinMode:1"]),
+ legacyRecord("4", ["pinMode:1"]),
+ legacyRecord("A0", ["pinMode:0"]),
+ );
- expect(match).toBeNull();
+ expect(screen.getAllByText("~")).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/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/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/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/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));
});
});
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);
});
});
});
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));
+ }
+ });
+});