| |
| |
| |
| |
| |
| |
| |
|
|
| 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'); |
|
|
| |
| |
| |
| |
| 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); |
| }, |
| }; |
| } |
|
|