/** * E2E Automated Test for Text Block Timer Plugin * * Connects to Obsidian's remote debugging port via CDP (Chrome DevTools Protocol) * WebSocket, then uses Runtime.evaluate to call app.commands.executeCommandById() * and inspect plugin state. Validates data correctness across: * - Editor line content (timer HTML span) * - In-memory TimerManager state * - TimerDatabase entries & daily_dur * - IndexedDB (real-time authoritative cache) * - Sidebar DOM elements (list, summary, chart) * - Status bar display * - Cross-layer consistency (IDB ↔ JSON ↔ TimerManager) * * Usage: * 1. Start Obsidian with --remote-debugging-port=9222 * 2. Open a markdown file (will be used as the test target) * 3. Run: node tests/e2e-timer-test.mjs * * Prerequisites: * - Node.js 18+ (uses native WebSocket) * - Obsidian running with remote debugging enabled */ // ─── Configuration ─────────────────────────────────────────────────────────── export const CDP_HOST = 'localhost'; export const CDP_PORT = 9222; export const WAIT_AFTER_COMMAND = 2000; // ms to wait after each command for effects to settle export const WAIT_TICK_ACCUMULATE = 6000; // ms to let a running timer accumulate some seconds export const TICK_TOLERANCE = 2; // seconds tolerance for tick-based assertions // ─── CDP Helper ────────────────────────────────────────────────────────────── class CDPClient { constructor(wsUrl) { this.wsUrl = wsUrl; this.ws = null; this.msgId = 0; this.pending = new Map(); } async connect() { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.wsUrl); this.ws.addEventListener('open', () => resolve()); this.ws.addEventListener('error', (e) => reject(e)); this.ws.addEventListener('message', (evt) => { const raw = (typeof evt === 'string') ? evt : (evt.data || evt); this._onMessage(raw); }); }); } _onMessage(raw) { try { const text = typeof raw === 'string' ? raw : raw.toString(); const msg = JSON.parse(text); if (msg.id !== undefined && this.pending.has(msg.id)) { this.pending.get(msg.id)(msg); this.pending.delete(msg.id); } } catch (e) { /* ignore */ } } async send(method, params = {}) { const id = ++this.msgId; return new Promise((resolve, reject) => { this.pending.set(id, resolve); this.ws.send(JSON.stringify({ id, method, params })); setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`CDP timeout for ${method} (id=${id})`)); } }, 30000); }); } async evaluate(expression, awaitPromise = false) { const resp = await this.send('Runtime.evaluate', { expression, awaitPromise, returnByValue: true }); if (resp.result && resp.result.result && resp.result.result.value !== undefined) { return resp.result.result.value; } if (resp.result && resp.result.exceptionDetails) { const d = resp.result.exceptionDetails; const desc = d.exception && d.exception.description ? d.exception.description : JSON.stringify(d); throw new Error(`JS Error: ${desc}`); } return resp.result ? resp.result.result : undefined; } close() { if (this.ws) this.ws.close(); } } // ─── Utility ───────────────────────────────────────────────────────────────── export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } export function assert(cond, msg) { if (!cond) throw new Error(`ASSERTION FAILED: ${msg}`); } export function log(emoji, msg) { console.log(`${emoji} ${msg}`); } // ─── Discover Obsidian WebSocket URL ───────────────────────────────────────── async function discoverWsUrl() { const resp = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json`); const targets = await resp.json(); const obsidian = targets.find(t => t.type === 'page' && ((t.url && t.url.includes('obsidian')) || (t.title && t.title.toLowerCase().includes('obsidian'))) ); if (!obsidian) throw new Error('Obsidian page not found in CDP targets.'); log('🔗', `Found Obsidian: "${obsidian.title}"`); return obsidian.webSocketDebuggerUrl; } // ─── Test Runner ───────────────────────────────────────────────────────────── class TestRunner { constructor(cdp) { this.cdp = cdp; this.passed = 0; this.failed = 0; this.errors = []; } async run(name, fn) { log('🧪', `TEST: ${name}`); try { await fn(); this.passed++; log('✅', `PASS: ${name}`); } catch (e) { this.failed++; this.errors.push({ name, error: e.message }); log('❌', `FAIL: ${name} — ${e.message}`); } } summary() { console.log('\n' + '═'.repeat(60)); log('📊', `Results: ${this.passed} passed, ${this.failed} failed, ${this.passed + this.failed} total`); if (this.errors.length > 0) { console.log('\nFailures:'); for (const { name, error } of this.errors) { console.log(` ❌ ${name}: ${error}`); } } console.log('═'.repeat(60)); return this.failed === 0; } async eval(expr) { return this.cdp.evaluate(expr); } async evalAsync(expr) { return this.cdp.evaluate(`(async () => { ${expr} })()`, true); } async executeCommand(id) { return this.evalAsync(`await app.commands.executeCommandById('${id}');`); } async setCursorToLine(n) { return this.eval(`(() => { const v = app.workspace.activeEditor; if (!v?.editor) return false; v.editor.setCursor({ line: ${n}, ch: 0 }); return true; })()`); } } // ─── IDB eval helpers ──────────────────────────────────────────────────────── // These helpers call the plugin's idb instance from within Obsidian's runtime. /** Read a single timer from IDB */ export function idbGetTimer(timerId) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const entry = await idb.getTimer('${timerId}'); return entry ? JSON.stringify(entry) : null; `; } /** Read all daily_dur records for a timer from IDB */ export function idbGetDailyByTimer(timerId) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getDailyDurByTimer('${timerId}'); return JSON.stringify(recs); `; } /** Read all daily_dur records for today from IDB */ export function idbGetDailyByDate(date) { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getDailyDurByDate('${date}'); return JSON.stringify(recs); `; } /** Read all daily_dur records from IDB */ export function idbGetAllDaily() { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const recs = await idb.getAllDailyDur(); return JSON.stringify(recs); `; } /** Read all timers from IDB */ export function idbGetAllTimers() { return ` const idb = app.plugins.plugins['text-block-timer'].idb; const entries = await idb.getAllTimers(); return JSON.stringify(entries); `; } /** Read timers by state from IDB */ export function idbGetTimersByState(states) { const statesStr = JSON.stringify(states); return ` const idb = app.plugins.plugins['text-block-timer'].idb; const entries = await idb.getTimersByState(${statesStr}); return JSON.stringify(entries); `; } // ═════════════════════════════════════════════════════════════════════════════ // TEST CHAINS // Each chain is an independent group of tests that can be run individually. // Dependency: basic/adjust/seed/delete all require preflight. // crossday only requires preflight. // // Available chains: // preflight — plugin loaded, editor open, IDB ready // basic — CRUD lifecycle, sidebar list/summary, IDB consistency // sidebar_tabs — scope switching, filter/sort, summary, chart data, statistics toggle // readonly — timer ticking in preview/reading mode // adjust — manual set duration + IDB adjustDailyDur // seed — seedIndexedDB / clearAll stale data // delete — delete timer, sidebar removal, final consistency // crossday — cross-midnight timer via Date monkey-patch // crossday_adjust — multi-day timer via multiple Date patches + manual increase/decrease allocation // // Usage: // node tests/e2e-timer-test.mjs # run ALL chains // node tests/e2e-timer-test.mjs crossday # run only crossday // node tests/e2e-timer-test.mjs crossday_adjust # run multi-day adjust test // node tests/e2e-timer-test.mjs sidebar_tabs # run only sidebar_tabs // node tests/e2e-timer-test.mjs readonly # run only readonly // node tests/e2e-timer-test.mjs basic,adjust # run basic + adjust // ═════════════════════════════════════════════════════════════════════════════ // Shared state across chains (populated by preflight/basic) export const shared = { today: null, testLineNum: null, createdTimerId: null, initial: null, pausedDur: 0, preAdjustDur: 0, preAdjustDailySum: 0, }; // ─── Chain Imports ─────────────────────────────────────────────────────────── import { chain_preflight } from './chains/preflight.mjs'; import { chain_basic } from './chains/basic.mjs'; import { chain_adjust } from './chains/adjust.mjs'; import { chain_seed } from './chains/seed.mjs'; import { chain_delete } from './chains/delete.mjs'; import { chain_crossday } from './chains/crossday.mjs'; import { chain_sidebar_tabs } from './chains/sidebar_tabs.mjs'; import { chain_readonly } from './chains/readonly.mjs'; import { chain_crossday_adjust } from './chains/crossday_adjust.mjs'; import { chain_passive_delete } from './chains/passive_delete.mjs'; import { chain_checkbox } from './chains/checkbox.mjs'; import { chain_crash_recovery } from './chains/crash_recovery.mjs'; import { chain_settings_behavior } from './chains/settings_behavior.mjs'; import { chain_restore_behavior } from './chains/restore_behavior.mjs'; import { chain_onunload_behavior } from './chains/onunload_behavior.mjs'; import { chain_cleanup_basic } from './chains/cleanup_basic.mjs'; // CHAIN REGISTRY & DISPATCHER // ═════════════════════════════════════════════════════════════════════════════ // Chain definitions with their dependencies and cleanup requirements. // When a chain is selected, its dependencies are automatically prepended. const CHAIN_REGISTRY = { preflight: { fn: chain_preflight, deps: [], cleanup: null }, basic: { fn: chain_basic, deps: ['preflight'], cleanup: 'cleanup_basic' }, adjust: { fn: chain_adjust, deps: ['preflight', 'basic'], cleanup: 'cleanup_basic' }, seed: { fn: chain_seed, deps: ['preflight', 'basic'], cleanup: 'cleanup_basic' }, delete: { fn: chain_delete, deps: ['preflight', 'basic'], cleanup: 'cleanup_basic' }, sidebar_tabs: { fn: chain_sidebar_tabs, deps: ['preflight', 'basic'], cleanup: 'cleanup_basic' }, readonly: { fn: chain_readonly, deps: ['preflight', 'basic'], cleanup: 'cleanup_basic' }, crossday: { fn: chain_crossday, deps: ['preflight'], cleanup: null }, crossday_adjust: { fn: chain_crossday_adjust, deps: ['preflight'], cleanup: null }, passive_delete: { fn: chain_passive_delete, deps: ['preflight'], cleanup: null }, checkbox: { fn: chain_checkbox, deps: ['preflight'], cleanup: null }, crash_recovery: { fn: chain_crash_recovery, deps: ['preflight'], cleanup: null }, settings_behavior: { fn: chain_settings_behavior, deps: ['preflight'], cleanup: null }, restore_behavior: { fn: chain_restore_behavior, deps: ['preflight'], cleanup: null }, onunload_behavior: { fn: chain_onunload_behavior, deps: ['preflight'], cleanup: null }, cleanup_basic: { fn: chain_cleanup_basic, deps: [], cleanup: null }, }; // Default run order when no chains specified (= run everything) const ALL_CHAINS = ['preflight', 'basic', 'sidebar_tabs', 'readonly', 'adjust', 'seed', 'delete', 'passive_delete', 'checkbox', 'crash_recovery', 'settings_behavior', 'restore_behavior', 'onunload_behavior', 'crossday', 'crossday_adjust', 'cleanup_basic']; function resolveChains(requestedNames) { const ordered = []; const seen = new Set(); const cleanups = new Set(); function addChain(name) { if (seen.has(name)) return; const entry = CHAIN_REGISTRY[name]; if (!entry) throw new Error(`Unknown test chain: "${name}"`); // Add dependencies first for (const dep of entry.deps) addChain(dep); seen.add(name); ordered.push(name); // Track required cleanups if (entry.cleanup) cleanups.add(entry.cleanup); } for (const name of requestedNames) addChain(name); // Append cleanups at the end (if not already included) for (const c of cleanups) { if (!seen.has(c)) { seen.add(c); ordered.push(c); } } return ordered; } // ─── Main ──────────────────────────────────────────────────────────────────── async function main() { // ── Auto-start Obsidian with remote debugging ────────────────────── const OBSIDIAN_EXE = 'C:\\Users\\frankthwang\\AppData\\Local\\Programs\\Obsidian\\Obsidian.exe'; const OBSIDIAN_PORT = 9222; // Check if Obsidian is already running with debug port let obsidianReady = false; try { const checkResp = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json`); if (checkResp.ok) obsidianReady = true; } catch { /* not running */ } if (!obsidianReady) { log('🚀', 'Starting Obsidian with remote debugging...'); const { execSync } = await import('child_process'); execSync(`Start-Process "${OBSIDIAN_EXE}" -ArgumentList "--remote-debugging-port=${OBSIDIAN_PORT}"`, { shell: 'powershell.exe' }); // Wait for Obsidian to start and CDP to become available for (let attempt = 0; attempt < 30; attempt++) { await new Promise(r => setTimeout(r, 2000)); try { const resp = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json`); if (resp.ok) { obsidianReady = true; break; } } catch { /* retry */ } if (attempt % 5 === 4) log('⏳', `Waiting for Obsidian... (${attempt + 1}/30)`); } if (!obsidianReady) throw new Error('Obsidian failed to start within 60 seconds'); log('✅', 'Obsidian is ready'); } else { log('✅', 'Obsidian already running with debug port'); } // Parse command-line arguments for chain selection const args = process.argv.slice(2); let requestedChains; if (args.length > 0) { // Support: node test.mjs crossday OR node test.mjs basic,adjust requestedChains = args.flatMap(a => a.split(',').map(s => s.trim()).filter(Boolean)); } else { requestedChains = ALL_CHAINS; } const chainsToRun = resolveChains(requestedChains); console.log('═'.repeat(60)); log('🚀', 'Text Block Timer — E2E Sidebar & IDB Test Suite'); log('📋', `Chains to run: ${chainsToRun.join(' → ')}`); if (args.length > 0) { log('📋', `(requested: ${requestedChains.join(', ')})`); } console.log('═'.repeat(60)); const wsUrl = await discoverWsUrl(); log('🔗', `WebSocket URL: ${wsUrl}`); const cdp = new CDPClient(wsUrl); await cdp.connect(); log('✅', 'Connected to Obsidian via CDP'); await cdp.send('Runtime.enable'); // Reload plugin to ensure latest build log('🔄', 'Reloading plugin...'); // Enable Console domain to capture console messages during plugin reload await cdp.send('Console.enable').catch(() => {}); await cdp.send('Runtime.enable'); // Clear existing console messages so we only capture reload-related ones await cdp.send('Console.clearMessages').catch(() => {}); // Install a console error collector in Obsidian before enabling the plugin await cdp.evaluate(`(() => { window.__pluginLoadErrors = []; window.__origConsoleError = console.error; console.error = function(...args) { window.__pluginLoadErrors.push(args.map(a => { if (a instanceof Error) return a.stack || a.message; if (typeof a === 'object') try { return JSON.stringify(a); } catch { return String(a); } return String(a); }).join(' ')); window.__origConsoleError.apply(console, args); }; return true; })()`, false); const pluginLoaded = await cdp.evaluate(`(async () => { const id = 'text-block-timer'; await app.plugins.disablePlugin(id); await new Promise(r => setTimeout(r, 1500)); await app.plugins.enablePlugin(id); await new Promise(r => setTimeout(r, 3000)); // Re-focus on a markdown leaf (plugin reload may steal focus to sidebar) const mdLeaves = app.workspace.getLeavesOfType('markdown'); if (mdLeaves.length > 0) { app.workspace.setActiveLeaf(mdLeaves[0], { focus: true }); await new Promise(r => setTimeout(r, 500)); } return !!app.plugins.plugins[id]; })()`, true); await sleep(1000); // Restore original console.error and read captured errors const loadErrors = await cdp.evaluate(`(() => { if (window.__origConsoleError) { console.error = window.__origConsoleError; delete window.__origConsoleError; } const errors = window.__pluginLoadErrors || []; delete window.__pluginLoadErrors; return JSON.stringify(errors); })()`, false); if (!pluginLoaded) { log('❌', 'Plugin failed to load!'); // Read captured console.error messages try { const errors = JSON.parse(loadErrors || '[]'); if (errors.length > 0) { log('🔍', `Console errors captured during plugin reload (${errors.length}):`); for (const err of errors) { log('🔍', ` ${err.substring(0, 500)}`); } } else { log('🔍', 'No console.error captured — checking Runtime exceptions...'); } } catch { /* ignore parse errors */ } // Also try reading Obsidian's internal plugin error notice const notice = await cdp.evaluate(`(() => { // Obsidian shows plugin errors in a Notice — check for recent error notices const notices = document.querySelectorAll('.notice'); const errorNotices = []; for (const n of notices) { if (n.textContent?.toLowerCase().includes('plugin') || n.textContent?.toLowerCase().includes('error') || n.textContent?.toLowerCase().includes('fail')) { errorNotices.push(n.textContent.trim()); } } return JSON.stringify(errorNotices); })()`, false); try { const noticeTexts = JSON.parse(notice || '[]'); if (noticeTexts.length > 0) { log('🔍', 'Obsidian error notices:'); for (const t of noticeTexts) log('🔍', ` ${t.substring(0, 300)}`); } } catch { /* ignore */ } // Fatal: cannot continue throw new Error('Plugin text-block-timer failed to load. See console errors above.'); } log('✅', 'Plugin reloaded'); const runner = new TestRunner(cdp); try { for (const chainName of chainsToRun) { console.log('\n' + '─'.repeat(40)); log('🔗', `Chain: ${chainName}`); console.log('─'.repeat(40)); const entry = CHAIN_REGISTRY[chainName]; await entry.fn(runner); } } catch (e) { log('💥', `Unexpected error: ${e.message}`); console.error(e); } const allPassed = runner.summary(); cdp.close(); process.exit(allPassed ? 0 : 1); } main().catch(e => { console.error('Fatal error:', e); process.exit(2); });