diff --git a/CHANGELOG.md b/CHANGELOG.md index efb9c49..4a45b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to `@qavajs/tx` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## [Unreleased] +## [0.0.14] ### Added - XPath locator support — `page.locator()`, `locator.locator()`, and `frameLocator.locator()` now accept XPath expressions in addition to CSS selectors. Prefix with `//` (e.g. `//button[@id='submit']`) or with `xpath=` (e.g. `xpath=//button[@id='submit']`). Evaluated via `document.evaluate()` using `ORDERED_NODE_SNAPSHOT_TYPE`. @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Command log entries no longer have a coloured left border — status is conveyed by the icon only - Inline test log now collapses automatically when a test finishes, regardless of pass or fail - `HtmlReporter` — each test row now displays only the leaf test name; the suite prefix is stripped since it is already shown in the group header +- **Front-end / runner decoupling** — control panel HTML, CSS, and browser scripts are now standalone files edited with full IDE support; the test runner import (`executeTests`) is isolated behind `src/panel/runner-bridge.ts`; HTML-generation functions live in `src/panel/render.ts` with no DOM or runner dependencies; shared element IDs are declared once in `src/panel/selectors.ts` and used by both the HTML template and `devPanel.ts`; the `tsLoader` now registers `.css`, `.html`, and `.iife.js` require hooks so reporters load correctly from source during `--test` runs without a prior build step ### Removed - `:has-text("…")` pseudo-class support in selectors — use `locator.filter({ hasText: '…' })` instead diff --git a/build.mjs b/build.mjs index 3261e29..874a5bc 100644 --- a/build.mjs +++ b/build.mjs @@ -10,8 +10,11 @@ if (!watch) { const sharedOpts = watch ? { watch: true } : {}; +const textLoader = { loader: { '.html': 'text', '.css': 'text', '.iife.js': 'text' } }; + await esbuild.build({ ...sharedOpts, + ...textLoader, entryPoints: ['src/index.ts'], bundle: true, platform: 'node', @@ -33,10 +36,11 @@ await esbuild.build({ await esbuild.build({ ...sharedOpts, + ...textLoader, entryPoints: [ 'src/reporters/ConsoleReporter.ts', 'src/reporters/HtmlReporter.ts', - 'src/reporters/JUnitReporter.ts', + 'src/reporters/JunitReporter.ts', ], bundle: true, platform: 'node', diff --git a/package-lock.json b/package-lock.json index f4b494b..be7507b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qavajs/tx", - "version": "0.0.13", + "version": "0.0.14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qavajs/tx", - "version": "0.0.13", + "version": "0.0.14", "license": "MIT", "dependencies": { "esbuild": "^0.28.0", diff --git a/package.json b/package.json index 863f842..4c56723 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qavajs/tx", - "version": "0.0.13", + "version": "0.0.14", "description": "@qavajs/tx — testing framework via Hammerhead proxy", "license": "MIT", "author": "Oleksandr Halichenko", diff --git a/src/assets.d.ts b/src/assets.d.ts new file mode 100644 index 0000000..fd6d391 --- /dev/null +++ b/src/assets.d.ts @@ -0,0 +1,3 @@ +declare module '*.css' { const content: string; export default content; } +declare module '*.html' { const content: string; export default content; } +declare module '*.iife.js' { const content: string; export default content; } diff --git a/src/browser/devPanel.ts b/src/browser/devPanel.ts index 539ef59..3f9f1d8 100644 --- a/src/browser/devPanel.ts +++ b/src/browser/devPanel.ts @@ -1,5 +1,6 @@ import { fromProxiedUrl, iframeDoc, wsOnMessage, page } from './browser'; import { escHtml } from '../utils/htmlUtils'; +import { SEL } from '../panel/selectors'; // ── Network panel ───────────────────────────────────────────────────────────── @@ -54,14 +55,14 @@ function _renderNetworkRow(entry: NetworkEntry): string { } function _updateNetworkCount() { - const el = document.getElementById('networkCount'); + const el = document.getElementById(SEL.networkCount); if (el) el.textContent = _networkEntries.length > 0 ? _networkEntries.length + ' request' + (_networkEntries.length !== 1 ? 's' : '') : ''; } function _appendNetworkEntry(entry: NetworkEntry) { - const list = document.getElementById('networkList'); + const list = document.getElementById(SEL.networkList); if (!list) return; const empty = list.querySelector('.tx-empty-network'); if (empty) empty.remove(); @@ -74,7 +75,7 @@ function _appendNetworkEntry(entry: NetworkEntry) { } function _refreshNetworkRow(entry: NetworkEntry) { - const list = document.getElementById('networkList'); + const list = document.getElementById(SEL.networkList); const row = list?.querySelector('[data-net-id="' + entry.id + '"]'); if (!row) return; const tmp = document.createElement('div'); @@ -84,7 +85,7 @@ function _refreshNetworkRow(entry: NetworkEntry) { row.replaceWith(newRow); _updateNetworkCount(); if (_selectedNetworkId === entry.id) { - const detailBody = document.getElementById('networkDetailBody'); + const detailBody = document.getElementById(SEL.networkDetailBody); if (detailBody) detailBody.innerHTML = _renderNetworkDetail(entry); } } @@ -160,9 +161,9 @@ function _openNetworkDetail(id: number) { document.querySelectorAll('.tx-network-row.selected').forEach(el => el.classList.remove('selected')); document.querySelector('[data-net-id="' + id + '"]')?.classList.add('selected'); _selectedNetworkId = id; - const detail = document.getElementById('networkDetail'); - const detailTitle = document.getElementById('networkDetailTitle'); - const detailBody = document.getElementById('networkDetailBody'); + const detail = document.getElementById(SEL.networkDetail); + const detailTitle = document.getElementById(SEL.networkDetailTitle); + const detailBody = document.getElementById(SEL.networkDetailBody); if (!detail || !detailBody) return; detail.classList.add('open'); if (detailTitle) detailTitle.textContent = entry.method + ' ' + _netShortUrl(entry.url); @@ -170,7 +171,7 @@ function _openNetworkDetail(id: number) { } (window as any).closeNetworkDetail = () => { - document.getElementById('networkDetail')?.classList.remove('open'); + document.getElementById(SEL.networkDetail)?.classList.remove('open'); document.querySelectorAll('.tx-network-row.selected').forEach(el => el.classList.remove('selected')); _selectedNetworkId = null; }; @@ -180,8 +181,8 @@ function _openNetworkDetail(id: number) { _networkCounter = 0; _hhReqMap.clear(); _selectedNetworkId = null; - document.getElementById('networkDetail')?.classList.remove('open'); - const list = document.getElementById('networkList'); + document.getElementById(SEL.networkDetail)?.classList.remove('open'); + const list = document.getElementById(SEL.networkList); if (list) list.innerHTML = '
No requests yet
'; _updateNetworkCount(); }; @@ -202,9 +203,9 @@ let _consoleErrorCount = 0; const _MAX_CONSOLE = 1000; function _updateConsoleBadge() { - const count = document.getElementById('consoleCount'); - const badge = document.getElementById('consoleErrorBadge'); - const panel = document.getElementById('networkPanel'); + const count = document.getElementById(SEL.consoleCount); + const badge = document.getElementById(SEL.consoleErrorBadge); + const panel = document.getElementById(SEL.networkPanel); const isConsoleTab = panel?.dataset.activeTab === 'console'; if (count) { count.textContent = _consoleEntries.length > 0 ? String(_consoleEntries.length) : ''; @@ -217,7 +218,7 @@ function _updateConsoleBadge() { } function _appendConsoleEntry(entry: ConsoleEntry) { - const list = document.getElementById('consoleList'); + const list = document.getElementById(SEL.consoleList); if (!list) return; const empty = list.querySelector('.tx-empty-network'); if (empty) empty.remove(); @@ -239,13 +240,12 @@ function _appendConsoleEntry(entry: ConsoleEntry) { let _activeDevTab: 'network' | 'console' | 'selector' = 'network'; function _openDevPanel(tab: 'network' | 'console' | 'selector') { - const panel = document.getElementById('networkPanel'); + const panel = document.getElementById(SEL.networkPanel); if (!panel) return; const alreadyOpen = panel.classList.contains('open'); if (alreadyOpen && _activeDevTab === tab) { panel.classList.remove('open'); - document.getElementById('networkToggleBtn')?.classList.remove('active'); - document.getElementById('consoleToggleBtn')?.classList.remove('active'); + document.getElementById(SEL.networkToggleBtn)?.classList.remove('active'); _clearSelectorHighlights(); return; } @@ -258,31 +258,31 @@ function _openDevPanel(tab: 'network' | 'console' | 'selector') { } function _switchDevTabInternal(tab: 'network' | 'console' | 'selector') { - const panel = document.getElementById('networkPanel'); + const panel = document.getElementById(SEL.networkPanel); if (!panel) return; if (_activeDevTab === 'selector' && tab !== 'selector') _clearSelectorHighlights(); _activeDevTab = tab; panel.dataset.activeTab = tab; - document.getElementById('devTabNetwork')?.classList.toggle('active', tab === 'network'); - document.getElementById('devTabConsole')?.classList.toggle('active', tab === 'console'); - document.getElementById('devTabSelector')?.classList.toggle('active', tab === 'selector'); - document.getElementById('devTabContentNetwork')?.classList.toggle('active', tab === 'network'); - document.getElementById('devTabContentConsole')?.classList.toggle('active', tab === 'console'); - document.getElementById('devTabContentSelector')?.classList.toggle('active', tab === 'selector'); - document.getElementById('networkToggleBtn')?.classList.toggle('active', panel.classList.contains('open')); + document.getElementById(SEL.devTabNetwork)?.classList.toggle('active', tab === 'network'); + document.getElementById(SEL.devTabConsole)?.classList.toggle('active', tab === 'console'); + document.getElementById(SEL.devTabSelector)?.classList.toggle('active', tab === 'selector'); + document.getElementById(SEL.devTabContentNetwork)?.classList.toggle('active', tab === 'network'); + document.getElementById(SEL.devTabContentConsole)?.classList.toggle('active', tab === 'console'); + document.getElementById(SEL.devTabContentSelector)?.classList.toggle('active', tab === 'selector'); + document.getElementById(SEL.networkToggleBtn)?.classList.toggle('active', panel.classList.contains('open')); if (tab === 'console') { _consoleErrorCount = 0; _updateConsoleBadge(); } if (tab === 'selector') { - const input = document.getElementById('selectorInput') as HTMLInputElement | null; + const input = document.getElementById(SEL.selectorInput) as HTMLInputElement | null; if (input?.value) _runSelectorQuery(input.value); setTimeout(() => input?.focus(), 50); } } (window as any).switchDevTab = (tab: 'network' | 'console' | 'selector') => { - const panel = document.getElementById('networkPanel'); + const panel = document.getElementById(SEL.networkPanel); if (!panel) return; if (!panel.classList.contains('open')) { panel.classList.add('open'); @@ -293,11 +293,10 @@ function _switchDevTabInternal(tab: 'network' | 'console' | 'selector') { }; (window as any).toggleNetworkPanel = () => { - const panel = document.getElementById('networkPanel'); + const panel = document.getElementById(SEL.networkPanel); if (panel?.classList.contains('open')) { panel.classList.remove('open'); - document.getElementById('networkToggleBtn')?.classList.remove('active'); - document.getElementById('consoleToggleBtn')?.classList.remove('active'); + document.getElementById(SEL.networkToggleBtn)?.classList.remove('active'); _clearSelectorHighlights(); } else { _openDevPanel(_activeDevTab); @@ -315,7 +314,7 @@ function _switchDevTabInternal(tab: 'network' | 'console' | 'selector') { _consoleEntries.length = 0; _consoleCounter = 0; _consoleErrorCount = 0; - const list = document.getElementById('consoleList'); + const list = document.getElementById(SEL.consoleList); if (list) list.innerHTML = '
No console output yet
'; _updateConsoleBadge(); } @@ -353,9 +352,9 @@ function _describeElement(el: Element, idx: number): string { } function _runSelectorQuery(selector: string) { - const input = document.getElementById('selectorInput') as HTMLInputElement | null; - const status = document.getElementById('selectorStatus'); - const matchList = document.getElementById('selectorMatches'); + const input = document.getElementById(SEL.selectorInput) as HTMLInputElement | null; + const status = document.getElementById(SEL.selectorStatus); + const matchList = document.getElementById(SEL.selectorMatches); if (!status || !matchList) return; _clearSelectorHighlights(); @@ -415,7 +414,7 @@ function _runSelectorQuery(selector: string) { (window as any).runSelectorQuery = _runSelectorQuery; (window as any).clearSelectorQuery = () => { - const input = document.getElementById('selectorInput') as HTMLInputElement | null; + const input = document.getElementById(SEL.selectorInput) as HTMLInputElement | null; if (input) { input.value = ''; input.className = 'tx-selector-input'; } _runSelectorQuery(''); }; @@ -423,8 +422,8 @@ function _runSelectorQuery(selector: string) { // ── Network panel resizer ───────────────────────────────────────────────────── export function initNetworkResizer(): void { - const panel = document.getElementById('networkPanel'); - const handle = document.getElementById('networkResizeHandle'); + const panel = document.getElementById(SEL.networkPanel); + const handle = document.getElementById(SEL.networkResizeHandle); if (!panel || !handle) return; handle.addEventListener('mousedown', (e: MouseEvent) => { @@ -490,7 +489,7 @@ export function initNetworkListeners(): void { _refreshNetworkRow(entry); }); - document.getElementById('networkList')?.addEventListener('click', (e: MouseEvent) => { + document.getElementById(SEL.networkList)?.addEventListener('click', (e: MouseEvent) => { const row = (e.target as Element).closest('.tx-network-row'); if (!row) return; const id = Number(row.getAttribute('data-net-id')); diff --git a/src/core/controller.ts b/src/core/controller.ts index dd7ac11..52eaa41 100644 --- a/src/core/controller.ts +++ b/src/core/controller.ts @@ -1,8 +1,9 @@ -import { log, attach, setLogContainer, page, expect, request, initIframe, setOnTabsChanged, getTabsSnapshot, createTab, closeTab, setActiveTab, browser, node, getSnapshots, clearSnapshots, wsConnect, wsSend, wsRequest, wsOnMessage } from '../browser/browser'; -import { escHtml, escAttr, jsq } from '../utils/htmlUtils'; -import { type TestResult } from '../runner/executor'; -import { executeTests } from '../runner/testRunner'; +import { log, attach, setLogContainer, page, expect, request, initIframe, setOnTabsChanged, getTabsSnapshot, createTab, closeTab, setActiveTab, browser, node, getSnapshots, clearSnapshots, wsConnect, wsSend, wsOnMessage, wsRequest } from '../browser/browser'; +import { escHtml, escAttr } from '../utils/htmlUtils'; import { initNetworkListeners, initNetworkResizer } from '../browser/devPanel'; +import { renderTestFileCard } from '../panel/render'; +import { fetchAndRun, type TestResult, type RunSpec } from '../panel/runner-bridge'; +import type { ParsedFile } from '../panel/render'; declare global { interface Window { @@ -157,9 +158,6 @@ function hideProgress() { // ── Spec list ───────────────────────────────────────────────────────────────── -interface ParsedTest { suite: string; name: string; tags?: string[]; } -interface ParsedFile { filename: string; relPath?: string; tests: ParsedTest[]; } - async function loadTestList() { const container = document.getElementById('testList')!; try { @@ -174,78 +172,6 @@ async function loadTestList() { } } -function renderTestItemHtml(filename: string, suite: string, name: string, tags: string[]): string { - const fullName = suite === '(root)' ? name : suite + ' > ' + name; - const stateIcons = - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - ''; - const key = escAttr(filename + '\x01' + fullName); - const tagsHtml = tags.length > 0 - ? '' + tags.map(t => '' + escHtml(t) + '').join('') + '' - : ''; - return '
' + - '' + - '' + stateIcons + '' + - '' + escHtml(name) + '' + - tagsHtml + - '' + - '' + - '
' + - '
'; -} - -function renderSuiteHtml(filename: string, suite: string, items: Array<{ name: string; tags: string[] }>): string { - const key = escAttr(filename + '\x01' + suite); - return '
' + - '' + - '' + escHtml(suite) + '' + - '' + - '' + - '
' + items.map(({ name, tags }) => renderTestItemHtml(filename, suite, name, tags)).join(''); -} - -function renderTestFileCard(f: ParsedFile): string { - const suites: Record> = Object.create(null); - f.tests.forEach(t => { - const k = t.suite || '(root)'; - if (!suites[k]) suites[k] = []; - suites[k].push({ name: t.name, tags: t.tags ?? [] }); - }); - const suiteHtml = Object.entries(suites).map(([s, items]) => renderSuiteHtml(f.filename, s, items)).join(''); - const display = f.relPath ?? f.filename; - const ext = display.split('.').pop() ?? 'js'; - const noExt = display.slice(0, -(ext.length + 1)); - const lastSlash = noExt.lastIndexOf('/'); - const dir = lastSlash >= 0 ? noExt.slice(0, lastSlash + 1) : ''; - const stem = lastSlash >= 0 ? noExt.slice(lastSlash + 1) : noExt; - return '
' + - '
' + - '' + - '' + - (dir ? '' + escHtml(dir) + '' : '') + - escHtml(stem) + '.' + escHtml(ext) + '' + - '' + - '' + - '' + - '
' + - (Object.keys(suites).length ? '
' + suiteHtml + '
' : '') + - '
'; -} - window.toggleCard = (filename: string) => document.getElementById('card-' + escAttr(filename))?.classList.toggle('open'); @@ -264,11 +190,8 @@ window.toggleSuite = (filename: string, suiteName: string) => { }); }; -// ── Test execution ──────────────────────────────────────────────────────────── - // ── Server communication ────────────────────────────────────────────────────── - function notifyRunBegin(specs: Array<{ file: string; tests: string[] | null }>): void { wsSend('run-begin', { specs } as Record); } @@ -308,6 +231,16 @@ function openAndResetCard(filename: string) { setCardRunning(filename); } +function countResults(results: TestResult[]): { passed: number; failed: number; duration: number } { + let passed = 0, failed = 0, duration = 0; + for (const r of results) { + if (r.passed) passed++ + else failed++; + duration += r.duration; + } + return { passed, failed, duration }; +} + async function _singleRun( setupFn: () => void, getSpecs: () => Array<{ file: string; tests: string[] | null }>, @@ -334,39 +267,27 @@ async function _singleRun( setStopBtnVisible(false); } -function countResults(results: TestResult[]): { passed: number; failed: number; duration: number } { - let passed = 0, failed = 0, duration = 0; - for (const r of results) { - if (r.passed) passed++ - else failed++; - duration += r.duration; - } - return { passed, failed, duration }; -} - -async function fetchAndRun( +async function _fetchAndRunFile( filename: string, - opts?: { filterSuite?: string; filterTest?: string; filterTests?: string[]; filename?: string } + spec: RunSpec | null, + uiFilename?: string, ): Promise { - const msg = await wsRequest<{ data?: string; error?: string }>('get-test-source', { file: filename }); - if (msg.error || !msg.data) throw new Error(msg.error ?? 'Failed to load test source'); - const results = await executeTests(msg.data, { - ...opts, + const results = await fetchAndRun(filename, spec, { isStopRequested: () => _stopRequested, setCancelFn: (fn) => { _currentTestCancel = fn; }, - onAttemptBegin: opts?.filename ? (testName, attempt) => { - setTestItemStatus(opts.filename!, testName, 'running'); - activateTestLog(opts.filename!, testName, attempt); + onAttemptBegin: uiFilename ? (testName, attempt) => { + setTestItemStatus(uiFilename, testName, 'running'); + activateTestLog(uiFilename, testName, attempt); } : undefined, - onAttemptError: opts?.filename ? appendErrorToLog : undefined, - onAttemptFinally: opts?.filename ? (testName) => { - const logEl = document.getElementById('tlog-' + escAttr(opts.filename! + '\x01' + testName)); + onAttemptError: uiFilename ? appendErrorToLog : undefined, + onAttemptFinally: uiFilename ? (testName) => { + const logEl = document.getElementById('tlog-' + escAttr(uiFilename + '\x01' + testName)); logEl?.classList.remove('open'); _activeTestLog = null; } : undefined, onTestEnd: (r) => { wsSend('report', { filename, tests: [r] } as Record); - if (opts?.filename) setTestItemStatus(opts.filename, r.name, r.passed ? 'pass' : 'fail', r.duration, r.retry); + if (uiFilename) setTestItemStatus(uiFilename, r.name, r.passed ? 'pass' : 'fail', r.duration, r.retry); if (_runTotal > 0) showProgress(++_runDone, _runTotal); }, }); @@ -393,8 +314,9 @@ async function _runMultiFile( openAndResetCard(filename); log(`run ${filename}`); try { + const spec: RunSpec | null = tests ? { filterTests: tests } : null; const { passed, failed, duration } = countResults( - await fetchAndRun(filename, tests ? { filename, filterTests: tests } : { filename }) + await _fetchAndRunFile(filename, spec, filename) ); totalPass += passed; totalFail += failed; totalDuration += duration; } catch (e: any) { @@ -423,7 +345,7 @@ window.runTestByFilename = async (filename: string) => { await _singleRun( () => { openAndResetCard(filename); log(`run ${filename}`); }, () => [{ file: filename, tests: null }], - () => fetchAndRun(filename, { filename }), + () => _fetchAndRunFile(filename, null, filename), () => updateCardStatus(filename, 0, 1), total, ); @@ -453,7 +375,7 @@ window.runSuite = async (filename: string, suiteName: string) => { ).filter(el => el.dataset.suite === suiteName).map(el => el.dataset.fullname!).filter(Boolean); }, () => [{ file: filename, tests: suiteTests.length ? suiteTests : null }], - () => fetchAndRun(filename, { filterSuite: suiteName, filename }), + () => _fetchAndRunFile(filename, { filterSuite: suiteName }, filename), () => updateCardStatus(filename, 0, 1), suiteTests.length, ); @@ -467,7 +389,7 @@ window.runTest = async (filename: string, fullName: string) => { setCardRunning(filename); }, () => [{ file: filename, tests: [fullName] }], - () => fetchAndRun(filename, { filterTest: fullName, filename }), + () => _fetchAndRunFile(filename, { filterTest: fullName }, filename), () => setTestItemStatus(filename, fullName, 'fail'), 1, ); @@ -799,7 +721,6 @@ function initResizers() { }); } - window.stopExecution = () => { _stopRequested = true; _currentTestCancel?.(new Error('Test stopped')); diff --git a/src/core/tsLoader.ts b/src/core/tsLoader.ts index 5fe99bf..569e3ad 100644 --- a/src/core/tsLoader.ts +++ b/src/core/tsLoader.ts @@ -20,4 +20,20 @@ export function register(): void { }); mod._compile(code, filename); }; + + const textHandler = (mod: any, filename: string) => { + mod.exports = fs.readFileSync(filename, 'utf-8'); + }; + (Module as any)._extensions['.css'] = textHandler; + (Module as any)._extensions['.html'] = textHandler; + + // .iife.js files have extension .js so patch the .js handler to intercept them + const originalJsExt = (Module as any)._extensions['.js']; + (Module as any)._extensions['.js'] = (mod: any, filename: string) => { + if (filename.endsWith('.iife.js')) { + mod.exports = fs.readFileSync(filename, 'utf-8'); + return; + } + originalJsExt(mod, filename); + }; } diff --git a/src/panel/controlPanel.css b/src/panel/controlPanel.css new file mode 100644 index 0000000..7cc14dd --- /dev/null +++ b/src/panel/controlPanel.css @@ -0,0 +1,1515 @@ + *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } + + :root { + --jade: #34a870; + --jade-bg: rgba(52, 168, 112, 0.10); + --jade-glow: rgba(52, 168, 112, 0.22); + --pass: #4ead7a; + --pass-bg: rgba(78, 173, 122, 0.10); + --fail: #ef4444; + --fail-bg: rgba(239, 68, 68, 0.10); + --warn: #f59e0b; + --bg-app: #161618; + --bg-topbar: #111113; + --bg-panel: #1c1c1e; + --bg-card: #242426; + --bg-hover: #2c2c2f; + --bg-active: #343437; + --border: rgba(255,255,255,0.055); + --border-s: rgba(255,255,255,0.09); + --text: #d4d4d8; + --text-dim: #71717a; + --text-muted: #52525b; + --radius: 5px; + --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Cascadia Code', 'Fira Code', monospace; + } + + html, body { height: 100%; overflow: hidden; } + + body { + font-family: var(--font-ui); + font-size: 13px; + background: var(--bg-app); + color: var(--text); + display: flex; + flex-direction: column; + } + + /* ══ Topbar ════════════════════════════════════════════════════════ */ + + .tx-topbar { + height: 44px; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 14px; + gap: 10px; + flex-shrink: 0; + } + + .tx-logo { + display: flex; + align-items: center; + gap: 8px; + } + + .tx-logo-mark { + width: 24px; + height: 24px; + border-radius: 5px; + background: var(--jade); + display: flex; + align-items: center; + justify-content: center; + font-weight: 900; + font-size: 10px; + letter-spacing: -0.5px; + color: #000; + flex-shrink: 0; + } + + .tx-logo-name { + font-size: 13px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.1px; + } + + .tx-topbar-div { + width: 1px; + height: 20px; + background: var(--border-s); + flex-shrink: 0; + } + + .tx-run-all-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 14px; + background: var(--jade); + color: #000; + border: none; + border-radius: var(--radius); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: background 0.12s, box-shadow 0.12s; + letter-spacing: 0.1px; + } + + .tx-run-all-btn:hover { background: #2d9963; box-shadow: 0 0 12px var(--jade-glow); } + .tx-run-all-btn:active { background: #268557; } + .tx-run-all-btn:disabled { + background: var(--bg-card); + color: var(--text-muted); + cursor: not-allowed; + box-shadow: none; + } + + .tx-stop-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 14px; + background: transparent; + color: var(--fail); + border: 1px solid var(--fail); + border-radius: var(--radius); + font-size: 12px; + font-weight: 700; + cursor: pointer; + transition: background 0.12s; + letter-spacing: 0.1px; + } + .tx-stop-btn:hover { background: var(--fail-bg); } + .tx-stop-btn:disabled { opacity: 0.5; cursor: not-allowed; } + .tx-hidden { display: none !important; } + .tx-console-error-badge { + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + background: var(--fail-bg); + color: var(--fail); + } + + .tx-topbar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; + } + + .tx-progress-badge { + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--text-dim); + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: 20px; + padding: 4px 11px; + letter-spacing: 0.01em; + } + + .tx-status-pill { + display: flex; + align-items: center; + gap: 7px; + padding: 4px 11px; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: 20px; + font-size: 11px; + color: var(--text-dim); + } + + .tx-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--text-muted); + flex-shrink: 0; + transition: background 0.3s; + } + .tx-status-dot.ready { background: var(--jade); box-shadow: 0 0 5px var(--jade); } + .tx-status-dot.running { background: var(--warn); animation: tx-pulse 0.9s ease-in-out infinite; } + .tx-status-dot.passed { background: var(--pass); } + .tx-status-dot.failed { background: var(--fail); } + .tx-status-dot.connected { background: var(--jade); box-shadow: 0 0 5px var(--jade); } + .tx-status-dot.disconnected { background: var(--fail); box-shadow: 0 0 5px var(--fail); } + + @keyframes tx-pulse { 0%,100% { opacity:1; } 50% { opacity:0.35; } } + + /* ══ 3-column body ════════════════════════════════════════════════ */ + + .tx-body { + flex: 1; + display: flex; + overflow: hidden; + } + + /* ══ Specs panel ══════════════════════════════════════════════════ */ + + .tx-specs { + width: 500px; + min-width: 180px; + flex-shrink: 0; + background: var(--bg-panel); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .tx-panel-hdr { + padding: 9px 14px 8px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + } + + .tx-filter-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + .tx-filter-input { + flex: 1; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: 5px; + padding: 4px 8px; + font-size: 12px; + color: var(--text); + outline: none; + transition: border-color 0.15s; + min-width: 0; + } + .tx-filter-input::placeholder { color: var(--text-muted); } + .tx-filter-input:focus { border-color: var(--jade); } + + .tx-filter-run-btn { + width: 24px; + height: 24px; + flex-shrink: 0; + border-radius: 4px; + background: var(--jade); + border: none; + color: #000; + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.15s; + } + .tx-filter-run-btn:hover { opacity: 0.8; } + .tx-filter-run-btn:disabled { opacity: 0.4; cursor: not-allowed; } + + .tx-specs-scroll { + flex: 1; + overflow-y: auto; + padding: 4px 0; + } + .tx-specs-scroll::-webkit-scrollbar { width: 3px; } + .tx-specs-scroll::-webkit-scrollbar-thumb { background: var(--border-s); border-radius: 2px; } + + /* spec card */ + .tx-spec-card { } + + .tx-spec-hdr { + display: flex; + align-items: center; + padding: 6px 10px 6px 6px; + gap: 5px; + cursor: pointer; + user-select: none; + transition: background 0.1s, border-color 0.1s; + border-left: 2px solid transparent; + } + .tx-spec-hdr:hover { background: var(--bg-hover); } + .tx-spec-card.active .tx-spec-hdr { background: var(--bg-active); } + .tx-spec-card.open .tx-spec-hdr { border-left-color: var(--jade); } + + .tx-spec-chevron { + width: 12px; + font-size: 10px; + color: var(--text-muted); + transition: transform 0.14s; + flex-shrink: 0; + text-align: center; + } + .tx-spec-card.open .tx-spec-chevron { transform: rotate(90deg); } + + .tx-spec-filename { + flex: 1; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + letter-spacing: 0.1px; + } + .tx-spec-filename .ext { color: var(--text-muted); } + .tx-spec-filename .tx-spec-dir { color: var(--text-muted); } + + .tx-suite-badges { display: flex; gap: 3px; flex-shrink: 0; } + + .tx-badge { + font-size: 12px; + font-weight: 700; + padding: 1px 6px; + border-radius: 10px; + line-height: 1.5; + } + .tx-badge--pass { background: var(--pass-bg); color: var(--pass); } + .tx-badge--fail { background: var(--fail-bg); color: var(--fail); } + .tx-badge--running { color: var(--warn); } + + .tx-spec-run-btn { + width: 20px; + height: 20px; + border-radius: 4px; + background: transparent; + border: 1px solid transparent; + color: var(--text-muted); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s, border-color 0.1s, color 0.1s; + } + .tx-spec-hdr:hover .tx-spec-run-btn { opacity: 1; border-color: var(--jade); color: var(--jade); } + + /* spec body: suites + test items */ + .tx-spec-body { display: none; padding: 0 0 6px; } + .tx-spec-card.open .tx-spec-body { display: block; } + .tx-spec-card { border-bottom: 1px solid var(--border); } + + .tx-suite-row { + display: flex; + align-items: center; + padding: 5px 10px 3px 10px; + gap: 5px; + margin-top: 2px; + cursor: pointer; + user-select: none; + } + .tx-suite-row:hover { background: var(--bg-hover); } + .tx-suite-chevron { + width: 12px; + font-size: 10px; + color: var(--text-muted); + transition: transform 0.14s; + flex-shrink: 0; + text-align: center; + transform: rotate(90deg); + } + .tx-suite-row.collapsed .tx-suite-chevron { transform: rotate(0deg); } + .tx-suite-name { + flex: 1; + font-size: 11px; + color: var(--text-dim); + font-weight: 700; + letter-spacing: 0.4px; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .tx-suite-run-btn { + width: 20px; + height: 20px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--text-muted); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s, border-color 0.1s, color 0.1s; + } + .tx-suite-row:hover .tx-suite-run-btn { opacity: 1; border-color: var(--jade); color: var(--jade); } + + .tx-test-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .tx-test-tags { display: flex; gap: 3px; flex-shrink: 0; } + .tx-test-tag { + font-size: 10px; + font-weight: 500; + padding: 1px 5px; + border-radius: 3px; + background: rgba(0, 208, 132, 0.12); + color: var(--jade); + letter-spacing: 0.01em; + white-space: nowrap; + } + .tx-test-run-btn { + width: 20px; + height: 20px; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: var(--text-muted); + font-size: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.1s, border-color 0.1s, color 0.1s; + } + .tx-test-item:hover .tx-test-run-btn { opacity: 1; border-color: var(--jade); color: var(--jade); } + + .tx-test-chevron { + width: 10px; + font-size: 9px; + color: var(--text-muted); + transition: transform 0.14s, color 0.14s; + flex-shrink: 0; + text-align: center; + } + .tx-test-item:has(+ .tx-test-log.open) .tx-test-chevron { + transform: rotate(90deg); + } + + .tx-test-badge { + font-size: 11px; + font-weight: 600; + padding: 1px 5px; + border-radius: 3px; + flex-shrink: 0; + letter-spacing: 0.02em; + } + .tx-test-badge.pass { background: rgba(34,197,94,0.15); color: var(--pass); } + .tx-test-badge.fail { background: rgba(239,68,68,0.15); color: var(--fail); } + + .tx-test-item { + padding: 3px 10px 3px 24px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 6px; + transition: color 0.15s, background 0.1s; + cursor: pointer; + } + .tx-test-item:hover { background: var(--bg-hover); } + .tx-test-item.pass { color: var(--text-dim); } + .tx-test-item.fail { color: var(--fail); } + .tx-test-item.tx-kb-focus { background: var(--bg-active); outline: 1px solid var(--jade); outline-offset: -1px; } + .tx-test-dot { + width: 12px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + } + .tx-state-svg { display: none; } + .tx-test-dot .tx-state-svg--idle { display: block; } + .tx-test-dot.pass .tx-state-svg--idle { display: none; } + .tx-test-dot.pass .tx-state-svg--pass { display: block; color: var(--pass); } + .tx-test-dot.fail .tx-state-svg--idle { display: none; } + .tx-test-dot.fail .tx-state-svg--fail { display: block; color: var(--fail); } + .tx-test-dot.running .tx-state-svg--idle { display: none; } + .tx-test-dot.running .tx-state-svg--running { display: block; color: var(--warn); animation: tx-dot-pulse 0.7s ease-in-out infinite; } + @keyframes tx-dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.2; } + } + + #testRunnerStatus { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 500; + letter-spacing: 0; + text-transform: none; + } + .tx-runner-total { color: var(--text-muted); } + .tx-runner-pass { color: var(--pass); } + .tx-runner-fail { color: var(--fail); } + + /* ══ Resize handles ══════════════════════════════════════════════ */ + + .tx-resize-handle { + width: 8px; + flex-shrink: 0; + cursor: col-resize; + position: relative; + display: flex; + align-items: stretch; + justify-content: center; + } + .tx-resize-handle::before { + content: ''; + width: 1px; + background: var(--border); + transition: background 0.15s, width 0.1s; + } + .tx-resize-handle:hover::before, + .tx-resize-handle.dragging::before { background: var(--jade); width: 2px; } + + /* ══ Inline test log ═════════════════════════════════════════════ */ + + .tx-test-log { + display: none; + background: var(--bg-app); + border-bottom: 1px solid var(--border); + overflow: hidden; + } + .tx-test-log.open { display: block; } + + /* log entries */ + .tx-cmd { + display: flex; + align-items: baseline; + padding: 3px 14px 3px 10px; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + } + .tx-cmd:hover { background: var(--bg-card); } + + .tx-cmd-icon { + width: 13px; + text-align: center; + flex-shrink: 0; + } + .tx-cmd-icon.pass { color: var(--pass); } + .tx-cmd-icon.fail { color: var(--fail); } + .tx-cmd-icon.warn { color: var(--warn); } + .tx-cmd-icon.pending { color: var(--warn); opacity: 0.7; } + .tx-cmd-icon.info { color: var(--text-muted); } + + .tx-cmd-msg { + flex: 1; + color: var(--text); + word-break: break-word; + } + .tx-cmd.pending .tx-cmd-msg { color: var(--text-dim); } + .tx-cmd.info .tx-cmd-msg { color: var(--text-dim); } + + .tx-cmd-dur { + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; + width: 10%; + } + + .tx-cmd-stack { + margin: 0 10px 4px 29px; + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.6; + color: var(--fail); + background: color-mix(in srgb, var(--fail) 8%, transparent); + border-left: 2px solid var(--fail); + border-radius: 0 3px 3px 0; + white-space: pre-wrap; + word-break: break-all; + } + + /* test-result error rows (tx-cmd--result) */ + .tx-cmd--result { align-items: center; padding: 2px 10px 2px 0; gap: 0; } + .tx-cmd-num { + width: 30px; + text-align: right; + padding-right: 6px; + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; + } + .tx-cmd-pin { + flex: 1; + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + } + .tx-cmd-method { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.3px; + flex-shrink: 0; + min-width: 42px; + text-align: right; + border-radius: 3px; + padding: 1px 4px; + } + .tx-cmd-method--pass { color: var(--pass); background: color-mix(in srgb, var(--pass) 12%, transparent); } + .tx-cmd-method--fail { color: var(--fail); background: color-mix(in srgb, var(--fail) 12%, transparent); } + .tx-cmd-method--child { color: var(--text-muted); background: var(--bg-card); } + .tx-cmd-msg--error { color: var(--fail); } + + /* ══ Browser panel ════════════════════════════════════════════════ */ + + .tx-browser { + flex: 1; + display: flex; + flex-direction: column; + background: #fff; + overflow: hidden; + min-width: 0; + } + + .tx-browser-toolbar { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + + .tx-browser-tabs { + display: flex; + gap: 4px; + margin-left: auto; + } + + .tx-browser-tab { + padding: 6px 10px; + border-radius: var(--radius); + border: 1px solid var(--border-s); + background: var(--bg-card); + color: var(--text-dim); + cursor: pointer; + font-size: 11px; + transition: all 0.1s; + } + .tx-browser-tab:hover { background: var(--bg-hover); color: var(--text); } + .tx-browser-tab.active { background: var(--jade-bg); color: var(--jade); border-color: var(--jade); } + + .tx-nav-btn { + width: 28px; + height: 28px; + border-radius: var(--radius); + background: transparent; + border: 1px solid var(--border-s); + color: var(--text-dim); + font-size: 15px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.1s; + line-height: 1; + } + .tx-nav-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--border-s); } + + .tx-url-bar { + flex: 1; + display: flex; + align-items: center; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: var(--radius); + overflow: hidden; + transition: border-color 0.12s; + } + .tx-url-bar:focus-within { border-color: var(--jade); } + + .tx-url-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--text); + font-size: 12px; + font-family: var(--font-mono); + padding: 5px 10px; + } + .tx-url-input::placeholder { color: var(--text-muted); } + + .tx-go-btn { + padding: 5px 11px; + background: transparent; + border: none; + border-left: 1px solid var(--border-s); + color: var(--text-dim); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.1s; + white-space: nowrap; + } + .tx-go-btn:hover { background: var(--jade-bg); color: var(--jade); } + + .tx-viewport-tag { + font-size: 10px; + color: var(--text-muted); + font-family: var(--font-mono); + white-space: nowrap; + padding: 4px 9px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); + flex-shrink: 0; + } + + + /* ══ Tab bar ══════════════════════════════════════════════════ */ + + .tx-tab-bar { + display: flex; + align-items: center; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + padding: 0 4px; + height: 32px; + gap: 2px; + flex-shrink: 0; + overflow-x: auto; + } + .tx-tab-bar::-webkit-scrollbar { height: 2px; } + .tx-tab-bar::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-tab-item { + display: flex; align-items: center; gap: 5px; + padding: 0 8px 0 10px; + height: 26px; + border-radius: var(--radius); + background: transparent; + cursor: pointer; + max-width: 180px; + min-width: 80px; + user-select: none; + transition: background 0.1s; + flex-shrink: 0; + } + .tx-tab-item:hover { background: var(--bg-hover); } + .tx-tab-item.active { background: var(--bg-active); } + + .tx-tab-title { + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + font-size: 11px; color: var(--text-dim); + } + .tx-tab-item.active .tx-tab-title { color: var(--text); } + + .tx-tab-close { + width: 14px; height: 14px; border-radius: 3px; + background: transparent; border: none; cursor: pointer; + color: var(--text-muted); font-size: 10px; line-height: 1; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; opacity: 0; transition: opacity 0.1s, background 0.1s; + } + .tx-tab-item:hover .tx-tab-close { opacity: 1; } + .tx-tab-close:hover { background: var(--fail-bg); color: var(--fail); } + + .tx-new-tab-btn { + width: 24px; height: 24px; border-radius: var(--radius); + background: transparent; border: 1px solid var(--border-s); + color: var(--text-muted); font-size: 14px; cursor: pointer; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; transition: all 0.1s; margin-left: 2px; + } + .tx-new-tab-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--jade); } + + .tx-snapshot-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px; + background: var(--bg-panel); + border-bottom: 1px solid var(--border); + } + .tx-snapshot-header-meta { + min-width: 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + overflow: hidden; + } + .tx-snapshot-header-text { + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; + overflow: hidden; + } + .tx-snapshot-title { + font-size: 13px; + font-weight: 700; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-snapshot-url { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-snapshot-close-btn { + border: 1px solid var(--border-s); + background: transparent; + color: var(--text); + border-radius: var(--radius); + padding: 6px 10px; + cursor: pointer; + font-size: 12px; + white-space: nowrap; + } + .tx-snapshot-close-btn:hover { + background: var(--bg-hover); + } + #snapshotViewportWrapper { + flex: 1; + overflow: hidden; + background: var(--bg-app); + position: relative; + } + #snapshotFrame { + background: #fff; + width: 100%; + height: 100%; + } + .tx-cmd.has-snapshot { + cursor: pointer; + } + .tx-cmd.has-snapshot:hover { + background: var(--bg-hover); + } + .tx-cmd-snapshot-badge { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--jade); + flex-shrink: 0; + align-self: center; + box-shadow: 0 0 0 2px var(--jade-bg); + } + + .tx-attachment-img { + display: block; + max-width: calc(100% - 29px); + max-height: 160px; + margin: 4px 0 6px 29px; + border-radius: 3px; + border: 1px solid var(--border); + object-fit: contain; + cursor: zoom-in; + } + .tx-attachment-html-btn { + display: inline-flex; + align-items: center; + margin: 3px 0 3px 29px; + padding: 3px 9px; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: 3px; + color: var(--jade); + font-size: 11px; + font-family: var(--font-mono); + cursor: pointer; + transition: background 0.1s, border-color 0.1s; + letter-spacing: 0.01em; + } + .tx-attachment-html-btn:hover { background: var(--bg-hover); border-color: var(--jade); } + + .tx-browser-main { + flex: 1; + display: flex; + position: relative; + overflow: hidden; + } + .tx-browser-pane { + flex: 1; + min-width: 0; + background: var(--bg-app); + overflow: hidden; + position: relative; + display: flex; + flex-direction: column; + } + .tx-browser-pane--hidden { display: none; } + #iframe-container { flex: 1; overflow: hidden; background: var(--bg-app); position: relative; } + .tx-time-travel-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 0; + } + .tx-time-travel-open { + border: 1px solid var(--border-s); + background: transparent; + color: var(--text-dim); + border-radius: var(--radius); + padding: 5px 10px; + cursor: pointer; + font-size: 11px; + transition: all 0.1s; + } + .tx-time-travel-open:hover { background: var(--bg-hover); color: var(--text); } + .tx-time-travel-open:disabled { opacity: 0.5; cursor: not-allowed; } + iframe { width: 100%; height: 100%; border: none; display: block; } + + .tx-empty { + padding: 24px 14px; + text-align: center; + color: var(--text-muted); + font-size: 11px; + line-height: 1.6; + } + .tx-loading { + padding: 24px 14px; + text-align: center; + color: var(--text-muted); + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + } + .tx-loading::before { + content: ''; + display: block; + width: 13px; + height: 13px; + border: 1.5px solid var(--border-s); + border-top-color: var(--jade); + border-radius: 50%; + animation: tx-spin 0.7s linear infinite; + flex-shrink: 0; + } + @keyframes tx-spin { + to { transform: rotate(360deg); } + } + + /* ══ Network panel ═══════════════════════════════════════════ */ + + .tx-network-toggle-btn { + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border-s); + border-radius: var(--radius); + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.1s; + flex-shrink: 0; + } + .tx-network-toggle-btn:hover { background: var(--bg-hover); color: var(--text); border-color: var(--jade); } + .tx-network-toggle-btn.active { background: var(--jade-bg); color: var(--jade); border-color: var(--jade); } + + .tx-network { + display: none; + flex-direction: column; + background: var(--bg-panel); + flex-shrink: 0; + overflow: hidden; + height: 200px; + } + .tx-network.open { display: flex; } + + .tx-network-resize-handle { + height: 6px; + flex-shrink: 0; + cursor: row-resize; + display: flex; + align-items: center; + justify-content: center; + } + .tx-network-resize-handle::before { + content: ''; + height: 1px; + width: 100%; + background: var(--border); + transition: background 0.15s, height 0.1s; + } + .tx-network-resize-handle:hover::before, + .tx-network-resize-handle.dragging::before { background: var(--jade); height: 2px; } + + .tx-devtools-tabs { + display: flex; + align-items: stretch; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + padding: 0 6px; + gap: 2px; + } + .tx-devtools-tab { + padding: 5px 10px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--text-muted); + font-size: 11px; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + margin-bottom: -1px; + transition: color 0.1s; + flex-shrink: 0; + } + .tx-devtools-tab:hover { color: var(--text); } + .tx-devtools-tab.active { color: var(--text); border-bottom-color: var(--jade); } + .tx-devtools-tab-count { + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 8px; + background: var(--bg-card); + color: var(--text-muted); + min-width: 18px; + text-align: center; + } + .tx-devtools-tab-count.has-errors { background: var(--fail-bg); color: var(--fail); } + .tx-devtools-tab-count:empty { display: none; } + .tx-devtools-spacer { flex: 1; } + .tx-network-clear-btn { + padding: 2px 8px; + align-self: center; + background: transparent; + border: 1px solid var(--border-s); + border-radius: 3px; + color: var(--text-dim); + font-size: 11px; + cursor: pointer; + transition: all 0.1s; + flex-shrink: 0; + } + .tx-network-clear-btn:hover { background: var(--bg-hover); color: var(--text); } + + .tx-devtab-content { display: none; flex: 1; overflow: hidden; flex-direction: column; } + .tx-devtab-content.active { display: flex; } + + .tx-console-body { + flex: 1; + overflow-y: auto; + font-family: var(--font-mono); + font-size: 11px; + } + .tx-console-body::-webkit-scrollbar { width: 3px; } + .tx-console-body::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-console-row { + display: flex; + align-items: baseline; + padding: 2px 10px; + gap: 8px; + line-height: 1.6; + border-bottom: 1px solid var(--border); + border-left: 2px solid transparent; + cursor: default; + transition: background 0.08s; + } + .tx-console-row:hover { background: var(--bg-hover); } + .tx-console-row.log { color: var(--text-dim); } + .tx-console-row.debug { color: var(--text-muted); } + .tx-console-row.info { color: #60a5fa; border-left-color: #60a5fa; } + .tx-console-row.warning { color: var(--warn); background: rgba(245,158,11,0.05); border-left-color: var(--warn); } + .tx-console-row.error, + .tx-console-row.pageerror { color: var(--fail); background: var(--fail-bg); border-left-color: var(--fail); } + .tx-console-row.trace { color: var(--text-muted); } + + .tx-con-level { + font-size: 9px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + flex-shrink: 0; + min-width: 40px; + } + .tx-con-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-wrap; + min-width: 0; + } + .tx-con-url { + font-size: 10px; + color: var(--text-muted); + flex-shrink: 0; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .tx-network-header { + display: grid; + grid-template-columns: 60px 50px 46px 1fr 62px; + padding: 2px 10px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + background: var(--bg-topbar); + flex-shrink: 0; + } + + .tx-network-content { + flex: 1; + display: flex; + overflow: hidden; + } + + .tx-network-list { + display: flex; + flex-direction: column; + overflow: hidden; + flex: 1; + min-width: 180px; + } + + .tx-network-body { + flex: 1; + overflow-y: auto; + } + .tx-network-body::-webkit-scrollbar { width: 3px; } + .tx-network-body::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-network-detail { + display: none; + width: clamp(240px, 45%, 420px); + flex-shrink: 0; + border-left: 1px solid var(--border); + background: var(--bg-app); + flex-direction: column; + overflow: hidden; + } + .tx-network-detail.open { display: flex; } + + .tx-network-detail-toolbar { + display: flex; + align-items: center; + padding: 4px 8px 4px 10px; + background: var(--bg-topbar); + border-bottom: 1px solid var(--border); + flex-shrink: 0; + gap: 6px; + } + .tx-network-detail-title { + flex: 1; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--text-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-network-detail-close { + width: 18px; + height: 18px; + border-radius: 3px; + background: transparent; + border: none; + color: var(--text-muted); + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all 0.1s; + } + .tx-network-detail-close:hover { background: var(--fail-bg); color: var(--fail); } + + .tx-network-detail-body { + flex: 1; + overflow-y: auto; + padding-bottom: 12px; + } + .tx-network-detail-body::-webkit-scrollbar { width: 3px; } + .tx-network-detail-body::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-nd-section { border-bottom: 1px solid var(--border); padding: 5px 0; } + .tx-nd-section-title { + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.7px; + text-transform: uppercase; + color: var(--text-muted); + padding: 3px 10px 4px; + } + .tx-nd-row { + display: flex; + padding: 1px 10px; + gap: 8px; + font-family: var(--font-mono); + font-size: 10.5px; + line-height: 1.55; + min-width: 0; + } + .tx-nd-row:hover { background: var(--bg-card); } + .tx-nd-key { + color: var(--text-muted); + flex-shrink: 0; + width: 20%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-nd-val { + color: var(--text); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + .tx-nd-val.wrap { white-space: pre-wrap; word-break: break-all; } + .tx-nd-pre { + margin: 3px 10px; + padding: 6px 8px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; + color: var(--text-dim); + white-space: pre-wrap; + word-break: break-all; + max-height: 180px; + overflow-y: auto; + } + .tx-nd-pre::-webkit-scrollbar { width: 3px; } + .tx-nd-pre::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-network-row.selected { background: var(--bg-active); } + + .tx-network-row { + display: grid; + grid-template-columns: 60px 50px 46px 1fr 62px; + padding: 2px 10px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.6; + color: var(--text-dim); + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: background 0.08s; + } + .tx-network-row:hover { background: var(--bg-hover); } + .tx-network-row.selected { background: var(--bg-active); } + .tx-network-row.pending { opacity: 0.55; } + .tx-network-row.failed .tx-net-url { color: var(--fail); } + + .tx-net-method { + font-weight: 700; + font-size: 10px; + color: var(--jade); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-net-status { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-net-status.ok { color: var(--pass); } + .tx-net-status.redirect { color: var(--warn); } + .tx-net-status.error { color: var(--fail); } + .tx-net-type { + font-size: 10px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .tx-net-url { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + min-width: 0; + } + .tx-net-dur { + text-align: right; + color: var(--text-muted); + white-space: nowrap; + } + .tx-empty-network { + padding: 18px 14px; + text-align: center; + color: var(--text-muted); + font-size: 11px; + } + + /* ══ Selector playground ═════════════════════════════════════ */ + + .tx-selector-body { + flex: 1; + display: flex; + flex-direction: column; + padding: 12px 14px; + gap: 8px; + overflow: auto; + } + + .tx-selector-row { + display: flex; + align-items: center; + gap: 8px; + } + + .tx-selector-input { + flex: 1; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: var(--radius); + color: var(--text); + font-family: var(--font-mono); + font-size: 12px; + padding: 6px 10px; + outline: none; + transition: border-color 0.15s; + } + .tx-selector-input:focus { border-color: var(--jade); } + .tx-selector-input.error { border-color: var(--fail); } + + .tx-selector-clear-btn { + padding: 5px 11px; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: var(--radius); + color: var(--text-dim); + font-size: 12px; + cursor: pointer; + transition: background 0.12s, color 0.12s; + flex-shrink: 0; + } + .tx-selector-clear-btn:hover { background: var(--bg-hover); color: var(--text); } + + .tx-selector-status { + font-size: 11px; + color: var(--text-dim); + min-height: 16px; + } + .tx-selector-status.match { color: var(--jade); } + .tx-selector-status.error { color: var(--fail); } + .tx-selector-status.zero { color: var(--warn); } + + .tx-selector-matches { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 3px; + } + .tx-selector-matches::-webkit-scrollbar { width: 3px; } + .tx-selector-matches::-webkit-scrollbar-thumb { background: var(--border-s); } + + .tx-selector-match-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 4px 8px; + background: var(--bg-card); + border-radius: var(--radius); + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-dim); + cursor: pointer; + transition: background 0.1s; + } + .tx-selector-match-item:hover { background: var(--bg-hover); color: var(--text); } + + .tx-selector-match-idx { + color: var(--text-muted); + font-size: 10px; + min-width: 18px; + flex-shrink: 0; + } + .tx-selector-match-tag { color: var(--jade); } + .tx-selector-match-id { color: #a78bfa; } + .tx-selector-match-cls { color: #60a5fa; } + .tx-selector-match-text { color: var(--text-dim); margin-left: 4px; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + /* ══ Log groups ══════════════════════════════════════════════ */ + + .tx-cmd-group { + border-left: 2px solid var(--border-s); + margin: 1px 0; + } + .tx-cmd-group.pass { border-left-color: var(--pass); } + .tx-cmd-group.fail { border-left-color: var(--fail); } + .tx-cmd-group.warn { border-left-color: var(--warn); } + + .tx-cmd-group-hdr { + display: flex; + align-items: baseline; + padding: 3px 14px 3px 10px; + gap: 6px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.55; + cursor: pointer; + user-select: none; + } + .tx-cmd-group-hdr:hover { background: var(--bg-card); } + + .tx-cmd-group-chevron { + width: 13px; + text-align: center; + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.14s; + } + .tx-cmd-group.open .tx-cmd-group-chevron { transform: rotate(90deg); } + + .tx-cmd-group-msg { + flex: 1; + color: var(--text); + word-break: break-word; + } + + .tx-cmd-group-body { + display: none; + margin-left: 10px; + } + .tx-cmd-group.open .tx-cmd-group-body { display: block; } + + /* ══ Keyboard shortcut help ══════════════════════════════════ */ + + .tx-kbd-help { + position: relative; + display: flex; + align-items: center; + } + + .tx-kbd-help-btn { + width: 22px; + height: 22px; + border-radius: 50%; + background: transparent; + border: 1px solid var(--border-s); + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + cursor: default; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.1s; + flex-shrink: 0; + } + .tx-kbd-help:hover .tx-kbd-help-btn { background: var(--bg-hover); color: var(--text); } + + .tx-kbd-tooltip { + display: none; + position: absolute; + top: calc(100% + 8px); + right: 0; + background: var(--bg-panel); + border: 1px solid var(--border-s); + border-radius: var(--radius); + padding: 6px 10px; + min-width: 190px; + z-index: 100; + box-shadow: 0 4px 16px rgba(0,0,0,0.4); + pointer-events: none; + } + .tx-kbd-help:hover .tx-kbd-tooltip { display: block; } + + .tx-kbd-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 3px 0; + font-size: 11px; + color: var(--text-dim); + } + + .tx-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + background: var(--bg-card); + border: 1px solid var(--border-s); + border-radius: 3px; + font-family: var(--font-mono); + font-size: 10px; + color: var(--text); + white-space: nowrap; + flex-shrink: 0; + } diff --git a/src/panel/controlPanel.html b/src/panel/controlPanel.html new file mode 100644 index 0000000..3d3bec0 --- /dev/null +++ b/src/panel/controlPanel.html @@ -0,0 +1,145 @@ + + + + + + Test Expert + + + + +
+ +
+ + +
+ +
+ + Initializing… +
+
+ + +
+
+
+ +
+ + + + +
+ + +
+
+ +
+ + +
+ + +
+
+
+
+
+
+
+
+
+
+
Snapshot
+
+
+ +
+ +
+
+ +
+
+
+
+
+
+ + + +
+ +
+
+
+
+
+ Method + Status + Type + URL + Duration +
+
+
No requests yet
+
+
+
+
+ Details + +
+
+
+
+
+
+
+
No console output yet
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ +
+ + {{CONFIG_SCRIPT}} + + + diff --git a/src/panel/controlPanel.ts b/src/panel/controlPanel.ts index 2fc66f5..aa3948e 100644 --- a/src/panel/controlPanel.ts +++ b/src/panel/controlPanel.ts @@ -1,8 +1,6 @@ -/** - * Control Panel - Tx HTML UI - */ - import { DEFAULT_CONTROL_PANEL_PORT } from '../constants'; +import panelHtml from './controlPanel.html'; +import panelCss from './controlPanel.css'; export interface ControlPanelConfig { proxyUrl: string; @@ -17,1671 +15,35 @@ export interface ControlPanelConfig { retries?: number; } -export function generateControlPanelHTML({ proxyUrl, controlPanelPort = DEFAULT_CONTROL_PANEL_PORT, viewport, testMode, snapshot, grep, actionTimeout, expectTimeout, testTimeout, retries }: ControlPanelConfig): string { - return ` - - - - - Test Expert - - - - -
- -
- - -
- -
- - Initializing… -
-
- - -
-
-
- -
- - - - -
- - -
-
- -
- - -
- - -
-
-
-
-
-
-
-
-
-
-
Snapshot
-
-
- -
- -
-
- -
-
-
-
-
-
- - - -
- -
-
-
-
-
- Method - Status - Type - URL - Duration -
-
-
No requests yet
-
-
-
-
- Details - -
-
-
-
-
-
-
-
No console output yet
-
-
-
-
-
- - -
-
-
-
-
-
-
- -
+function buildConfigScript({ + proxyUrl, + controlPanelPort = DEFAULT_CONTROL_PANEL_PORT, + viewport, + testMode, + snapshot, + grep, + actionTimeout, + expectTimeout, + testTimeout, + retries, +}: ControlPanelConfig): string { + const props: string[] = [ + `proxyUrl: "${proxyUrl}"`, + `port: ${controlPanelPort}`, + ]; + if (viewport) props.push(`viewport: { width: ${viewport.width}, height: ${viewport.height} }`); + if (testMode) props.push(`autorun: true`); + if (snapshot) props.push(`snapshot: true`); + if (grep) { props.push(`grep: ${JSON.stringify(grep.source)}`); props.push(`grepFlags: ${JSON.stringify(grep.flags)}`); } + if (actionTimeout != null) props.push(`actionTimeout: ${actionTimeout}`); + if (expectTimeout != null) props.push(`expectTimeout: ${expectTimeout}`); + if (testTimeout != null) props.push(`testTimeout: ${testTimeout}`); + if (retries != null) props.push(`retries: ${retries}`); + return ``; +} - - - -`; +export function generateControlPanelHTML(config: ControlPanelConfig): string { + return (panelHtml as string) + .replace('{{CSS}}', panelCss as string) + .replace('{{CONFIG_SCRIPT}}', buildConfigScript(config)); } diff --git a/src/panel/render.ts b/src/panel/render.ts new file mode 100644 index 0000000..43f1e52 --- /dev/null +++ b/src/panel/render.ts @@ -0,0 +1,76 @@ +import { escHtml, escAttr, jsq } from '../utils/htmlUtils'; + +export interface ParsedTest { suite: string; name: string; tags?: string[]; } +export interface ParsedFile { filename: string; relPath?: string; tests: ParsedTest[]; } + +export function renderTestItemHtml(filename: string, suite: string, name: string, tags: string[]): string { + const fullName = suite === '(root)' ? name : suite + ' > ' + name; + const stateIcons = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + const key = escAttr(filename + '\x01' + fullName); + const tagsHtml = tags.length > 0 + ? '' + tags.map(t => '' + escHtml(t) + '').join('') + '' + : ''; + return '
' + + '' + + '' + stateIcons + '' + + '' + escHtml(name) + '' + + tagsHtml + + '' + + '' + + '
' + + '
'; +} + +export function renderSuiteHtml(filename: string, suite: string, items: Array<{ name: string; tags: string[] }>): string { + const key = escAttr(filename + '\x01' + suite); + return '
' + + '' + + '' + escHtml(suite) + '' + + '' + + '' + + '
' + items.map(({ name, tags }) => renderTestItemHtml(filename, suite, name, tags)).join(''); +} + +export function renderTestFileCard(f: ParsedFile): string { + const suites: Record> = Object.create(null); + f.tests.forEach(t => { + const k = t.suite || '(root)'; + if (!suites[k]) suites[k] = []; + suites[k].push({ name: t.name, tags: t.tags ?? [] }); + }); + const suiteHtml = Object.entries(suites).map(([s, items]) => renderSuiteHtml(f.filename, s, items)).join(''); + const display = f.relPath ?? f.filename; + const ext = display.split('.').pop() ?? 'js'; + const noExt = display.slice(0, -(ext.length + 1)); + const lastSlash = noExt.lastIndexOf('/'); + const dir = lastSlash >= 0 ? noExt.slice(0, lastSlash + 1) : ''; + const stem = lastSlash >= 0 ? noExt.slice(lastSlash + 1) : noExt; + return '
' + + '
' + + '' + + '' + + (dir ? '' + escHtml(dir) + '' : '') + + escHtml(stem) + '.' + escHtml(ext) + '' + + '' + + '' + + '' + + '
' + + (Object.keys(suites).length ? '
' + suiteHtml + '
' : '') + + '
'; +} diff --git a/src/panel/runner-bridge.ts b/src/panel/runner-bridge.ts new file mode 100644 index 0000000..8ea1b69 --- /dev/null +++ b/src/panel/runner-bridge.ts @@ -0,0 +1,40 @@ +import { executeTests } from '../runner/testRunner'; +import { wsRequest } from '../browser/browser'; +import type { TestResult } from '../runner/executor'; + +export type { TestResult }; + +export interface RunCallbacks { + isStopRequested: () => boolean; + setCancelFn: (fn: ((err: Error) => void) | null) => void; + onAttemptBegin?: (testName: string, attempt: number) => void; + onAttemptError?: (message: string) => void; + onAttemptFinally?: (testName: string, passed: boolean, attemptsLeft: number) => void; + onTestEnd: (result: TestResult) => void; +} + +export interface RunSpec { + filterSuite?: string; + filterTest?: string; + filterTests?: string[]; +} + +export async function fetchAndRun( + filename: string, + spec: RunSpec | null, + callbacks: RunCallbacks, +): Promise { + const msg = await wsRequest<{ data?: string; error?: string }>('get-test-source', { file: filename }); + if (msg.error || !msg.data) throw new Error(msg.error ?? 'Failed to load test source'); + return executeTests(msg.data, { + filterSuite: spec?.filterSuite, + filterTest: spec?.filterTest, + filterTests: spec?.filterTests, + isStopRequested: callbacks.isStopRequested, + setCancelFn: callbacks.setCancelFn, + onAttemptBegin: callbacks.onAttemptBegin, + onAttemptError: callbacks.onAttemptError, + onAttemptFinally: callbacks.onAttemptFinally, + onTestEnd: callbacks.onTestEnd, + }); +} diff --git a/src/panel/selectors.ts b/src/panel/selectors.ts new file mode 100644 index 0000000..e3afbbd --- /dev/null +++ b/src/panel/selectors.ts @@ -0,0 +1,23 @@ +/** Canonical element IDs shared between controlPanel.html and devPanel.ts. */ +export const SEL = { + networkPanel: 'networkPanel', + networkResizeHandle: 'networkResizeHandle', + networkList: 'networkList', + networkDetail: 'networkDetail', + networkDetailTitle: 'networkDetailTitle', + networkDetailBody: 'networkDetailBody', + networkCount: 'networkCount', + networkToggleBtn: 'networkToggleBtn', + consoleList: 'consoleList', + consoleCount: 'consoleCount', + consoleErrorBadge: 'consoleErrorBadge', + selectorInput: 'selectorInput', + selectorStatus: 'selectorStatus', + selectorMatches: 'selectorMatches', + devTabNetwork: 'devTabNetwork', + devTabConsole: 'devTabConsole', + devTabSelector: 'devTabSelector', + devTabContentNetwork: 'devTabContentNetwork', + devTabContentConsole: 'devTabContentConsole', + devTabContentSelector:'devTabContentSelector', +} as const; diff --git a/src/reporters/HtmlReporter.ts b/src/reporters/HtmlReporter.ts index 7533350..9ba3ed7 100644 --- a/src/reporters/HtmlReporter.ts +++ b/src/reporters/HtmlReporter.ts @@ -1,6 +1,8 @@ import { writeFileSync, mkdirSync } from 'node:fs'; import { resolve, dirname } from 'node:path'; import type { Reporter, FullConfig, Suite, TestCase, TestResult, FullResult, LogEntry } from '../runner/reporter'; +import reportCss from './report.css'; +import reportJs from './report.iife.js'; interface StepEntry { cmd: string; @@ -127,480 +129,6 @@ function fmtDur(ms: number): string { return `${ms}ms`; } -// ── CSS ─────────────────────────────────────────────────────────────────────── - -const CSS = ` -:root { - --bg: #f1f5f9; - --surface: #fff; - --border: #e2e8f0; - --text: #1e293b; - --muted: #64748b; - --pass: #16a34a; - --pass-light: #dcfce7; - --pass-dark: #166534; - --fail: #dc2626; - --fail-light: #fee2e2; - --fail-dark: #991b1b; - --skip: #d97706; - --skip-light: #fef3c7; - --skip-dark: #92400e; - --primary: #2563eb; - --radius: 10px; - --shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.05); -} -*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 14px; } - -/* ── Header ── */ -.hdr { background: #0f172a; color: #f8fafc; } -.hdr-inner { max-width: 1440px; margin: 0 auto; padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; } -.hdr-left { display: flex; align-items: center; gap: 10px; } -.logo { width: 20px; height: 20px; color: #60a5fa; flex-shrink: 0; } -.hdr h1 { font-size: 1rem; font-weight: 600; color: #f8fafc; letter-spacing: -.01em; } -.hdr-right { display: flex; align-items: center; gap: 14px; } -.run-badge { padding: 3px 11px; border-radius: 20px; font-size: 0.7rem; font-weight: 700; letter-spacing: .08em; } -.run-badge.pass { background: var(--pass-light); color: var(--pass-dark); } -.run-badge.fail { background: var(--fail-light); color: var(--fail-dark); } -.gen-time { font-size: 0.75rem; color: #94a3b8; } - -/* ── Main ── */ -main { max-width: 1440px; margin: 0 auto; padding: 20px 24px 40px; } - -/* ── Stats ── */ -.stats { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } -.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; flex: 1; min-width: 100px; box-shadow: var(--shadow); } -.stat .n { font-size: 1.75rem; font-weight: 700; line-height: 1; letter-spacing: -.02em; } -.stat .l { font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; margin-top: 3px; } -.stat.pass .n { color: var(--pass); } -.stat.fail .n { color: var(--fail); } -.stat.skip .n { color: var(--skip); } -.rbar { height: 3px; background: var(--border); border-radius: 2px; margin-top: 10px; overflow: hidden; } -.rfill { height: 100%; border-radius: 2px; } -.rfill.pass { background: var(--pass); } -.rfill.fail { background: var(--fail); } - -/* ── Toolbar ── */ -.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } -.sw { position: relative; flex: 1; min-width: 180px; } -.si { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: #94a3b8; pointer-events: none; } -.si-x { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: #94a3b8; background: none; border: none; cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0; } -.si-x.visible { display: flex; } -.search { width: 100%; padding: 7px 30px 7px 32px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.85rem; background: var(--surface); outline: none; transition: border-color .15s, box-shadow .15s; } -.search:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } -.fbs { display: flex; gap: 5px; flex-wrap: wrap; } -.fb { padding: 6px 12px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 5px; transition: all .15s; white-space: nowrap; color: var(--text); } -.fb:hover { background: #f8fafc; border-color: #cbd5e1; } -.fb.active { background: #0f172a; color: #fff; border-color: #0f172a; } -.fb.pass.active { background: var(--pass); border-color: var(--pass); } -.fb.fail.active { background: var(--fail); border-color: var(--fail); } -.fb.skip.active { background: var(--skip); border-color: var(--skip); } -.fb span { font-weight: 600; } -.jump { padding: 6px 12px; background: var(--fail-light); color: var(--fail); border: 1px solid #fecaca; border-radius: 8px; font-size: 0.8rem; cursor: pointer; white-space: nowrap; transition: background .15s; } -.jump:hover { background: #fecaca; } - -/* ── Suite buttons ── */ -.suite-btns { display: flex; gap: 5px; } -.suite-btn { padding: 6px 12px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; font-size: 0.8rem; cursor: pointer; transition: all .15s; white-space: nowrap; color: var(--text); } -.suite-btn:hover { background: #f8fafc; border-color: #cbd5e1; } - -/* ── Groups ── */ -#groups { display: flex; flex-direction: column; gap: 8px; } - -/* ── Group ── */ -.group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); } -.group.has-fail { border-color: #fca5a5; } -.gh { display: flex; align-items: center; gap: 10px; padding: 11px 14px; cursor: pointer; user-select: none; transition: background .15s; } -.gh:hover { background: #f8fafc; } -.group.has-fail .gh { background: #fff5f5; } -.group.has-fail .gh:hover { background: #fee2e2; } -.chev { width: 15px; height: 15px; color: #94a3b8; flex-shrink: 0; transition: transform .2s; } -.chev.open { transform: rotate(90deg); } -.gname { font-size: 0.85rem; font-weight: 600; flex: 1; word-break: break-word; } -.gbadges { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; } -.gbadge { font-size: 0.68rem; padding: 2px 7px; border-radius: 10px; font-weight: 600; white-space: nowrap; } -.gbadge.pass { background: var(--pass-light); color: var(--pass-dark); } -.gbadge.fail { background: var(--fail-light); color: var(--fail-dark); } -.gbadge.skip { background: var(--skip-light); color: var(--skip-dark); } -.gbadge.total { background: #f1f5f9; color: #475569; } -.gdur { font-size: 0.72rem; color: var(--muted); white-space: nowrap; } -.gbody { border-top: 1px solid #f8fafc; } - -/* ── Test rows ── */ -.tr { border-top: 1px solid #f8fafc; } -.tr:first-child { border-top: none; } -.ts { display: flex; align-items: center; gap: 9px; padding: 9px 14px; cursor: pointer; transition: background .15s; } -.ts:hover { background: #fafafa; } -.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } -.dot.pass { background: var(--pass); } -.dot.fail { background: var(--fail); } -.dot.skip { background: var(--skip); } -.tname { flex: 1; font-size: 0.855rem; word-break: break-word; } -.tdur { font-size: 0.75rem; color: var(--muted); white-space: nowrap; } -.dbar-w { width: 64px; height: 3px; background: #f1f5f9; border-radius: 2px; overflow: hidden; flex-shrink: 0; } -.dbar { height: 100%; border-radius: 2px; } -.dbar.pass { background: #86efac; } -.dbar.fail { background: #fca5a5; } -.dbar.skip { background: #fcd34d; } -.exi { width: 13px; height: 13px; color: #cbd5e1; flex-shrink: 0; transition: transform .18s; } -.exi.open { transform: rotate(90deg); } - -/* ── Test detail ── */ -.td { display: none; background: #fafafa; border-top: 1px solid #f1f5f9; padding: 10px 14px 12px 30px; } -.td.open { display: block; } - -/* ── Steps ── */ -.steps { list-style: none; margin-bottom: 10px; } -.step { display: flex; align-items: flex-start; gap: 7px; padding: 2px 0; font-size: 0.8rem; color: #475569; line-height: 1.4; } -.sico { flex-shrink: 0; margin-top: 1px; width: 13px; height: 13px; } -.sico.pass { color: var(--pass); } -.sico.fail { color: var(--fail); } -.sico.info { color: #94a3b8; } -.sico.warn { color: var(--skip); } -.step span { font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 0.79rem; } -.sdur { color: #94a3b8; margin-left: auto; white-space: nowrap; padding-left: 10px; } - -/* ── Step groups ── */ -.step-group { list-style: none; } -.step-group-hdr { display: flex; align-items: flex-start; gap: 7px; padding: 2px 0; font-size: 0.8rem; color: #475569; line-height: 1.4; cursor: pointer; user-select: none; } -.step-group-hdr:hover { color: var(--text); } -.step-group-chev { font-size: 0.58rem; color: #94a3b8; transition: transform .15s; margin-top: 2px; flex-shrink: 0; } -.step-group.open > .step-group-hdr .step-group-chev { transform: rotate(90deg); } -.step-group-body { display: none; padding-left: 18px; border-left: 2px solid #e2e8f0; margin-left: 5px; margin-top: 2px; margin-bottom: 2px; } -.step-group.open > .step-group-body { display: block; } - -/* ── Error ── */ -.err-block { background: #fff5f5; border: 1px solid #fecaca; border-radius: 7px; padding: 10px 12px; margin-bottom: 10px; } -.err-hdr { display: flex; align-items: center; margin-bottom: 5px; } -.err-lbl { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--fail); } -.copy-btn { font-size: 0.68rem; background: none; border: 1px solid #fecaca; color: var(--fail); border-radius: 4px; padding: 1px 6px; cursor: pointer; margin-left: 8px; transition: background .12s; } -.copy-btn:hover { background: #fee2e2; } -.err-block pre { font-size: 0.78rem; color: #991b1b; white-space: pre-wrap; word-break: break-all; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; line-height: 1.5; } - -/* ── Attachments ── */ -.atts { display: flex; flex-wrap: wrap; gap: 10px; } -.att { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); max-width: 380px; } -.att.att-html { max-width: 100%; width: 100%; } -.att-lbl { font-size: 0.72rem; font-weight: 600; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 5px; } -.att-img { display: block; max-width: 100%; max-height: 220px; cursor: zoom-in; object-fit: contain; } -.att-txt { font-size: 0.75rem; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; background: #1e293b; color: #e2e8f0; padding: 10px; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; } -.att-iframe { display: block; width: 100%; height: 400px; border: none; background: #fff; } -.att-open { font-size: 0.68rem; background: none; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 1px 6px; cursor: pointer; margin-left: auto; transition: background .12s; } -.att-open:hover { background: #f1f5f9; } - -/* ── Empty ── */ -.empty { text-align: center; padding: 60px 24px; color: #94a3b8; } -.empty p { font-size: 0.9rem; } - -/* ── Lightbox ── */ -.lb { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: flex; align-items: center; justify-content: center; z-index: 9999; cursor: zoom-out; } -.lb-close { position: absolute; top: 14px; right: 18px; background: none; border: none; color: #fff; font-size: 2rem; cursor: pointer; line-height: 1; opacity: .8; } -.lb-close:hover { opacity: 1; } -#lbi { max-width: 95vw; max-height: 92vh; border-radius: 4px; cursor: default; box-shadow: 0 20px 60px rgba(0,0,0,.5); } - -/* ── Footer ── */ -footer { text-align: center; padding: 20px; font-size: 0.75rem; color: #94a3b8; border-top: 1px solid var(--border); margin-top: 8px; } - -/* ── Print ── */ -@media print { - .toolbar, footer { display: none !important; } - .td { display: block !important; } - .gbody { display: block !important; } - .group { box-shadow: none; break-inside: avoid; } -} -`; - -// ── JS ──────────────────────────────────────────────────────────────────────── - -const JS = ` -(function () { - var activeFilter = 'all'; - var query = ''; - - function sc(status) { return status === 'passed' ? 'pass' : status === 'failed' ? 'fail' : 'skip'; } - - function fmt(ms) { - if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm'; - if (ms >= 1000) return (ms / 1000).toFixed(2) + 's'; - return ms + 'ms'; - } - - function esc(s) { - return String(s || '') - .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - } - - var STEP_ICONS = { - pass: '', - fail: '', - info: '', - warn: '' - }; - - var CHEV = ''; - var EXI = ''; - var ATT_ICON = ''; - - function renderStepList(steps) { - if (!steps || !steps.length) return ''; - return '
    ' + steps.map(function(s) { - var ico = STEP_ICONS[s.state] || STEP_ICONS.info; - var dur = s.duration != null ? '' + fmt(s.duration) + '' : ''; - if (s.children && s.children.length) { - var open = s.state === 'fail' || s.state === 'warn'; - return '
  • ' - + '
    ' - + '' - + '' + esc(s.message) + '' - + dur - + '
    ' - + '
    ' + renderStepList(s.children) + '
    ' - + '
  • '; - } - return '
  • ' + ico + '' + esc(s.message) + '' + dur + '
  • '; - }).join('') + '
'; - } - - function renderTest(t) { - var cls = sc(t.status); - var steps = ''; - if (t.steps && t.steps.length) { - steps = renderStepList(t.steps); - } - - var errBlock = ''; - if (t.error) { - errBlock = '
Error
' + esc(t.error) + '
'; - } - - var atts = ''; - if (t.attachments && t.attachments.length) { - atts = '
' + t.attachments.map(function(a, ai) { - var inner, attCls = 'att', openBtn = ''; - if (a.isImage) { - inner = '' + esc(a.label) + ''; - } else if (a.isHtml) { - attCls = 'att att-html'; - inner = ''; - openBtn = ''; - } else { - inner = '
' + esc(a.body) + (a.body && a.body.length >= 4000 ? '\\n…' : '') + '
'; - } - return '
' + ATT_ICON + esc(a.label) + openBtn + '
' + inner + '
'; - }).join('') + '
'; - } - - var hasDetail = steps || errBlock || atts; - - return '
' - + '
' - + '' - + '' + esc(t.title) + '' - + '' + fmt(t.duration) + '' - + '
' - + (hasDetail ? EXI : '') - + '
' - + (hasDetail ? '
' + steps + errBlock + atts + '
' : '') - + '
'; - } - - function groupBy(tests) { - var map = []; - var idx = {}; - tests.forEach(function(t) { - var s = t.suite || '(root)'; - if (!(s in idx)) { idx[s] = map.length; map.push({ name: s, tests: [] }); } - map[idx[s]].tests.push(t); - }); - return map; - } - - function renderGroups(filtered) { - var container = document.getElementById('groups'); - var empty = document.getElementById('empty'); - if (!filtered.length) { - container.innerHTML = ''; - empty.style.display = ''; - return; - } - empty.style.display = 'none'; - - var groups = groupBy(filtered); - container.innerHTML = groups.map(function(g) { - var passed = g.tests.filter(function(t){return t.status==='passed';}).length; - var failed = g.tests.filter(function(t){return t.status==='failed';}).length; - var skipped = g.tests.filter(function(t){return t.status==='skipped';}).length; - var dur = g.tests.reduce(function(a,b){return a+b.duration;},0); - - var badges = ''; - if (failed) badges += '' + failed + ' failed'; - if (passed) badges += '' + passed + ' passed'; - if (skipped) badges += '' + skipped + ' skipped'; - badges += '' + g.tests.length + ' total'; - - var startOpen = failed > 0; - return '
' - + '
' + (startOpen ? CHEV : CHEV.replace('chev open','chev')) + '' + esc(g.name) + '' - + '
' + badges + '
' - + '' + fmt(dur) + '
' - + '' - + '
'; - }).join(''); - - wireEvents(); - } - - function wireEvents() { - // expand/collapse test detail - document.querySelectorAll('.ts').forEach(function(el) { - el.addEventListener('click', function() { - var row = el.closest('.tr'); - var detail = row && row.querySelector('.td'); - if (!detail) return; - var icon = el.querySelector('.exi'); - var open = detail.classList.toggle('open'); - if (icon) icon.classList.toggle('open', open); - }); - }); - - // copy error - document.querySelectorAll('.copy-btn[data-tid]').forEach(function(btn) { - btn.addEventListener('click', function(ev) { - ev.stopPropagation(); - var t = DATA.find(function(d){ return d.id === parseInt(btn.dataset.tid); }); - if (!t || !t.error) return; - navigator.clipboard.writeText(t.error).then(function() { - btn.textContent = 'Copied!'; - setTimeout(function(){ btn.textContent = 'Copy'; }, 1500); - }); - }); - }); - - // image lightbox - document.querySelectorAll('[data-lb]').forEach(function(img) { - img.addEventListener('click', function(ev) { - ev.stopPropagation(); - document.getElementById('lbi').src = img.src; - document.getElementById('lb').style.display = 'flex'; - }); - }); - - // open HTML attachment in new tab - document.querySelectorAll('.att-open[data-tid]').forEach(function(btn) { - btn.addEventListener('click', function(ev) { - ev.stopPropagation(); - var t = DATA.find(function(d) { return d.id === parseInt(btn.dataset.tid); }); - var a = t && t.attachments[parseInt(btn.dataset.ai)]; - if (!a || !a.body) return; - var blob = new Blob([a.body], { type: 'text/html' }); - var url = URL.createObjectURL(blob); - window.open(url, '_blank'); - setTimeout(function() { URL.revokeObjectURL(url); }, 10000); - }); - }); - - // auto-expand failed tests - document.querySelectorAll('.tr[data-status="failed"]').forEach(function(row) { - var detail = row.querySelector('.td'); - var icon = row.querySelector('.exi'); - if (detail) detail.classList.add('open'); - if (icon) icon.classList.add('open'); - }); - } - - // step-group toggle (event delegation — survives re-renders) - document.addEventListener('click', function(ev) { - var hdr = ev.target.closest && ev.target.closest('.step-group-hdr'); - if (!hdr) return; - var group = hdr.closest('.step-group'); - if (group) group.classList.toggle('open'); - }); - - // group collapse (event delegation — survives re-renders) - document.addEventListener('click', function(ev) { - var gh = ev.target.closest && ev.target.closest('.gh'); - if (!gh) return; - var body = gh.nextElementSibling; - var chev = gh.querySelector('.chev'); - var isOpen = body.style.display !== 'none'; - body.style.display = isOpen ? 'none' : ''; - if (chev) chev.classList.toggle('open', !isOpen); - }); - - // expand/collapse all suites - document.getElementById('expand-all').addEventListener('click', function() { - document.querySelectorAll('.gbody').forEach(function(b) { b.style.display = ''; }); - document.querySelectorAll('.chev').forEach(function(c) { c.classList.add('open'); }); - }); - document.getElementById('collapse-all').addEventListener('click', function() { - document.querySelectorAll('.gbody').forEach(function(b) { b.style.display = 'none'; }); - document.querySelectorAll('.chev').forEach(function(c) { c.classList.remove('open'); }); - }); - - // filter buttons - document.querySelectorAll('.fb').forEach(function(btn) { - btn.addEventListener('click', function() { - document.querySelectorAll('.fb').forEach(function(b){ b.classList.remove('active'); }); - btn.classList.add('active'); - activeFilter = btn.dataset.f; - refresh(); - }); - }); - - // search - var searchEl = document.getElementById('s'); - var clearBtn = document.getElementById('sx'); - searchEl.addEventListener('input', function() { - query = searchEl.value; - if (clearBtn) clearBtn.classList.toggle('visible', query.length > 0); - refresh(); - }); - if (clearBtn) { - clearBtn.addEventListener('click', function() { - searchEl.value = ''; query = ''; - clearBtn.classList.remove('visible'); - refresh(); - }); - } - - // jump to failures - var jumpBtn = document.getElementById('jf'); - if (jumpBtn) { - jumpBtn.addEventListener('click', function() { - document.querySelectorAll('.fb').forEach(function(b){ - b.classList.toggle('active', b.dataset.f === 'failed'); - }); - activeFilter = 'failed'; - refresh(); - var firstFail = document.querySelector('.group.has-fail'); - if (firstFail) firstFail.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }); - } - - // lightbox close - var lb = document.getElementById('lb'); - document.getElementById('lbc').addEventListener('click', function() { lb.style.display = 'none'; }); - lb.addEventListener('click', function(ev) { - if (ev.target === lb) lb.style.display = 'none'; - }); - document.addEventListener('keydown', function(ev) { - if (ev.key === 'Escape') lb.style.display = 'none'; - }); - - function getFiltered() { - return DATA.filter(function(t) { - var fOk = activeFilter === 'all' || t.status === activeFilter; - var qOk = !query || t.fullTitle.toLowerCase().indexOf(query.toLowerCase()) >= 0; - return fOk && qOk; - }); - } - - function refresh() { renderGroups(getFiltered()); } - - refresh(); -}()); -`; - // ── HTML builder ───────────────────────────────────────────────────────────── function buildHtml( @@ -648,7 +176,7 @@ function buildHtml( ${esc(reportTitle)} - + @@ -732,7 +260,7 @@ function buildHtml( `; diff --git a/src/reporters/JUnitReporter.ts b/src/reporters/JunitReporter.ts similarity index 100% rename from src/reporters/JUnitReporter.ts rename to src/reporters/JunitReporter.ts diff --git a/src/reporters/report.css b/src/reporters/report.css new file mode 100644 index 0000000..9abfb5d --- /dev/null +++ b/src/reporters/report.css @@ -0,0 +1,178 @@ +:root { + --bg: #f1f5f9; + --surface: #fff; + --border: #e2e8f0; + --text: #1e293b; + --muted: #64748b; + --pass: #16a34a; + --pass-light: #dcfce7; + --pass-dark: #166534; + --fail: #dc2626; + --fail-light: #fee2e2; + --fail-dark: #991b1b; + --skip: #d97706; + --skip-light: #fef3c7; + --skip-dark: #92400e; + --primary: #2563eb; + --radius: 10px; + --shadow: 0 1px 3px rgba(0,0,0,.07), 0 1px 2px rgba(0,0,0,.05); +} +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 14px; } + +/* ── Header ── */ +.hdr { background: #0f172a; color: #f8fafc; } +.hdr-inner { max-width: 1440px; margin: 0 auto; padding: 0 24px; height: 56px; display: flex; align-items: center; justify-content: space-between; } +.hdr-left { display: flex; align-items: center; gap: 10px; } +.logo { width: 20px; height: 20px; color: #60a5fa; flex-shrink: 0; } +.hdr h1 { font-size: 1rem; font-weight: 600; color: #f8fafc; letter-spacing: -.01em; } +.hdr-right { display: flex; align-items: center; gap: 14px; } +.run-badge { padding: 3px 11px; border-radius: 20px; font-size: 0.7rem; font-weight: 700; letter-spacing: .08em; } +.run-badge.pass { background: var(--pass-light); color: var(--pass-dark); } +.run-badge.fail { background: var(--fail-light); color: var(--fail-dark); } +.gen-time { font-size: 0.75rem; color: #94a3b8; } + +/* ── Main ── */ +main { max-width: 1440px; margin: 0 auto; padding: 20px 24px 40px; } + +/* ── Stats ── */ +.stats { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; } +.stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; flex: 1; min-width: 100px; box-shadow: var(--shadow); } +.stat .n { font-size: 1.75rem; font-weight: 700; line-height: 1; letter-spacing: -.02em; } +.stat .l { font-size: 0.68rem; color: var(--muted); text-transform: uppercase; letter-spacing: .07em; margin-top: 3px; } +.stat.pass .n { color: var(--pass); } +.stat.fail .n { color: var(--fail); } +.stat.skip .n { color: var(--skip); } +.rbar { height: 3px; background: var(--border); border-radius: 2px; margin-top: 10px; overflow: hidden; } +.rfill { height: 100%; border-radius: 2px; } +.rfill.pass { background: var(--pass); } +.rfill.fail { background: var(--fail); } + +/* ── Toolbar ── */ +.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; } +.sw { position: relative; flex: 1; min-width: 180px; } +.si { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: #94a3b8; pointer-events: none; } +.si-x { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: #94a3b8; background: none; border: none; cursor: pointer; display: none; align-items: center; justify-content: center; padding: 0; } +.si-x.visible { display: flex; } +.search { width: 100%; padding: 7px 30px 7px 32px; border: 1px solid var(--border); border-radius: 8px; font-size: 0.85rem; background: var(--surface); outline: none; transition: border-color .15s, box-shadow .15s; } +.search:focus { border-color: var(--primary); box-shadow: 0 0 0 3px rgba(37,99,235,.1); } +.fbs { display: flex; gap: 5px; flex-wrap: wrap; } +.fb { padding: 6px 12px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 5px; transition: all .15s; white-space: nowrap; color: var(--text); } +.fb:hover { background: #f8fafc; border-color: #cbd5e1; } +.fb.active { background: #0f172a; color: #fff; border-color: #0f172a; } +.fb.pass.active { background: var(--pass); border-color: var(--pass); } +.fb.fail.active { background: var(--fail); border-color: var(--fail); } +.fb.skip.active { background: var(--skip); border-color: var(--skip); } +.fb span { font-weight: 600; } +.jump { padding: 6px 12px; background: var(--fail-light); color: var(--fail); border: 1px solid #fecaca; border-radius: 8px; font-size: 0.8rem; cursor: pointer; white-space: nowrap; transition: background .15s; } +.jump:hover { background: #fecaca; } + +/* ── Suite buttons ── */ +.suite-btns { display: flex; gap: 5px; } +.suite-btn { padding: 6px 12px; border: 1px solid var(--border); background: var(--surface); border-radius: 8px; font-size: 0.8rem; cursor: pointer; transition: all .15s; white-space: nowrap; color: var(--text); } +.suite-btn:hover { background: #f8fafc; border-color: #cbd5e1; } + +/* ── Groups ── */ +#groups { display: flex; flex-direction: column; gap: 8px; } + +/* ── Group ── */ +.group { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); } +.group.has-fail { border-color: #fca5a5; } +.gh { display: flex; align-items: center; gap: 10px; padding: 11px 14px; cursor: pointer; user-select: none; transition: background .15s; } +.gh:hover { background: #f8fafc; } +.group.has-fail .gh { background: #fff5f5; } +.group.has-fail .gh:hover { background: #fee2e2; } +.chev { width: 15px; height: 15px; color: #94a3b8; flex-shrink: 0; transition: transform .2s; } +.chev.open { transform: rotate(90deg); } +.gname { font-size: 0.85rem; font-weight: 600; flex: 1; word-break: break-word; } +.gbadges { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; } +.gbadge { font-size: 0.68rem; padding: 2px 7px; border-radius: 10px; font-weight: 600; white-space: nowrap; } +.gbadge.pass { background: var(--pass-light); color: var(--pass-dark); } +.gbadge.fail { background: var(--fail-light); color: var(--fail-dark); } +.gbadge.skip { background: var(--skip-light); color: var(--skip-dark); } +.gbadge.total { background: #f1f5f9; color: #475569; } +.gdur { font-size: 0.72rem; color: var(--muted); white-space: nowrap; } +.gbody { border-top: 1px solid #f8fafc; } + +/* ── Test rows ── */ +.tr { border-top: 1px solid #f8fafc; } +.tr:first-child { border-top: none; } +.ts { display: flex; align-items: center; gap: 9px; padding: 9px 14px; cursor: pointer; transition: background .15s; } +.ts:hover { background: #fafafa; } +.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } +.dot.pass { background: var(--pass); } +.dot.fail { background: var(--fail); } +.dot.skip { background: var(--skip); } +.tname { flex: 1; font-size: 0.855rem; word-break: break-word; } +.tdur { font-size: 0.75rem; color: var(--muted); white-space: nowrap; } +.dbar-w { width: 64px; height: 3px; background: #f1f5f9; border-radius: 2px; overflow: hidden; flex-shrink: 0; } +.dbar { height: 100%; border-radius: 2px; } +.dbar.pass { background: #86efac; } +.dbar.fail { background: #fca5a5; } +.dbar.skip { background: #fcd34d; } +.exi { width: 13px; height: 13px; color: #cbd5e1; flex-shrink: 0; transition: transform .18s; } +.exi.open { transform: rotate(90deg); } + +/* ── Test detail ── */ +.td { display: none; background: #fafafa; border-top: 1px solid #f1f5f9; padding: 10px 14px 12px 30px; } +.td.open { display: block; } + +/* ── Steps ── */ +.steps { list-style: none; margin-bottom: 10px; } +.step { display: flex; align-items: flex-start; gap: 7px; padding: 2px 0; font-size: 0.8rem; color: #475569; line-height: 1.4; } +.sico { flex-shrink: 0; margin-top: 1px; width: 13px; height: 13px; } +.sico.pass { color: var(--pass); } +.sico.fail { color: var(--fail); } +.sico.info { color: #94a3b8; } +.sico.warn { color: var(--skip); } +.step span { font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 0.79rem; } +.sdur { color: #94a3b8; margin-left: auto; white-space: nowrap; padding-left: 10px; } + +/* ── Step groups ── */ +.step-group { list-style: none; } +.step-group-hdr { display: flex; align-items: flex-start; gap: 7px; padding: 2px 0; font-size: 0.8rem; color: #475569; line-height: 1.4; cursor: pointer; user-select: none; } +.step-group-hdr:hover { color: var(--text); } +.step-group-chev { font-size: 0.58rem; color: #94a3b8; transition: transform .15s; margin-top: 2px; flex-shrink: 0; } +.step-group.open > .step-group-hdr .step-group-chev { transform: rotate(90deg); } +.step-group-body { display: none; padding-left: 18px; border-left: 2px solid #e2e8f0; margin-left: 5px; margin-top: 2px; margin-bottom: 2px; } +.step-group.open > .step-group-body { display: block; } + +/* ── Error ── */ +.err-block { background: #fff5f5; border: 1px solid #fecaca; border-radius: 7px; padding: 10px 12px; margin-bottom: 10px; } +.err-hdr { display: flex; align-items: center; margin-bottom: 5px; } +.err-lbl { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--fail); } +.copy-btn { font-size: 0.68rem; background: none; border: 1px solid #fecaca; color: var(--fail); border-radius: 4px; padding: 1px 6px; cursor: pointer; margin-left: 8px; transition: background .12s; } +.copy-btn:hover { background: #fee2e2; } +.err-block pre { font-size: 0.78rem; color: #991b1b; white-space: pre-wrap; word-break: break-all; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; line-height: 1.5; } + +/* ── Attachments ── */ +.atts { display: flex; flex-wrap: wrap; gap: 10px; } +.att { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--surface); max-width: 380px; } +.att.att-html { max-width: 100%; width: 100%; } +.att-lbl { font-size: 0.72rem; font-weight: 600; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 5px; } +.att-img { display: block; max-width: 100%; max-height: 220px; cursor: zoom-in; object-fit: contain; } +.att-txt { font-size: 0.75rem; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; background: #1e293b; color: #e2e8f0; padding: 10px; max-height: 160px; overflow: auto; white-space: pre-wrap; word-break: break-all; line-height: 1.5; } +.att-iframe { display: block; width: 100%; height: 400px; border: none; background: #fff; } +.att-open { font-size: 0.68rem; background: none; border: 1px solid var(--border); color: var(--muted); border-radius: 4px; padding: 1px 6px; cursor: pointer; margin-left: auto; transition: background .12s; } +.att-open:hover { background: #f1f5f9; } + +/* ── Empty ── */ +.empty { text-align: center; padding: 60px 24px; color: #94a3b8; } +.empty p { font-size: 0.9rem; } + +/* ── Lightbox ── */ +.lb { position: fixed; inset: 0; background: rgba(0,0,0,.88); display: flex; align-items: center; justify-content: center; z-index: 9999; cursor: zoom-out; } +.lb-close { position: absolute; top: 14px; right: 18px; background: none; border: none; color: #fff; font-size: 2rem; cursor: pointer; line-height: 1; opacity: .8; } +.lb-close:hover { opacity: 1; } +#lbi { max-width: 95vw; max-height: 92vh; border-radius: 4px; cursor: default; box-shadow: 0 20px 60px rgba(0,0,0,.5); } + +/* ── Footer ── */ +footer { text-align: center; padding: 20px; font-size: 0.75rem; color: #94a3b8; border-top: 1px solid var(--border); margin-top: 8px; } + +/* ── Print ── */ +@media print { + .toolbar, footer { display: none !important; } + .td { display: block !important; } + .gbody { display: block !important; } + .group { box-shadow: none; break-inside: avoid; } +} diff --git a/src/reporters/report.iife.d.ts b/src/reporters/report.iife.d.ts new file mode 100644 index 0000000..7cac97f --- /dev/null +++ b/src/reporters/report.iife.d.ts @@ -0,0 +1,2 @@ +declare const content: string; +export default content; diff --git a/src/reporters/report.iife.js b/src/reporters/report.iife.js new file mode 100644 index 0000000..d3e2133 --- /dev/null +++ b/src/reporters/report.iife.js @@ -0,0 +1,286 @@ +(function () { + var activeFilter = 'all'; + var query = ''; + + function sc(status) { return status === 'passed' ? 'pass' : status === 'failed' ? 'fail' : 'skip'; } + + function fmt(ms) { + if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm'; + if (ms >= 1000) return (ms / 1000).toFixed(2) + 's'; + return ms + 'ms'; + } + + function esc(s) { + return String(s || '') + .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); + } + + var STEP_ICONS = { + pass: '', + fail: '', + info: '', + warn: '' + }; + + var CHEV = ''; + var EXI = ''; + var ATT_ICON = ''; + + function renderStepList(steps) { + if (!steps || !steps.length) return ''; + return '
    ' + steps.map(function(s) { + var ico = STEP_ICONS[s.state] || STEP_ICONS.info; + var dur = s.duration != null ? '' + fmt(s.duration) + '' : ''; + if (s.children && s.children.length) { + var open = s.state === 'fail' || s.state === 'warn'; + return '
  • ' + + '
    ' + + '' + + '' + esc(s.message) + '' + + dur + + '
    ' + + '
    ' + renderStepList(s.children) + '
    ' + + '
  • '; + } + return '
  • ' + ico + '' + esc(s.message) + '' + dur + '
  • '; + }).join('') + '
'; + } + + function renderTest(t) { + var cls = sc(t.status); + var steps = ''; + if (t.steps && t.steps.length) { + steps = renderStepList(t.steps); + } + + var errBlock = ''; + if (t.error) { + errBlock = '
Error
' + esc(t.error) + '
'; + } + + var atts = ''; + if (t.attachments && t.attachments.length) { + atts = '
' + t.attachments.map(function(a, ai) { + var inner, attCls = 'att', openBtn = ''; + if (a.isImage) { + inner = '' + esc(a.label) + ''; + } else if (a.isHtml) { + attCls = 'att att-html'; + inner = ''; + openBtn = ''; + } else { + inner = '
' + esc(a.body) + (a.body && a.body.length >= 4000 ? '\n…' : '') + '
'; + } + return '
' + ATT_ICON + esc(a.label) + openBtn + '
' + inner + '
'; + }).join('') + '
'; + } + + var hasDetail = steps || errBlock || atts; + + return '
' + + '
' + + '' + + '' + esc(t.title) + '' + + '' + fmt(t.duration) + '' + + '
' + + (hasDetail ? EXI : '') + + '
' + + (hasDetail ? '
' + steps + errBlock + atts + '
' : '') + + '
'; + } + + function groupBy(tests) { + var map = []; + var idx = {}; + tests.forEach(function(t) { + var s = t.suite || '(root)'; + if (!(s in idx)) { idx[s] = map.length; map.push({ name: s, tests: [] }); } + map[idx[s]].tests.push(t); + }); + return map; + } + + function renderGroups(filtered) { + var container = document.getElementById('groups'); + var empty = document.getElementById('empty'); + if (!filtered.length) { + container.innerHTML = ''; + empty.style.display = ''; + return; + } + empty.style.display = 'none'; + + var groups = groupBy(filtered); + container.innerHTML = groups.map(function(g) { + var passed = g.tests.filter(function(t){return t.status==='passed';}).length; + var failed = g.tests.filter(function(t){return t.status==='failed';}).length; + var skipped = g.tests.filter(function(t){return t.status==='skipped';}).length; + var dur = g.tests.reduce(function(a,b){return a+b.duration;},0); + + var badges = ''; + if (failed) badges += '' + failed + ' failed'; + if (passed) badges += '' + passed + ' passed'; + if (skipped) badges += '' + skipped + ' skipped'; + badges += '' + g.tests.length + ' total'; + + var startOpen = failed > 0; + return '
' + + '
' + (startOpen ? CHEV : CHEV.replace('chev open','chev')) + '' + esc(g.name) + '' + + '
' + badges + '
' + + '' + fmt(dur) + '
' + + '' + + '
'; + }).join(''); + + wireEvents(); + } + + function wireEvents() { + // expand/collapse test detail + document.querySelectorAll('.ts').forEach(function(el) { + el.addEventListener('click', function() { + var row = el.closest('.tr'); + var detail = row && row.querySelector('.td'); + if (!detail) return; + var icon = el.querySelector('.exi'); + var open = detail.classList.toggle('open'); + if (icon) icon.classList.toggle('open', open); + }); + }); + + // copy error + document.querySelectorAll('.copy-btn[data-tid]').forEach(function(btn) { + btn.addEventListener('click', function(ev) { + ev.stopPropagation(); + var t = DATA.find(function(d){ return d.id === parseInt(btn.dataset.tid); }); + if (!t || !t.error) return; + navigator.clipboard.writeText(t.error).then(function() { + btn.textContent = 'Copied!'; + setTimeout(function(){ btn.textContent = 'Copy'; }, 1500); + }); + }); + }); + + // image lightbox + document.querySelectorAll('[data-lb]').forEach(function(img) { + img.addEventListener('click', function(ev) { + ev.stopPropagation(); + document.getElementById('lbi').src = img.src; + document.getElementById('lb').style.display = 'flex'; + }); + }); + + // open HTML attachment in new tab + document.querySelectorAll('.att-open[data-tid]').forEach(function(btn) { + btn.addEventListener('click', function(ev) { + ev.stopPropagation(); + var t = DATA.find(function(d) { return d.id === parseInt(btn.dataset.tid); }); + var a = t && t.attachments[parseInt(btn.dataset.ai)]; + if (!a || !a.body) return; + var blob = new Blob([a.body], { type: 'text/html' }); + var url = URL.createObjectURL(blob); + window.open(url, '_blank'); + setTimeout(function() { URL.revokeObjectURL(url); }, 10000); + }); + }); + + // auto-expand failed tests + document.querySelectorAll('.tr[data-status="failed"]').forEach(function(row) { + var detail = row.querySelector('.td'); + var icon = row.querySelector('.exi'); + if (detail) detail.classList.add('open'); + if (icon) icon.classList.add('open'); + }); + } + + // step-group toggle (event delegation — survives re-renders) + document.addEventListener('click', function(ev) { + var hdr = ev.target.closest && ev.target.closest('.step-group-hdr'); + if (!hdr) return; + var group = hdr.closest('.step-group'); + if (group) group.classList.toggle('open'); + }); + + // group collapse (event delegation — survives re-renders) + document.addEventListener('click', function(ev) { + var gh = ev.target.closest && ev.target.closest('.gh'); + if (!gh) return; + var body = gh.nextElementSibling; + var chev = gh.querySelector('.chev'); + var isOpen = body.style.display !== 'none'; + body.style.display = isOpen ? 'none' : ''; + if (chev) chev.classList.toggle('open', !isOpen); + }); + + // expand/collapse all suites + document.getElementById('expand-all').addEventListener('click', function() { + document.querySelectorAll('.gbody').forEach(function(b) { b.style.display = ''; }); + document.querySelectorAll('.chev').forEach(function(c) { c.classList.add('open'); }); + }); + document.getElementById('collapse-all').addEventListener('click', function() { + document.querySelectorAll('.gbody').forEach(function(b) { b.style.display = 'none'; }); + document.querySelectorAll('.chev').forEach(function(c) { c.classList.remove('open'); }); + }); + + // filter buttons + document.querySelectorAll('.fb').forEach(function(btn) { + btn.addEventListener('click', function() { + document.querySelectorAll('.fb').forEach(function(b){ b.classList.remove('active'); }); + btn.classList.add('active'); + activeFilter = btn.dataset.f; + refresh(); + }); + }); + + // search + var searchEl = document.getElementById('s'); + var clearBtn = document.getElementById('sx'); + searchEl.addEventListener('input', function() { + query = searchEl.value; + if (clearBtn) clearBtn.classList.toggle('visible', query.length > 0); + refresh(); + }); + if (clearBtn) { + clearBtn.addEventListener('click', function() { + searchEl.value = ''; query = ''; + clearBtn.classList.remove('visible'); + refresh(); + }); + } + + // jump to failures + var jumpBtn = document.getElementById('jf'); + if (jumpBtn) { + jumpBtn.addEventListener('click', function() { + document.querySelectorAll('.fb').forEach(function(b){ + b.classList.toggle('active', b.dataset.f === 'failed'); + }); + activeFilter = 'failed'; + refresh(); + var firstFail = document.querySelector('.group.has-fail'); + if (firstFail) firstFail.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + + // lightbox close + var lb = document.getElementById('lb'); + document.getElementById('lbc').addEventListener('click', function() { lb.style.display = 'none'; }); + lb.addEventListener('click', function(ev) { + if (ev.target === lb) lb.style.display = 'none'; + }); + document.addEventListener('keydown', function(ev) { + if (ev.key === 'Escape') lb.style.display = 'none'; + }); + + function getFiltered() { + return DATA.filter(function(t) { + var fOk = activeFilter === 'all' || t.status === activeFilter; + var qOk = !query || t.fullTitle.toLowerCase().indexOf(query.toLowerCase()) >= 0; + return fOk && qOk; + }); + } + + function refresh() { renderGroups(getFiltered()); } + + refresh(); +}()); diff --git a/test/tx.config.ts b/test/tx.config.ts index c2379b2..9235c07 100644 --- a/test/tx.config.ts +++ b/test/tx.config.ts @@ -42,7 +42,7 @@ module.exports = { browser: 'chrome', reporters: [ ['../src/reporters/ConsoleReporter.ts', {}], - ['../src/reporters/JUnitReporter.ts', { outputPath: 'report/report.xml' }], + ['../src/reporters/JunitReporter.ts', { outputPath: 'report/report.xml' }], ['../src/reporters/HtmlReporter.ts', { outputPath: 'report/report.html' }], ], tasks: {