import { describe, expect, test, beforeEach } from "bun:test"; import { EventEmitter } from "events"; import type { StateUpdateEvent } from "../types.ts"; import { createRuntime, createAction, type RuntimePlugin } from "@gizmo-ai/runtime"; /** * Tests for SSE events route - specifically for array state slices * and type change handling in JSON Patch generation. * * This validates the fix for the bug where `compare(previousState || {}, after)` * would generate invalid patches for array slices and type changes. */ describe("SSE Events - Array State Slices", () => { const testAction = createAction<{ value: string }, "TEST_ACTION">("TEST_ACTION"); test("array slice sends full replacement on first update", () => { // Simulate plugin with array as root state const logsPlugin: RuntimePlugin<"logs", string[]> = { key: "logs", initialState: [], reducer: (state = [], action) => { if (testAction.match(action)) { return [...state, action.payload.value]; } return state; }, }; const runtime = createRuntime({ plugins: [logsPlugin], }); // Simulate first state change const previousState = undefined; // First update - no previous state const currentState = ["log 1", "log 2"]; // Detect that we need full replacement const needsFullReplacement = previousState === undefined || previousState === null || currentState === null || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); // Should send full value, not patches const event: StateUpdateEvent = { type: "state.update", slice: "logs", value: currentState, timestamp: Date.now(), }; expect(event.value).toEqual(["log 1", "log 2"]); expect(event.patches).toBeUndefined(); }); test("array slice sends patches for incremental updates after initialization", () => { const { compare } = require("fast-json-patch"); const previousState = ["log 1", "log 2"]; const currentState = ["log 1", "log 2", "log 3"]; // Should NOT need full replacement const needsFullReplacement = previousState === undefined || previousState === null || currentState === null || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(false); // Should compute patches normally const patches = compare(previousState, currentState); expect(patches.length).toBeGreaterThan(0); expect(patches).toMatchObject([ { op: "add", path: "/2", value: "log 3", }, ]); }); }); describe("SSE Events - Type Changes", () => { test("object → array sends full replacement", () => { const previousState = { count: 0 }; const currentState = [1, 2, 3]; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); const event: StateUpdateEvent = { type: "state.update", slice: "data", value: currentState, timestamp: Date.now(), }; expect(event.value).toEqual([1, 2, 3]); expect(event.patches).toBeUndefined(); }); test("array → object sends full replacement", () => { const previousState = [1, 2, 3]; const currentState = { count: 3 }; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); const event: StateUpdateEvent = { type: "state.update", slice: "data", value: currentState, timestamp: Date.now(), }; expect(event.value).toEqual({ count: 3 }); expect(event.patches).toBeUndefined(); }); test("primitive → object sends full replacement", () => { const previousState = 42; const currentState = { value: 42 }; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); const event: StateUpdateEvent = { type: "state.update", slice: "data", value: currentState, timestamp: Date.now(), }; expect(event.value).toEqual({ value: 42 }); expect(event.patches).toBeUndefined(); }); test("object → primitive sends full replacement", () => { const previousState = { value: 42 }; const currentState = 100; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); const event: StateUpdateEvent = { type: "state.update", slice: "data", value: currentState, timestamp: Date.now(), }; expect(event.value).toBe(100); expect(event.patches).toBeUndefined(); }); }); describe("SSE Events - Normal Incremental Updates", () => { test("object updates send patches when type is stable", () => { const { compare } = require("fast-json-patch"); const previousState = { count: 0, name: "test" }; const currentState = { count: 1, name: "test" }; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(false); const patches = compare(previousState, currentState); expect(patches).toMatchObject([ { op: "replace", path: "/count", value: 1, }, ]); }); test("no patches sent when state is unchanged", () => { const { compare } = require("fast-json-patch"); const previousState = { count: 5 }; const currentState = { count: 5 }; const patches = compare(previousState, currentState); // Should skip sending event when patches.length === 0 expect(patches.length).toBe(0); }); }); describe("SSE Events - Edge Cases", () => { test("null → object sends full replacement", () => { const previousState = null; const currentState = { value: "data" }; const needsFullReplacement = previousState === undefined || previousState === null || currentState === null || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); }); test("undefined → value sends full replacement", () => { const previousState = undefined; const currentState = "initial value"; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(true); }); test("empty array → non-empty array sends patches", () => { const { compare } = require("fast-json-patch"); const previousState: string[] = []; const currentState = ["item 1"]; const needsFullReplacement = previousState === undefined || typeof previousState !== typeof currentState || Array.isArray(previousState) !== Array.isArray(currentState); expect(needsFullReplacement).toBe(false); const patches = compare(previousState, currentState); expect(patches).toMatchObject([ { op: "add", path: "/0", value: "item 1", }, ]); }); });