diff --git a/.changeset/robust-host-boundaries.md b/.changeset/robust-host-boundaries.md new file mode 100644 index 0000000..b4dbf07 --- /dev/null +++ b/.changeset/robust-host-boundaries.md @@ -0,0 +1,9 @@ +--- +"ragelayer": patch +--- + +Clean up failed custom-tool mounts across vanilla, React, Vue, and custom-element integrations. +Isolate host event, toolbar, lifecycle, and telemetry callbacks so one exception cannot prevent +cleanup, stall frame recording, or leave other views stale. Complete disposal even when a custom +tool reset throws. Validate rate-limiter and history numeric boundaries, reject overlapping or +nonfinite scripted strikes, and release gesture/audio state when a tool throws. diff --git a/docs/advanced.md b/docs/advanced.md index d2a2f63..50d8b63 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -132,5 +132,7 @@ The SDK preserves inferred state types across `onDown`, `onMove`, `onUp`, `tick` `hasPendingWork`, `backgroundTick`, and `reset`. Pair the two background hooks for a fuse or projectile that must continue after another tool is selected; return `false` for held-only tools so their selected idle state can sleep. `createRateLimiter()` prevents a stalled frame from releasing -an unbounded effect burst. Custom procedural models can register measured icon bounds with +an unbounded effect burst. Its rate and maximum burst must be finite; nonfinite configuration throws +`RangeError`. A nonfinite or nonpositive elapsed time returns zero without changing accumulated time. +Custom procedural models can register measured icon bounds with `registerToolIconBounds()`. diff --git a/docs/api.md b/docs/api.md index 2ceae1a..df49b5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -44,6 +44,11 @@ rageLayer.subscribe((engine) => console.log(engine)); registers the tools and returns a live engine immediately, so call it only in a browser. Construct `RageLayerEngine` directly when you want to register each tool yourself. +If a custom tool fails during mounting, the helper disposes the partially initialized engine +before rethrowing the error. The React, Vue, and custom-element components use the same cleanup. +Lifecycle and engine event subscribers are isolated: a throwing subscriber is warned about without +interrupting other subscribers or engine cleanup. + ## `new RageLayerEngine(options?)` Creates the overlay and starts capturing the page. Must run in a browser. @@ -241,7 +246,8 @@ they always have. - `engine.strike(x, y, { holdMs? })` — use the active tool at a document point with no pointer device. Runs the same `onDown`/`onUp` pair a click produces and takes a history checkpoint, so custom tools need no special handling to be driven this way. Returns `false` when no tool is - selected or the engine is paused. + selected, the engine is paused or disposed, a pointer gesture is already active, or any coordinate + or `holdMs` is nonfinite. Tool exceptions propagate to the caller after gesture and audio cleanup. - `engine.historyEnabled` — whether undo/redo was turned on, as distinct from whether there is currently anything to undo. A toolbar needs this to show its undo controls from the start. diff --git a/src/callbacks.ts b/src/callbacks.ts new file mode 100644 index 0000000..deccd27 --- /dev/null +++ b/src/callbacks.ts @@ -0,0 +1,8 @@ +/** Host callbacks must not interrupt engine notifications or resource teardown. */ +export function runSafely(callback: () => void, context: string): void { + try { + callback(); + } catch (error) { + console.warn(`[RageLayer] ${context}`, error); + } +} diff --git a/src/element.ts b/src/element.ts index de82420..8f5a556 100644 --- a/src/element.ts +++ b/src/element.ts @@ -16,7 +16,8 @@ import { RAGELAYER_IGNORE_ATTR } from "./capture"; import { defaultTools } from "./default-tools"; -import { RageLayerEngine } from "./engine"; +import type { RageLayerEngine } from "./engine"; +import { mountRageLayer } from "./mount"; import type { RageLayerStrings } from "./strings"; import { type ToolbarButton, ToolbarModel, type ToolbarState } from "./toolbar"; import { toolbarIconElement } from "./toolbar-icons"; @@ -267,12 +268,13 @@ export class RageLayerElement extends ElementBase { const tools = this.config.tools ?? defaultTools; - const engine = new RageLayerEngine({ + const engine = mountRageLayer({ soundEnabled: this.hasAttribute("sound"), history: true, ...this.config, + tools, + initialTool: null, }); - for (const tool of tools) engine.registerTool(tool); const initialTool = this.getAttribute("initial-tool"); if (initialTool) engine.setTool(initialTool); this.engine = engine; diff --git a/src/engine.ts b/src/engine.ts index d6b51b1..6aaf940 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -1,5 +1,6 @@ import { SoundEngine } from "./audio"; import { BugSwarm } from "./bugs-sim"; +import { runSafely } from "./callbacks"; import { defaultCaptureFilter } from "./capture"; import { CaptureController, type CaptureHost } from "./capture-controller"; import { type ComboEvent, ComboTracker, type InteractionKind } from "./combos"; @@ -509,7 +510,11 @@ export class RageLayerEngine implements RageLayerEngineApi { } finally { this.applyingCombo = false; } - for (const callback of this.comboListeners) callback(event); + for (const callback of [...this.comboListeners]) { + if (this.comboListeners.has(callback)) { + runSafely(() => callback(event), "Combo listener failed"); + } + } } return events; } @@ -544,7 +549,12 @@ export class RageLayerEngine implements RageLayerEngineApi { if (cause === undefined) console.warn(`[RageLayer] ${message}`); else console.warn(`[RageLayer] ${message}`, cause); } else { - for (const callback of this.errorListeners) callback(this.lastError); + const error = this.lastError; + for (const callback of [...this.errorListeners]) { + if (this.errorListeners.has(callback)) { + runSafely(() => callback(error), "Error listener failed"); + } + } } this.emit("error"); } @@ -605,6 +615,7 @@ export class RageLayerEngine implements RageLayerEngineApi { } registerTool(tool: Tool) { + if (this.disposed) return; const previous = this.tools.get(tool.id); const replacingActive = previous === this.activeTool; @@ -915,8 +926,6 @@ export class RageLayerEngine implements RageLayerEngineApi { this.overlay.container.replaceChildren(); this.sound.dispose(); this.history?.clear(); - this.emit("dispose"); - this.listeners.clear(); this.monitor.dispose(); // `dispose` must release the expensive state even when application code @@ -946,7 +955,9 @@ export class RageLayerEngine implements RageLayerEngineApi { this.comboListeners.clear(); this.errorListeners.clear(); // Pending tool state must not survive disposal. - for (const tool of this.tools.values()) tool.reset?.(this); + for (const tool of this.tools.values()) { + runSafely(() => tool.reset?.(this), "Tool cleanup failed"); + } this.tools.clear(); this.activeTool = null; this.fire.dispose(); @@ -956,6 +967,8 @@ export class RageLayerEngine implements RageLayerEngineApi { // engine is created later — `sprites()` rebuilds lazily. liveEngines = Math.max(0, liveEngines - 1); if (liveEngines === 0) clearSpriteCache(); + this.emit("dispose"); + this.listeners.clear(); } // ── Content capture (the "destroy the real page" pipeline) ──────────────── @@ -1606,9 +1619,11 @@ export class RageLayerEngine implements RageLayerEngineApi { } private emit(event: EngineEvent) { - this.listeners.get(event)?.forEach((cb) => { - cb(); - }); + const listeners = this.listeners.get(event); + if (!listeners) return; + for (const callback of [...listeners]) { + if (listeners.has(callback)) runSafely(callback, `${event} listener failed`); + } } private applyQuality(tier: PerformanceQualityTier) { diff --git a/src/history.ts b/src/history.ts index 1a56c8f..58ee9a7 100644 --- a/src/history.ts +++ b/src/history.ts @@ -28,8 +28,10 @@ export class DestructionHistory { private readonly maxPixels: number; constructor(options: HistoryOptions = {}) { - this.maxEntries = Math.max(1, Math.min(20, Math.round(options.maxEntries ?? 6))); - this.maxPixels = Math.max(1, Math.round(options.maxPixels ?? 24_000_000)); + const entries = options.maxEntries ?? 6; + const pixels = options.maxPixels ?? 24_000_000; + this.maxEntries = Math.max(1, Math.min(20, Math.round(Number.isFinite(entries) ? entries : 6))); + this.maxPixels = Math.max(1, Math.round(Number.isFinite(pixels) ? pixels : 24_000_000)); } get state(): HistoryState { @@ -42,7 +44,7 @@ export class DestructionHistory { } canStore(pixelCost: number): boolean { - return pixelCost <= this.maxPixels; + return Number.isFinite(pixelCost) && pixelCost >= 0 && pixelCost <= this.maxPixels; } push(entry: T): boolean { @@ -62,7 +64,7 @@ export class DestructionHistory { current.dispose(); return null; } - if (current.pixelCost <= this.maxPixels) this.future.push(current); + if (this.canStore(current.pixelCost)) this.future.push(current); else current.dispose(); this.enforceBudget(); return previous; @@ -74,7 +76,7 @@ export class DestructionHistory { current.dispose(); return null; } - if (current.pixelCost <= this.maxPixels) this.past.push(current); + if (this.canStore(current.pixelCost)) this.past.push(current); else current.dispose(); this.enforceBudget(); return next; diff --git a/src/mount.ts b/src/mount.ts index 95237aa..f184548 100644 --- a/src/mount.ts +++ b/src/mount.ts @@ -1,3 +1,4 @@ +import { runSafely } from "./callbacks"; import { defaultTools } from "./default-tools"; import { RageLayerEngine } from "./engine"; import type { RageLayerEngineOptions, Tool } from "./types"; @@ -24,15 +25,18 @@ export function mountRageLayer(options: MountRageLayerOptions = {}) { const { tools: explicitTools, initialTool, ...engineOptions } = options; const tools = explicitTools ?? defaultTools; const selectedInitialTool = initialTool === undefined ? "hammer" : initialTool; - const engine = new RageLayerEngine(engineOptions); - for (const tool of tools) engine.registerTool(tool); - if (selectedInitialTool !== null && !tools.some((tool) => tool.id === selectedInitialTool)) { - engine.dispose(); throw new RangeError(`Unknown initial RageLayer tool: ${selectedInitialTool}`); } - engine.setTool(selectedInitialTool); - return engine; + const engine = new RageLayerEngine(engineOptions); + try { + for (const tool of tools) engine.registerTool(tool); + engine.setTool(selectedInitialTool); + return engine; + } catch (error) { + engine.dispose(); + throw error; + } } export interface RageLayerController { @@ -56,7 +60,13 @@ export function createRageLayer(options: MountRageLayerOptions = {}): RageLayerC const listeners = new Set<(engine: RageLayerEngine | null) => void>(); const notify = () => { - for (const listener of listeners) listener(engine); + const current = engine; + for (const listener of [...listeners]) { + // A listener can synchronously open or close the controller. That newer + // notification must not be followed by the stale state from this loop. + if (engine !== current) break; + if (listeners.has(listener)) runSafely(() => listener(current), "Lifecycle listener failed"); + } }; const controller: RageLayerController = { @@ -97,7 +107,7 @@ export function createRageLayer(options: MountRageLayerOptions = {}): RageLayerC }, subscribe(listener) { listeners.add(listener); - listener(engine); + runSafely(() => listener(engine), "Lifecycle listener failed"); return () => listeners.delete(listener); }, }; diff --git a/src/performance.ts b/src/performance.ts index 55faf50..54ba3a3 100644 --- a/src/performance.ts +++ b/src/performance.ts @@ -1,3 +1,4 @@ +import { runSafely } from "./callbacks"; import { measuredUploadCostMs } from "./gl"; import type { PerfCounterName, PerfCounterSink } from "./perf-counters"; import type { @@ -498,7 +499,12 @@ export class PerformanceMonitor implements PerfCounterSink { captureMs: this.captureMs, memory: memorySnapshot(), }; - for (const callback of this.callbacks) callback(this.latest); + const snapshot = this.latest; + for (const callback of [...this.callbacks]) { + if (this.callbacks.has(callback)) { + runSafely(() => callback(snapshot), "Performance listener failed"); + } + } if (!this.adaptive || this.samples < 12) return null; const cpuOverload = cpu.p95 > targetBudget * 0.72; diff --git a/src/pointer-input.ts b/src/pointer-input.ts index c381868..aa80a69 100644 --- a/src/pointer-input.ts +++ b/src/pointer-input.ts @@ -90,14 +90,19 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll function end(event?: PointerEvent) { if (!held) return; if (event && activePointerId !== null && event.pointerId !== activePointerId) return; - flush(); - held = false; - artUpAt = host.nowSeconds(); - const toolEvent = event ? toToolEvent(event) : { ...pointer, dx: 0, dy: 0, buttons: 0 }; - host.getTool()?.onUp?.(host.engine, toolEvent); - host.silenceToolLoops(); - releasePointerCapture(); - host.requestFrame(); + try { + flush(); + held = false; + artUpAt = host.nowSeconds(); + const toolEvent = event ? toToolEvent(event) : { ...pointer, dx: 0, dy: 0, buttons: 0 }; + host.getTool()?.onUp?.(host.engine, toolEvent); + } finally { + held = false; + artUpAt = host.nowSeconds(); + host.silenceToolLoops(); + releasePointerCapture(); + host.requestFrame(); + } } /** @@ -118,7 +123,8 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll /** Drive the selected tool at a document point without a pointing device. */ function strike(x: number, y: number, holdMs = 0): boolean { const tool = host.getTool(); - if (!tool || host.isBlocked()) return false; + if (!tool || held || host.isBlocked()) return false; + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(holdMs)) return false; flush(); host.checkpoint(tool.id); @@ -127,18 +133,29 @@ export function createPointerInput(host: PointerInputHost): PointerInputControll pointer.y = previous.y = y; artDownAt = host.nowSeconds(); held = true; - tool.onDown?.(host.engine, event); + try { + tool.onDown?.(host.engine, event); + + if (holdMs > 0) { + const dt = 1 / 60; + const steps = Math.min(600, Math.round(holdMs / (dt * 1000))); + for (let i = 0; i < steps; i++) { + if (!held || host.isBlocked() || host.getTool() !== tool) break; + tool.tick?.(host.engine, dt, true, pointer); + } + } - if (holdMs > 0) { - const dt = 1 / 60; - const steps = Math.min(600, Math.round(holdMs / (dt * 1000))); - for (let i = 0; i < steps; i++) tool.tick?.(host.engine, dt, true, pointer); + if (held && !host.isBlocked() && host.getTool() === tool) { + held = false; + artUpAt = host.nowSeconds(); + tool.onUp?.(host.engine, { ...event, buttons: 0 }); + } + } finally { + held = false; + artUpAt = host.nowSeconds(); + host.silenceToolLoops(); + host.requestFrame(); } - - held = false; - artUpAt = host.nowSeconds(); - tool.onUp?.(host.engine, { ...event, buttons: 0 }); - host.requestFrame(); return true; } diff --git a/src/react/RageLayer.tsx b/src/react/RageLayer.tsx index bb85a4c..0a4f8a0 100644 --- a/src/react/RageLayer.tsx +++ b/src/react/RageLayer.tsx @@ -3,7 +3,8 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { defaultTools } from "../default-tools"; -import { RageLayerEngine } from "../engine"; +import type { RageLayerEngine } from "../engine"; +import { mountRageLayer } from "../mount"; import type { RageLayerStrings } from "../strings"; import { type ToolbarButton as ButtonState, ToolbarModel, type ToolbarState } from "../toolbar"; import type { RageLayerEngineOptions, Tool, ToolStyle } from "../types"; @@ -110,12 +111,13 @@ export function RageLayer({ const resolvedToolStyle = engineOptions?.toolStyle ?? toolStyle; useEffect(() => { - const engine = new RageLayerEngine({ + const engine = mountRageLayer({ soundEnabled: soundDefault, ...engineOptions, toolStyle: resolvedToolStyle, + tools: toolset, + initialTool: null, }); - for (const tool of toolset) engine.registerTool(tool); const model = new ToolbarModel(engine, { tools: toolset, diff --git a/src/sdk.ts b/src/sdk.ts index 03ffb61..0599cec 100644 --- a/src/sdk.ts +++ b/src/sdk.ts @@ -79,11 +79,15 @@ export interface RateLimiter { /** Bounded fractional scheduler for particle sprays and other hold-to-use effects. */ export function createRateLimiter(ratePerSecond: number, maxBurst = 8): RateLimiter { + if (!Number.isFinite(ratePerSecond) || !Number.isFinite(maxBurst)) { + throw new RangeError("Rate and maximum burst must be finite"); + } const rate = Math.max(0, ratePerSecond); const limit = Math.max(1, Math.round(maxBurst)); let debt = 0; return { take(dt) { + if (!Number.isFinite(dt) || dt <= 0) return 0; debt = Math.min(limit, debt + Math.max(0, dt) * rate); const count = Math.floor(debt); debt -= count; diff --git a/src/toolbar.ts b/src/toolbar.ts index f33ff2f..6c626ec 100644 --- a/src/toolbar.ts +++ b/src/toolbar.ts @@ -13,6 +13,7 @@ * subscribers whenever anything a view would render has changed. */ +import { runSafely } from "./callbacks"; import type { RageLayerEngine } from "./engine"; import { copyBlobToClipboard, downloadBlob, snapshotFilename } from "./share"; import { type RageLayerStrings, resolveStrings, toolStrings } from "./strings"; @@ -118,8 +119,10 @@ export class ToolbarModel { /** Subscribe to state changes. The callback runs immediately. */ subscribe(listener: (state: ToolbarState) => void): () => void { + if (this.destroyed) return () => {}; this.listeners.add(listener); - listener(this.state); + const state = this.state; + runSafely(() => listener(state), "Toolbar listener failed"); return () => this.listeners.delete(listener); } @@ -127,7 +130,10 @@ export class ToolbarModel { if (this.destroyed) return; this.cached = null; const next = this.state; - for (const listener of this.listeners) listener(next); + for (const listener of [...this.listeners]) { + if (this.destroyed || this.cached !== next) break; + if (this.listeners.has(listener)) runSafely(() => listener(next), "Toolbar listener failed"); + } } private get tools(): readonly Tool[] { diff --git a/src/vue/RageLayer.ts b/src/vue/RageLayer.ts index 59243fc..dadaf0e 100644 --- a/src/vue/RageLayer.ts +++ b/src/vue/RageLayer.ts @@ -22,7 +22,8 @@ import { import { RAGELAYER_IGNORE_ATTR } from "../capture"; import { defaultTools } from "../default-tools"; -import { RageLayerEngine } from "../engine"; +import type { RageLayerEngine } from "../engine"; +import { mountRageLayer } from "../mount"; import type { RageLayerStrings } from "../strings"; import { type ToolbarButton, ToolbarModel, type ToolbarState } from "../toolbar"; import type { RageLayerEngineOptions, Tool } from "../types"; @@ -55,14 +56,15 @@ export const RageLayer = defineComponent({ }; onMounted(() => { - acquireToolbarStyles(); - const created = new RageLayerEngine({ + const created = mountRageLayer({ soundEnabled: props.soundDefault, history: true, ...props.engineOptions, + tools: toolset.value, + initialTool: null, }); - for (const tool of toolset.value) created.registerTool(tool); engine.value = created; + acquireToolbarStyles(); const created_model = new ToolbarModel(created, { tools: toolset.value, @@ -87,9 +89,11 @@ export const RageLayer = defineComponent({ unsubscribe = null; model.value?.destroy(); model.value = null; - engine.value?.dispose(); - engine.value = null; - releaseToolbarStyles(); + if (engine.value) { + engine.value.dispose(); + engine.value = null; + releaseToolbarStyles(); + } if (previousFocus?.isConnected) previousFocus.focus(); previousFocus = null; }); diff --git a/tests/api-boundaries.test.mjs b/tests/api-boundaries.test.mjs new file mode 100644 index 0000000..4044b15 --- /dev/null +++ b/tests/api-boundaries.test.mjs @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { createRageLayer, mountRageLayer } from "../src/mount.ts"; +import { setViewport } from "./support/dom.mjs"; + +const engines = new Set(); +const options = { captureContent: false, postFX: false, initialTool: null }; +const tool = (reset) => ({ id: "probe", name: "Probe", icon: "p", hint: "test", reset }); + +afterEach(() => { + for (const engine of engines) engine.dispose(); + engines.clear(); + document.body.replaceChildren(); +}); + +function mount(extra = {}) { + setViewport(800, 600, 600); + const engine = mountRageLayer({ ...options, ...extra }); + engines.add(engine); + return engine; +} + +describe("host failure boundaries", () => { + test("an error observer removed during notification is not called afterward", () => { + const engine = mount(); + let calls = 0; + let off; + engine.onError(() => off()); + off = engine.onError(() => calls++); + setViewport(800, 600, 40_000); + window.dispatchEvent(new Event("resize")); + expect(calls).toBe(0); + }); + test("a throwing onError cannot abort constructor cleanup registration", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + setViewport(800, 600, 40_000); + let engine; + expect(() => { + engine = mountRageLayer({ + ...options, + onError: () => { + throw new Error("reporter failed"); + }, + }); + engines.add(engine); + }).not.toThrow(); + expect(engine.error.scope).toBe("page-height"); + engine.dispose(); + expect(document.body.contains(engine.container)).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + test("a lifecycle listener can close during open without leaving stale state in peers", () => { + const controller = createRageLayer(options); + controller.subscribe((engine) => { + if (engine) controller.close(); + }); + const seen = []; + controller.subscribe((engine) => seen.push(engine)); + engines.add(controller.open()); + expect(controller.isOpen).toBe(false); + expect(seen.every((engine) => engine === null)).toBe(true); + }); + test("a failed tool initialization rolls back the mounted engine", () => { + setViewport(800, 600, 600); + const before = document.body.childElementCount; + let failedEngine; + expect(() => + mountRageLayer({ + ...options, + tools: [ + tool((engine) => { + failedEngine = engine; + throw new Error("initialization failed"); + }), + ], + }), + ).toThrow("initialization failed"); + engines.add(failedEngine); + expect(document.body.childElementCount).toBe(before); + expect(failedEngine.disposed).toBe(true); + }); + + test("one broken disposal subscriber cannot prevent cleanup or other subscribers", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const engine = mount(); + void engine.damageCtx; + let notified = false; + engine.on("dispose", () => { + throw new Error("host failed"); + }); + engine.on("dispose", () => { + notified = true; + }); + expect(() => engine.dispose()).not.toThrow(); + expect(notified).toBe(true); + expect(engine.damageCtx.canvas.width).toBe(0); + expect(engine.getTools()).toEqual([]); + expect(document.body.contains(engine.container)).toBe(false); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + test("a broken custom reset cannot retain the other tools on disposal", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + let resets = 0; + const engine = mount({ + tools: [ + tool(() => { + if (++resets > 1) throw new Error("reset failed"); + }), + ], + }); + let cleaned = false; + engine.registerTool({ + ...tool(() => { + cleaned = true; + }), + id: "second", + }); + cleaned = false; + expect(() => engine.dispose()).not.toThrow(); + expect(cleaned).toBe(true); + expect(engine.getTools()).toEqual([]); + } finally { + warn.mockRestore(); + } + }); + + test("a broken lifecycle observer does not hide an open or closed engine from peers", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const controller = createRageLayer(options); + let armed = false; + controller.subscribe(() => { + if (armed) throw new Error("observer failed"); + }); + const seen = []; + controller.subscribe((engine) => seen.push(engine)); + armed = true; + let engine; + expect(() => { + engine = controller.open(); + engines.add(engine); + }).not.toThrow(); + expect(seen.at(-1)).toBe(engine); + expect(() => controller.close()).not.toThrow(); + expect(seen.at(-1)).toBeNull(); + expect(controller.isOpen).toBe(false); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/tests/element.test.mjs b/tests/element.test.mjs index f6c5f85..c5fb20d 100644 --- a/tests/element.test.mjs +++ b/tests/element.test.mjs @@ -72,6 +72,33 @@ describe("registration", () => { }); describe("mounting", () => { + test("a failed custom tool setup leaves no hidden engine after reconfiguration", () => { + mount((node) => node.configure({ captureContent: false })); + const previous = element.rageLayerEngine; + let failed; + expect(() => + element.configure({ + captureContent: false, + tools: [ + { + id: "broken", + name: "Broken", + icon: "!", + hint: "test", + reset(engine) { + failed = engine; + throw new Error("tool setup failed"); + }, + }, + ], + }), + ).toThrow("tool setup failed"); + expect(previous.disposed).toBe(true); + expect(failed.disposed).toBe(true); + expect(document.body.contains(failed.container)).toBe(false); + element.configure({ captureContent: false, tools: [hammer] }); + expect(element.rageLayerEngine.getTools().map((tool) => tool.id)).toEqual(["hammer"]); + }); test("connecting builds a labelled toolbar in a shadow root", () => { mount((node) => node.configure({ tools: [hammer], captureContent: false })); diff --git a/tests/history.test.mjs b/tests/history.test.mjs index 6e3cb2a..af591ab 100644 --- a/tests/history.test.mjs +++ b/tests/history.test.mjs @@ -12,6 +12,34 @@ const entry = (label, pixelCost = 10) => ({ }); describe("DestructionHistory", () => { + test("nonfinite limits retain the default bounded history", () => { + for (const value of [NaN, Infinity, -Infinity]) { + const history = new DestructionHistory({ maxEntries: value, maxPixels: value }); + for (let i = 0; i < 30; i++) history.push(entry(String(i))); + expect(history.state.undoDepth).toBe(6); + expect(history.canStore(24_000_001)).toBe(false); + } + }); + + test("invalid entry costs cannot disable the pixel budget on any stack", () => { + for (const cost of [-1, NaN, Infinity, -Infinity]) { + const history = new DestructionHistory({ maxPixels: 100 }); + const invalid = entry("invalid", cost); + expect(history.push(invalid)).toBe(false); + expect(invalid.disposed).toBe(true); + history.push(entry("valid")); + const current = entry("invalid current", cost); + expect(history.undo(current)?.label).toBe("valid"); + expect(current.disposed).toBe(true); + expect(history.state.canRedo).toBe(false); + history.push(entry("valid")); + history.undo(entry("current")); + const redo = entry("invalid redo", cost); + expect(history.redo(redo)?.label).toBe("current"); + expect(redo.disposed).toBe(true); + expect(history.state.canUndo).toBe(false); + } + }); test("moves current states between bounded undo and redo stacks", () => { const history = new DestructionHistory({ maxEntries: 2, maxPixels: 1_000_000 }); const a = entry("a"); diff --git a/tests/performance.test.mjs b/tests/performance.test.mjs index f62c6b5..8810c0b 100644 --- a/tests/performance.test.mjs +++ b/tests/performance.test.mjs @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { detectInitialQuality, PerformanceMonitor, QUALITY_PROFILES } from "../src/performance.ts"; /** Controllable clock: the monitor samples `performance.now()` directly. */ @@ -48,6 +48,22 @@ function run(monitor, frames, intervalMs = 1000) { } describe("PerformanceMonitor percentiles", () => { + test("a throwing telemetry subscriber cannot interrupt frame recording", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const monitor = new PerformanceMonitor(undefined, "high"); + const seen = []; + monitor.onSample(() => { + throw new Error("telemetry consumer failed"); + }); + monitor.onSample((snapshot) => seen.push(snapshot)); + expect(() => run(monitor, [measurement()])).not.toThrow(); + expect(seen).toHaveLength(1); + expect(seen[0].frames).toBe(1); + } finally { + warn.mockRestore(); + } + }); test("p50/p95/p99/max over a known distribution", () => { const monitor = new PerformanceMonitor(undefined, "high"); // 100 distinct frame times 1..100 ms, shuffled deterministically so the diff --git a/tests/pointer-input.test.mjs b/tests/pointer-input.test.mjs index 855bfe1..a0fa65b 100644 --- a/tests/pointer-input.test.mjs +++ b/tests/pointer-input.test.mjs @@ -42,6 +42,57 @@ function makeInput(tool, overrides = {}) { } describe("PointerInputController", () => { + test("a throwing release handler still releases pointer capture and silences audio", () => { + let silenced = 0; + let released = 0; + const { container, input } = makeInput( + { + onUp: () => { + throw new Error("release failed"); + }, + }, + { + silenceToolLoops: () => silenced++, + }, + ); + container.releasePointerCapture = () => released++; + container.dispatchEvent(pointerEvent("pointerdown", 10, 10)); + expect(() => input.end()).toThrow("release failed"); + expect(input.held).toBe(false); + expect(silenced).toBe(1); + expect(released).toBe(1); + }); + test("scripted strikes reject invalid coordinates and overlapping gestures", () => { + let downs = 0; + const { input, container } = makeInput({ onDown: () => downs++ }); + for (const value of [NaN, Infinity, -Infinity]) { + expect(input.strike(value, 10)).toBe(false); + expect(input.strike(10, value)).toBe(false); + expect(input.strike(10, 10, value)).toBe(false); + } + expect(downs).toBe(0); + container.dispatchEvent(pointerEvent("pointerdown", 10, 10)); + expect(input.strike(20, 20)).toBe(false); + expect(input.held).toBe(true); + expect(downs).toBe(1); + }); + + test("a throwing scripted tool releases gesture state and audio loops", () => { + let silenced = 0; + const { input } = makeInput( + { + onDown: () => { + throw new Error("tool failed"); + }, + }, + { + silenceToolLoops: () => silenced++, + }, + ); + expect(() => input.strike(10, 10)).toThrow("tool failed"); + expect(input.held).toBe(false); + expect(silenced).toBe(1); + }); test("buffers a path in order and bounds an event storm", () => { const moves = []; const { container, input } = makeInput({ diff --git a/tests/react-component.test.mjs b/tests/react-component.test.mjs index 87eba3b..bfc0072 100644 --- a/tests/react-component.test.mjs +++ b/tests/react-component.test.mjs @@ -44,6 +44,27 @@ afterEach(async () => { }); describe("shared toolbar state", () => { + test("failed custom tool setup disposes the engine before propagating the error", async () => { + let failed; + await expect( + mount({ + tools: [ + { + id: "broken", + name: "Broken", + icon: "!", + hint: "test", + reset(engine) { + failed = engine; + throw new Error("tool setup failed"); + }, + }, + ], + }), + ).rejects.toThrow("tool setup failed"); + expect(failed.disposed).toBe(true); + expect(document.body.contains(failed.container)).toBe(false); + }); test("renders translated labels and a visible tool instruction", async () => { await mount({ strings: { toolbarLabel: "Outils de destruction" } }); diff --git a/tests/sdk.test.mjs b/tests/sdk.test.mjs index 089928f..b266cf1 100644 --- a/tests/sdk.test.mjs +++ b/tests/sdk.test.mjs @@ -38,6 +38,20 @@ describe("custom tool SDK", () => { expect(limiter.take(0)).toBe(0); }); + test("invalid scheduler configuration fails before creating poisoned state", () => { + for (const value of [NaN, Infinity, -Infinity]) { + expect(() => createRateLimiter(value)).toThrow(RangeError); + expect(() => createRateLimiter(10, value)).toThrow(RangeError); + } + }); + + test("invalid elapsed time does not poison the fractional accumulator", () => { + const limiter = createRateLimiter(10, 3); + expect(limiter.take(0.05)).toBe(0); + for (const value of [NaN, Infinity, -Infinity, -1]) expect(limiter.take(value)).toBe(0); + expect(limiter.take(0.05)).toBe(1); + }); + test("typed autonomous work shares the factory's isolated state", () => { const tool = createTool({ id: "timer", diff --git a/tests/toolbar.test.mjs b/tests/toolbar.test.mjs index 9d00c17..042f468 100644 --- a/tests/toolbar.test.mjs +++ b/tests/toolbar.test.mjs @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { DEFAULT_STRINGS, formatString, resolveStrings, toolStrings } from "../src/strings.ts"; import { ToolbarModel } from "../src/toolbar.ts"; import { baseTools, broom, hammer } from "../src/tools.ts"; @@ -102,6 +102,32 @@ describe("button list", () => { }); describe("subscriptions", () => { + test("a throwing observer cannot leave the other toolbar views stale", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const { model } = makeToolbar(); + model.subscribe((state) => { + if (state.activeToolId) throw new Error("view failed"); + }); + const seen = []; + model.subscribe((state) => seen.push(state.activeToolId)); + model.selectTool("hammer"); + expect(seen).toEqual([null, "hammer"]); + } finally { + warn.mockRestore(); + } + }); + + test("reentrant selection cannot overwrite a newer toolbar state", () => { + const { model } = makeToolbar(); + model.subscribe((state) => { + if (state.activeToolId === "hammer") model.selectTool("broom"); + }); + const seen = []; + model.subscribe((state) => seen.push(state.activeToolId)); + model.selectTool("hammer"); + expect(seen).toEqual([null, "broom"]); + }); test("subscribers are called immediately and on change", () => { const { model } = makeToolbar(); const seen = []; diff --git a/tests/vue-component.test.mjs b/tests/vue-component.test.mjs index 5486d59..4c12da2 100644 --- a/tests/vue-component.test.mjs +++ b/tests/vue-component.test.mjs @@ -22,10 +22,11 @@ let host; * guard), and Vue applies that state change on the next tick — so every test * has to await one before the toolbar exists. */ -async function mount(props = {}) { +async function mount(props = {}, onError) { host = document.createElement("div"); document.body.appendChild(host); app = createApp({ render: () => h(RageLayer, props) }); + if (onError) app.config.errorHandler = onError; app.mount(host); await nextTick(); return app; @@ -50,6 +51,31 @@ afterEach(() => { }); describe("rendering", () => { + test("failed custom tool setup disposes the engine before reporting the error", async () => { + let failed; + const errors = []; + await mount( + { + engineOptions: { captureContent: false }, + tools: [ + { + id: "broken", + name: "Broken", + icon: "!", + hint: "test", + reset(engine) { + failed = engine; + throw new Error("tool setup failed"); + }, + }, + ], + }, + (error) => errors.push(error), + ); + expect(errors.map((error) => error.message)).toEqual(["tool setup failed"]); + expect(failed.disposed).toBe(true); + expect(document.body.contains(failed.container)).toBe(false); + }); test("a labelled toolbar is teleported into the body", async () => { await mount({ tools: [hammer], engineOptions: { captureContent: false } });