from __future__ import annotations import subprocess import textwrap from pathlib import Path def test_dashboard_overlay_cleans_timers_and_ignores_late_fetches(): plugin_root = Path(__file__).resolve().parents[1] script = textwrap.dedent( f""" (async function () {{ const assert = require('assert'); const fs = require('fs'); const vm = require('vm'); const timers = new Map(); let nextTimer = 1; const events = []; const cleanups = []; let registeredComponent = null; let stateValue = {{ enabled: true, mode: 'idle', mood: 'calm', name: 'Signal', revision: 1 }}; let errorWrites = 0; let stateWrites = 0; function makeElement(type, props, ...children) {{ return {{ type, props: props || {{}}, children }}; }} const React = {{ createElement: makeElement, useState(initial) {{ if (initial === null) {{ const setter = function (value) {{ stateWrites += 1; stateValue = value; }}; return [stateValue, setter]; }} const setter = function () {{ errorWrites += 1; }}; return [initial, setter]; }}, useRef(initial) {{ return {{ current: initial }}; }}, useEffect(fn) {{ const cleanup = fn(); if (cleanup) cleanups.push(cleanup); }}, }}; const windowObj = {{ __HERMES_PLUGIN_SDK__: {{ React, hooks: React, authedFetch(url, options) {{ if (options && options.method === 'POST') {{ events.push(JSON.parse(options.body).type); }} return Promise.resolve({{ ok: true, json: () => Promise.resolve({{ ok: true, state: stateValue }}), }}); }}, }}, __HERMES_PLUGINS__: {{ registerSlot(plugin, slot, component) {{ registeredComponent = component; }} }}, setTimeout(fn, delay) {{ const id = nextTimer++; timers.set(id, {{ fn, delay }}); return id; }}, clearTimeout(id) {{ timers.delete(id); }}, setInterval(fn, delay) {{ const id = nextTimer++; timers.set(id, {{ fn, delay, interval: true }}); return id; }}, clearInterval(id) {{ timers.delete(id); }}, }}; const context = {{ window: windowObj, fetch: windowObj.__HERMES_PLUGIN_SDK__.authedFetch, console }}; vm.createContext(context); vm.runInContext(fs.readFileSync({str(plugin_root / 'dashboard/dist/index.js')!r}, 'utf8'), context); assert.equal(typeof registeredComponent, 'function'); const tree = registeredComponent(); const avatar = tree.children[1]; avatar.props.onMouseEnter(); avatar.props.onMouseEnter(); const hoverTimers = Array.from(timers.values()).filter(t => t.delay === 1000); assert.equal(hoverTimers.length, 1, 'duplicate hover enters should leave one hover timer'); for (const cleanup of cleanups) cleanup(); assert.equal(timers.size, 0, 'unmount should clear interval, hover, and transition timers'); avatar.props.onClick(); await Promise.resolve(); assert.deepEqual(events, ['click']); assert.equal(stateWrites, 0, 'late fetch resolution after unmount must not write state'); assert.equal(errorWrites, 0, 'late fetch resolution after unmount must not write errors'); }})(); """ ) result = subprocess.run(["node", "-e", script], text=True, capture_output=True, check=False) assert result.returncode == 0, result.stdout + result.stderr