// Bundles a tiny `extends Panel` subclass with no constructor-only-UI // override, so tests can prove the BASE-CLASS unlock-restore behavior // without depending on any specific premium panel's implementation. // // Mirrors the structure of chat-analyst-panel-harness.mjs (same stubs, // same browser-environment shim) but the esbuild entry is a virtual // in-memory file rather than a real source file. import { build } from 'esbuild'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { createBrowserEnvironment } from './runtime-config-panel-harness.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const root = resolve(__dirname, '..', '..'); function snapshotGlobal(name) { return { exists: Object.prototype.hasOwnProperty.call(globalThis, name), value: globalThis[name], }; } function restoreGlobal(name, snapshot) { if (snapshot.exists) { Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: snapshot.value, }); return; } delete globalThis[name]; } function defineGlobal(name, value) { Object.defineProperty(globalThis, name, { configurable: true, writable: true, value, }); } async function loadMinimalPanel() { const tempDir = mkdtempSync(join(tmpdir(), 'wm-minimal-panel-')); const outfile = join(tempDir, 'MinimalPanel.bundle.mjs'); // Virtual entry source — defines a minimal `extends Panel` subclass that // builds its UI ONCE in the constructor (the exact shape that triggers // the unlock-wipe bug). Imports the REAL Panel base class so the test // exercises the actual unlock/restore code path. const panelImportPath = resolve(root, 'src/components/Panel.ts').replace(/\\/g, '/'); const domUtilsPath = resolve(root, 'src/utils/dom-utils.ts').replace(/\\/g, '/'); const dataFreshnessPath = resolve(root, 'src/services/data-freshness.ts').replace(/\\/g, '/'); const virtualEntrySource = ` import { Panel } from '${panelImportPath}'; import { h, replaceChildren } from '${domUtilsPath}'; import { dataFreshness } from '${dataFreshnessPath}'; export { dataFreshness }; // Module-level counter so a test can prove the ctor (and thus the // initial DOM build) runs ONLY ONCE per panel instance — the whole // point of base-class restore is to avoid rebuilding. export let constructorRunCount = 0; export function resetConstructorRunCount() { constructorRunCount = 0; } export class MinimalConstructorOnlyPanel extends Panel { static MARKER_CLASS = 'minimal-test-wrapper'; static INPUT_CLASS = 'minimal-test-input'; constructor(options = {}) { super({ id: options.id ?? 'minimal-test', title: options.title ?? 'Minimal Test', collapsible: options.collapsible, defaultRowSpan: options.defaultRowSpan, className: options.className, }); constructorRunCount += 1; // Build UI ONCE — no buildUI() method, no override of unlockPanel. // This mirrors DeductionPanel's shape (src/components/DeductionPanel.ts:30-77). const input = h('textarea', { className: MinimalConstructorOnlyPanel.INPUT_CLASS }); const wrapper = h('div', { className: MinimalConstructorOnlyPanel.MARKER_CLASS }, input); replaceChildren(this.content, wrapper); } publicRunWhenConnected(callback) { return this.runWhenConnected(callback); } publicNotifyConnected() { this.notifyConnected(); } } export class FreshnessMappedPanel extends Panel { constructor() { super({ id: 'polymarket', title: 'Mapped Freshness', showCount: true, closable: true, collapsible: true }); } publicDataBadge(state, detail) { this.setDataBadge(state, detail); } } `; const stubModules = new Map([ ['i18n-stub', ` function interpolate(value, options = {}) { return String(value).replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_, key) => String(options[key] ?? '')); } export function t(key, options = {}) { if (typeof options.defaultValue === 'string') return interpolate(options.defaultValue, options); return key; } `], ['runtime-stub', `export function isDesktopRuntime() { return false; }`], ['tauri-bridge-stub', `export function invokeTauri() { return Promise.reject(new Error('not wired in test')); }`], ['analytics-stub', `export function trackPanelResized() {}`], ['ai-flow-settings-stub', `export function getAiFlowSettings() { return { badgeAnimation: false }; }`], ['runtime-config-stub', `export function getSecretState() { return { present: true }; }`], ['panel-gating-stub', ` export const PanelGateReason = Object.freeze({ NONE: 'none', ANONYMOUS: 'anonymous', FREE_TIER: 'free_tier', }); `], ['checkout-stub', `export function startCheckout() {}`], ['products-stub', `export const DEFAULT_UPGRADE_PRODUCT = 'pro';`], ['theme-colors-stub', `export function getCSSColor() { return '#000'; }`], ['virtual-entry', virtualEntrySource], ]); const aliasMap = new Map([ ['@/services/i18n', 'i18n-stub'], ['../services/i18n', 'i18n-stub'], ['@/services/runtime', 'runtime-stub'], ['../services/runtime', 'runtime-stub'], ['@/services/tauri-bridge', 'tauri-bridge-stub'], ['../services/tauri-bridge', 'tauri-bridge-stub'], ['@/services/analytics', 'analytics-stub'], ['@/services/ai-flow-settings', 'ai-flow-settings-stub'], ['@/services/runtime-config', 'runtime-config-stub'], ['@/services/panel-gating', 'panel-gating-stub'], ['@/services/checkout', 'checkout-stub'], ['@/config/products', 'products-stub'], ['@/utils/theme-colors', 'theme-colors-stub'], ['virtual:minimal-entry', 'virtual-entry'], ]); const plugin = { name: 'minimal-panel-test-stubs', setup(buildApi) { buildApi.onResolve({ filter: /.*/ }, (args) => { const target = aliasMap.get(args.path); return target ? { path: target, namespace: 'stub' } : null; }); buildApi.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => ({ contents: stubModules.get(args.path), loader: 'ts', resolveDir: root, })); }, }; const result = await build({ entryPoints: [{ in: 'virtual:minimal-entry', out: 'MinimalPanel.bundle' }], bundle: true, format: 'esm', platform: 'browser', target: 'es2020', write: false, plugins: [plugin], }); writeFileSync(outfile, result.outputFiles[0].text, 'utf8'); const mod = await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`); return { MinimalConstructorOnlyPanel: mod.MinimalConstructorOnlyPanel, FreshnessMappedPanel: mod.FreshnessMappedPanel, dataFreshness: mod.dataFreshness, getConstructorRunCount: () => mod.constructorRunCount, resetConstructorRunCount: mod.resetConstructorRunCount, cleanupBundle() { rmSync(tempDir, { recursive: true, force: true }); }, }; } export async function createMinimalPanelHarness() { const originalGlobals = { document: snapshotGlobal('document'), window: snapshotGlobal('window'), localStorage: snapshotGlobal('localStorage'), requestAnimationFrame: snapshotGlobal('requestAnimationFrame'), cancelAnimationFrame: snapshotGlobal('cancelAnimationFrame'), navigator: snapshotGlobal('navigator'), HTMLElement: snapshotGlobal('HTMLElement'), HTMLButtonElement: snapshotGlobal('HTMLButtonElement'), Node: snapshotGlobal('Node'), }; const browserEnvironment = createBrowserEnvironment(); const MiniNode = Object.getPrototypeOf(browserEnvironment.HTMLElement.prototype).constructor; defineGlobal('document', browserEnvironment.document); defineGlobal('window', browserEnvironment.window); defineGlobal('localStorage', browserEnvironment.localStorage); defineGlobal('requestAnimationFrame', browserEnvironment.requestAnimationFrame); defineGlobal('cancelAnimationFrame', browserEnvironment.cancelAnimationFrame); defineGlobal('navigator', browserEnvironment.window.navigator); defineGlobal('HTMLElement', browserEnvironment.HTMLElement); defineGlobal('HTMLButtonElement', browserEnvironment.HTMLButtonElement); defineGlobal('Node', MiniNode); let MinimalConstructorOnlyPanel; let FreshnessMappedPanel; let dataFreshness; let getConstructorRunCount; let resetConstructorRunCount; let cleanupBundle; try { ({ MinimalConstructorOnlyPanel, FreshnessMappedPanel, dataFreshness, getConstructorRunCount, resetConstructorRunCount, cleanupBundle, } = await loadMinimalPanel()); } catch (error) { restoreGlobal('document', originalGlobals.document); restoreGlobal('window', originalGlobals.window); restoreGlobal('localStorage', originalGlobals.localStorage); restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame); restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame); restoreGlobal('navigator', originalGlobals.navigator); restoreGlobal('HTMLElement', originalGlobals.HTMLElement); restoreGlobal('HTMLButtonElement', originalGlobals.HTMLButtonElement); restoreGlobal('Node', originalGlobals.Node); throw error; } return { document: browserEnvironment.document, window: browserEnvironment.window, localStorage: browserEnvironment.localStorage, createPanel: (options) => new MinimalConstructorOnlyPanel(options), createFreshnessPanel: () => new FreshnessMappedPanel(), dataFreshness, getConstructorRunCount, resetConstructorRunCount, cleanup() { cleanupBundle(); restoreGlobal('document', originalGlobals.document); restoreGlobal('window', originalGlobals.window); restoreGlobal('localStorage', originalGlobals.localStorage); restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame); restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame); restoreGlobal('navigator', originalGlobals.navigator); restoreGlobal('HTMLElement', originalGlobals.HTMLElement); restoreGlobal('HTMLButtonElement', originalGlobals.HTMLButtonElement); restoreGlobal('Node', originalGlobals.Node); }, }; }