diff --git a/oracle-server.js b/oracle-server.js new file mode 100644 index 0000000..35ce685 --- /dev/null +++ b/oracle-server.js @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Persistent Oracle Server for Go fuzz testing framework. + * + * Unlike cross-validate.ts which handles a single request and exits, + * this server stays alive and handles multiple requests via line-delimited JSON. + * + * Protocol: + * - Each line of stdin is a JSON request: {"oldTree": ..., "diff": ...} + * - Each request gets a JSON response on stdout: {"html": ..., "tree": ..., "error": ...} + * - Server exits when stdin closes + * + * This reduces per-request overhead from ~300ms (process spawn) to ~20-50ms (IPC only). + */ + +const readline = require("readline"); +const { TreeRenderer } = require("./dist/state/tree-renderer"); +const { createLogger } = require("./dist/utils/logger"); + +// Create a silent logger to avoid console output +const logger = createLogger({ level: "error" }); + +// Create readline interface for line-by-line processing +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: false, +}); + +// Process each line as a separate request +rl.on("line", (line) => { + try { + const data = JSON.parse(line); + + // Create a fresh renderer for each request to avoid state leakage + const renderer = new TreeRenderer(logger); + + // Apply the old tree first (initial state) + if (data.oldTree) { + renderer.applyUpdate(data.oldTree); + } + + // Apply the diff + const result = renderer.applyUpdate(data.diff); + + // Get the current tree state from renderer + const finalTree = renderer.getTreeState(); + + const output = { + html: result.html, + tree: finalTree, + error: null, + }; + + // Write response as single line + console.log(JSON.stringify(output)); + } catch (err) { + const output = { + html: "", + tree: null, + error: err instanceof Error ? err.message : String(err), + }; + console.log(JSON.stringify(output)); + } +}); + +// Handle stdin close +rl.on("close", () => { + process.exit(0); +}); + +// Handle errors gracefully - use stdout to maintain JSON protocol +// Exit after uncaught exception as process state may be corrupted +process.on("uncaughtException", (err) => { + console.log(JSON.stringify({ html: "", tree: null, error: err.message })); + rl.close(); + process.exit(1); +}); diff --git a/state/tree-renderer.ts b/state/tree-renderer.ts index 9ccf706..7dc7274 100644 --- a/state/tree-renderer.ts +++ b/state/tree-renderer.ts @@ -40,6 +40,81 @@ function isRangeNode(node: any): boolean { ); } +/** + * Checks if a node or any of its nested children contains a range structure. + * + * This is needed for detecting structure transitions like: + * - {{if .ShowList}}
Hidden
{{end}} + * + * When ShowList changes from true to false, the outer node doesn't have `d` directly, + * but its child at position "0" does. We need to detect this nested range to know + * that a full replacement is required instead of a merge. + * + * @param node - The tree node to check + * @returns true if this node or any nested child has a range structure + */ +function hasRangeAnywhere(node: any, depth = 0): boolean { + // Depth limit to prevent potential stack overflow on deeply nested or malformed trees + const MAX_DEPTH = 50; + if (depth > MAX_DEPTH || node == null || typeof node !== "object" || Array.isArray(node)) { + return false; + } + + // Check if this node itself is a range + if (isRangeNode(node)) { + return true; + } + + // Check numeric keys (dynamic positions) for nested ranges + for (const key of Object.keys(node)) { + if (/^\d+$/.test(key)) { + const child = node[key]; + if (child != null && typeof child === "object" && !Array.isArray(child)) { + if (hasRangeAnywhere(child, depth + 1)) { + return true; + } + } + } + } + + return false; +} + +/** + * Determines if a structure transition requires full replacement instead of merge. + * + * This handles cases where: + * 1. Old structure has a range (directly or nested) but new structure doesn't + * 2. New structure has statics (indicating a complete structure definition) + * 3. Old structure has dynamics that new structure doesn't have + * + * @param existing - The existing tree node + * @param update - The update tree node + * @returns true if the update should fully replace existing instead of merging + */ +function shouldFullReplace(existing: any, update: any): boolean { + // If update doesn't have statics, it's a partial update, not a replacement + if (!update.s || !Array.isArray(update.s)) { + return false; + } + + // Check for range→non-range transition (including nested ranges) + if (hasRangeAnywhere(existing) && !hasRangeAnywhere(update)) { + return true; + } + + // Check if existing has dynamics that update doesn't have + for (const key of Object.keys(existing)) { + if (/^\d+$/.test(key) && !(key in update)) { + // Existing has a dynamic position that update doesn't + // If update has statics, this is a structure change + return true; + } + } + + return false; +} + /** * Handles tree state management and HTML reconstruction logic for LiveTemplate. */ @@ -132,14 +207,17 @@ export class TreeRenderer { return update; } - // Detect range→non-range transition: when existing has a range structure - // but update does NOT, we must do a full replacement instead of merge. - // Otherwise, the old range items would be preserved and rendered with - // the new (else clause) statics, causing wrong content. - // See isRangeNode() for definition of "range" vs "non-range" structures. - if (isRangeNode(existing) && !isRangeNode(update)) { + // Detect structure transitions that require full replacement instead of merge. + // This handles: + // 1. Direct range→non-range: existing has d/s arrays, update doesn't + // 2. Nested range→non-range: existing has range in a child, update doesn't + // 3. Structure changes: existing has dynamics that update doesn't have + // + // Without this check, old range data would be preserved and mixed with + // new statics, causing wrong content or memory leaks. + if (shouldFullReplace(existing, update)) { this.logger.debug( - `[deepMerge] Range→non-range transition at path ${currentPath}, replacing instead of merging` + `[deepMerge] Structure transition at path ${currentPath}, replacing instead of merging` ); return update; } @@ -274,10 +352,11 @@ export class TreeRenderer { ); const changes = operation[2]; if (updateIndex >= 0 && changes) { - currentItems[updateIndex] = { - ...currentItems[updateIndex], - ...changes, - }; + currentItems[updateIndex] = this.mergeRangeItem( + currentItems[updateIndex], + changes, + statePath + ); } break; } @@ -592,10 +671,11 @@ export class TreeRenderer { ); const changes = operation[2]; if (updateIndex >= 0 && changes) { - currentItems[updateIndex] = { - ...currentItems[updateIndex], - ...changes, - }; + currentItems[updateIndex] = this.mergeRangeItem( + currentItems[updateIndex], + changes, + statePath || "" + ); } break; } @@ -815,4 +895,14 @@ export class TreeRenderer { (item: any) => this.getItemKey(item, statics, statePath) === key ); } + + /** + * Merges changes into a range item using deep merge to preserve statics. + * When the server sends partial updates like {"5": {"0": "new text"}}, + * we need to merge this into the existing item's field 5, not replace it. + * Shallow spread would lose the statics ({"s": [...]}) that the client has cached. + */ + private mergeRangeItem(item: any, changes: any, statePath: string): any { + return this.deepMergeTreeNodes(item, changes, `${statePath}.item`); + } } diff --git a/tests/tree-renderer.test.ts b/tests/tree-renderer.test.ts index 9fc8235..8df62fd 100644 --- a/tests/tree-renderer.test.ts +++ b/tests/tree-renderer.test.ts @@ -184,4 +184,149 @@ describe("TreeRenderer", () => { expect(elseResult.html).toContain("No items available
"); }); }); + + describe("render - NESTED range to non-range transition", () => { + /** + * This tests the case where a range is nested inside another structure: + * + * Template: + * {{if .ShowList}} + *List is hidden
+ * {{end}} + * + * When ShowList goes from true to false: + * - Position 0 changes from {0: {range...}, s: ["List is hidden
"]} + * + * The nested range at position 0.0 must be removed, not preserved. + */ + it("should replace nested range structure with else clause", () => { + // Initial state: ShowList=true with items + // Structure: position 0 contains a UL wrapper with nested range at 0.0 + const initialUpdate = { + s: ["List is hidden
"], + }, + }; + const elseResult = renderer.applyUpdate(elseUpdate); + + // Verify the tree state doesn't contain orphaned range data + const treeState = renderer.getTreeState(); + expect(treeState[0]).not.toHaveProperty("d"); + expect(treeState[0][0]).toBeUndefined(); // Nested range should be gone + + // Should NOT contain old items + expect(elseResult.html).not.toContain("Apple"); + expect(elseResult.html).not.toContain("Banana"); + expect(elseResult.html).not.toContain("List is hidden
"], + }, + }; + renderer.applyUpdate(elseUpdate); + + // Transition to ShowList=true with items + const rangeUpdate = { + 0: { + 0: { + d: [ + { 0: "item-1", 1: "New Item", _k: "item-1" }, + ], + s: ['No content
"], + }, + }; + const simpleResult = renderer.applyUpdate(simpleUpdate); + + // All nested range data should be gone + expect(simpleResult.html).not.toContain("Deep Item"); + expect(simpleResult.html).not.toContain("No content"); + + // Verify tree state is clean + const treeState = renderer.getTreeState(); + expect(treeState[0]).not.toHaveProperty("d"); + expect(treeState[0][0]).toBeUndefined(); + }); + }); });