@scenetest/vite-plugin 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/__tests__/strip.test.d.ts +2 -0
- package/dist/__tests__/strip.test.d.ts.map +1 -0
- package/dist/__tests__/strip.test.js +434 -0
- package/dist/__tests__/strip.test.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +71 -0
- package/dist/config.js.map +1 -0
- package/dist/dev-panel.generated.d.ts +9 -0
- package/dist/dev-panel.generated.d.ts.map +1 -0
- package/dist/dev-panel.generated.js +9 -0
- package/dist/dev-panel.generated.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +137 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +14 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +121 -0
- package/dist/middleware.js.map +1 -0
- package/dist/strip.d.ts +26 -0
- package/dist/strip.d.ts.map +1 -0
- package/dist/strip.js +195 -0
- package/dist/strip.js.map +1 -0
- package/dist/transform.d.ts +52 -0
- package/dist/transform.d.ts.map +1 -0
- package/dist/transform.js +477 -0
- package/dist/transform.js.map +1 -0
- package/dist/virtual-module.d.ts +36 -0
- package/dist/virtual-module.d.ts.map +1 -0
- package/dist/virtual-module.js +72 -0
- package/dist/virtual-module.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated dev panel script
|
|
3
|
+
* DO NOT EDIT - this file is generated by scripts/bundle-dev-panel.mjs
|
|
4
|
+
*
|
|
5
|
+
* To modify the dev panel, edit files in src/dev-panel/ and run:
|
|
6
|
+
* pnpm run build:dev-panel
|
|
7
|
+
*/
|
|
8
|
+
export declare const devPanelScript = "\"use strict\";\n(() => {\n // src/dev-panel/state.ts\n var assertions = [];\n var groups = [];\n var assertionHistory = /* @__PURE__ */ new Map();\n var locationGroups = /* @__PURE__ */ new Map();\n var passCount = 0;\n var failCount = 0;\n function incrementPassCount() {\n passCount++;\n }\n function incrementFailCount() {\n failCount++;\n }\n var panel = null;\n var listEl = null;\n var fullscreenWindow = null;\n var filter = \"all\";\n var collapsedMode = true;\n var viewMode = \"grouped\";\n var sequenceLocationKey = null;\n function setPanel(el) {\n panel = el;\n }\n function setListEl(el) {\n listEl = el;\n }\n function setFullscreenWindow(win) {\n fullscreenWindow = win;\n }\n function setFilter(newFilter) {\n filter = newFilter;\n }\n function setViewMode(mode) {\n viewMode = mode;\n if (mode !== \"sequence\") {\n sequenceLocationKey = null;\n }\n }\n function setSequenceLocation(key) {\n sequenceLocationKey = key;\n if (key !== null) {\n viewMode = \"sequence\";\n }\n }\n function getLocationKey(result) {\n if (!result.location) return null;\n return `${result.location.file}:${result.location.line}`;\n }\n function trackLocationGroup(result, index) {\n const key = getLocationKey(result);\n if (!key || !result.location) return;\n let group = locationGroups.get(key);\n if (!group) {\n group = {\n key,\n location: result.location,\n description: result.description,\n entries: [],\n lastResult: result.result,\n lastTimestamp: result.timestamp\n };\n locationGroups.set(key, group);\n }\n group.description = result.description;\n group.lastResult = result.result;\n group.lastTimestamp = result.timestamp;\n group.entries.push({\n result: result.result,\n timestamp: result.timestamp,\n index,\n description: result.description,\n context: result.context\n });\n }\n var GROUP_THRESHOLD_MS = 50;\n var pendingGroup = null;\n var groupTimeout = null;\n function setPendingGroup(group) {\n pendingGroup = group;\n }\n function setGroupTimeout(timeout) {\n groupTimeout = timeout;\n }\n function clearAll() {\n assertions.length = 0;\n groups.length = 0;\n assertionHistory.clear();\n locationGroups.clear();\n passCount = 0;\n failCount = 0;\n pendingGroup = null;\n sequenceLocationKey = null;\n if (groupTimeout) {\n clearTimeout(groupTimeout);\n groupTimeout = null;\n }\n }\n\n // src/dev-panel/history.ts\n function trackAssertion(result, index) {\n const key = result.description;\n if (!assertionHistory.has(key)) {\n assertionHistory.set(key, []);\n }\n const history = assertionHistory.get(key);\n history.push({ result: result.result, timestamp: result.timestamp, index });\n return history;\n }\n function getHistoryStats(description, currentIndex) {\n const history = assertionHistory.get(description);\n if (!history || history.length <= 1) return null;\n let priorPassed = 0;\n let priorFailed = 0;\n let afterPassed = 0;\n let afterFailed = 0;\n for (const entry of history) {\n if (entry.index < currentIndex) {\n if (entry.result) priorPassed++;\n else priorFailed++;\n } else if (entry.index > currentIndex) {\n if (entry.result) afterPassed++;\n else afterFailed++;\n }\n }\n const total = history.length - 1;\n if (total === 0) return null;\n return { priorPassed, priorFailed, afterPassed, afterFailed, total };\n }\n function formatHistorySummary(stats) {\n if (!stats) return \"\";\n const parts = [];\n const priorTotal = stats.priorPassed + stats.priorFailed;\n const afterTotal = stats.afterPassed + stats.afterFailed;\n if (priorTotal > 0) {\n if (stats.priorFailed === 0) {\n parts.push(`${priorTotal} prior \\u2713`);\n } else if (stats.priorPassed === 0) {\n parts.push(`${priorTotal} prior \\u2717`);\n } else {\n parts.push(`${priorTotal} prior (${stats.priorPassed}\\u2713 ${stats.priorFailed}\\u2717)`);\n }\n }\n if (afterTotal > 0) {\n if (stats.afterFailed === 0) {\n parts.push(`${afterTotal} after \\u2713`);\n } else if (stats.afterPassed === 0) {\n parts.push(`${afterTotal} after \\u2717`);\n } else {\n parts.push(`${afterTotal} after (${stats.afterPassed}\\u2713 ${stats.afterFailed}\\u2717)`);\n }\n }\n return parts.length > 0 ? `(${parts.join(\", \")})` : \"\";\n }\n\n // src/dev-panel/utils.ts\n function escapeHtml(str) {\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n }\n function formatContext(ctx) {\n if (!ctx) return \"\";\n try {\n return JSON.stringify(ctx, null, 2);\n } catch {\n return String(ctx);\n }\n }\n function formatLocation(loc) {\n if (!loc) return \"\";\n const file = loc.file.replace(/^.*\\/src\\//, \"src/\");\n return `${file}:${loc.line}${loc.column ? \":\" + loc.column : \"\"}`;\n }\n function formatTime(ts) {\n const d = new Date(ts);\n return d.toLocaleTimeString(\"en-US\", { hour12: false }) + \".\" + String(d.getMilliseconds()).padStart(3, \"0\");\n }\n function openInEditor(loc) {\n if (!loc) return;\n fetch(`/__open-in-editor?file=${encodeURIComponent(loc.file)}&line=${loc.line}${loc.column ? \"&column=\" + loc.column : \"\"}`).catch(() => {\n window.open(`vscode://file${loc.file}:${loc.line}${loc.column ? \":\" + loc.column : \"\"}`);\n });\n }\n function filterItems(items) {\n if (filter === \"all\") return items;\n if (filter === \"fails\") return items.filter((a) => !a.result);\n if (filter === \"passes\") return items.filter((a) => a.result);\n return items;\n }\n function getGroupStats(items) {\n let passCount2 = 0;\n let failCount2 = 0;\n for (const item of items) {\n if (item.result) passCount2++;\n else failCount2++;\n }\n return { passCount: passCount2, failCount: failCount2 };\n }\n function formatLocationShort(loc) {\n if (!loc) return \"\";\n const file = loc.file.split(\"/\").pop() || loc.file;\n return `${file}:${loc.line}`;\n }\n\n // src/dev-panel/render.ts\n function renderPanelItem(a, groupId) {\n const titleAttr = a.context ? escapeHtml(formatContext(a.context)) : a.location ? escapeHtml(formatLocation(a.location)) : \"\";\n return `\n <div class=\"scenetest-item ${a.result ? \"pass\" : \"fail\"}\"\n onclick=\"if(window.__scenetest_openFullscreenToGroup)window.__scenetest_openFullscreenToGroup(${groupId})\"\n title=\"${titleAttr}\">\n <span class=\"scenetest-icon\">${a.result ? \"\\u2713\" : \"\\u2717\"}</span>\n <div class=\"scenetest-content\">\n <div class=\"scenetest-desc${a.type === \"fail\" && a.result ? \" negated\" : \"\"}\">${escapeHtml(a.description)}</div>\n ${a.location ? `<div class=\"scenetest-location\">${escapeHtml(formatLocation(a.location))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderPanelGroup(g) {\n const stats = getGroupStats(g.items);\n return `\n <div class=\"scenetest-group${g.collapsed ? \" collapsed\" : \"\"}\" data-group-id=\"${g.id}\">\n <div class=\"scenetest-group-header\" onclick=\"this.parentElement.classList.toggle('collapsed')\">\n <div class=\"scenetest-group-summary\">\n <span class=\"scenetest-group-time\">${formatTime(g.timestamp)}</span>\n <div class=\"scenetest-group-stats\">\n <span class=\"scenetest-group-stat pass\">\\u2713${stats.passCount}</span>\n <span class=\"scenetest-group-stat ${stats.failCount > 0 ? \"fail\" : \"zero\"}\">\\u2717${stats.failCount}</span>\n </div>\n </div>\n <span class=\"scenetest-group-toggle\">\\u25BC</span>\n </div>\n <div class=\"scenetest-group-items\">\n ${g.items.map((a) => renderPanelItem(a, g.id)).join(\"\")}\n </div>\n </div>\n `;\n }\n function renderFullscreenItem(a) {\n const histStats = getHistoryStats(a.description, a._index ?? 0);\n const histSummary = formatHistorySummary(histStats);\n const locJson = a.location ? JSON.stringify(a.location).replace(/\"/g, \""\") : \"null\";\n return `\n <div class=\"item ${a.result ? \"pass\" : \"fail\"}\">\n <span class=\"icon\">${a.result ? \"\\u2713\" : \"\\u2717\"}</span>\n <div class=\"content\">\n <div class=\"desc${a.type === \"fail\" && a.result ? \" negated\" : \"\"}\">${escapeHtml(a.description)}</div>\n ${a.location ? `<div class=\"location\" onclick=\"window.opener && window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${locJson})\">${escapeHtml(formatLocation(a.location))}</div>` : \"\"}\n ${histSummary ? `<div class=\"history\">${histSummary}</div>` : \"\"}\n ${a.context ? `<div class=\"context\">${escapeHtml(formatContext(a.context))}</div>` : \"\"}\n ${a.stack && !a.context ? `<div class=\"stack\">${escapeHtml(a.stack.split(\"\\n\").slice(0, 3).join(\"\\n\"))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderFullscreenGroup(g) {\n const stats = getGroupStats(g.items);\n return `\n <div class=\"group\" data-group-id=\"${g.id}\">\n <div class=\"group-header\" onclick=\"this.parentElement.classList.toggle('collapsed')\">\n <div class=\"group-info\">\n <span class=\"group-time\">${formatTime(g.timestamp)}</span>\n <div class=\"group-stats\">\n ${stats.passCount > 0 ? `<span class=\"group-stat pass\">\\u2713 ${stats.passCount}</span>` : \"\"}\n ${stats.failCount > 0 ? `<span class=\"group-stat fail\">\\u2717 ${stats.failCount}</span>` : \"\"}\n </div>\n <span style=\"color: #6a6a8a\">${g.items.length} assertion${g.items.length === 1 ? \"\" : \"s\"}</span>\n </div>\n <span class=\"group-toggle\">\\u25BC</span>\n </div>\n <div class=\"group-items\">\n ${g.items.map((a) => renderFullscreenItem(a)).join(\"\")}\n </div>\n </div>\n `;\n }\n function renderLocationRow(group) {\n const passCount2 = group.entries.filter((e) => e.result).length;\n const failCount2 = group.entries.filter((e) => !e.result).length;\n const total = group.entries.length;\n const keyJson = JSON.stringify(group.key).replace(/\"/g, \""\");\n const recentEntries = group.entries.slice(-10);\n const dots = recentEntries.map((e) => {\n if (e.result) {\n return `<span class=\"status-dot pass\" title=\"${formatTime(e.timestamp)}\"></span>`;\n } else {\n return `<span class=\"status-dot fail\" title=\"${formatTime(e.timestamp)}\"><span class=\"dot-x\">\\u2717</span></span>`;\n }\n }).join(\"\");\n const hasAnyFails = failCount2 > 0;\n const lastFailed = !group.lastResult;\n const statusClass = lastFailed ? \"last-fail\" : hasAnyFails ? \"has-fails\" : \"all-pass\";\n const stateIcon = lastFailed ? '<span class=\"state-icon fail\">\\u2717</span>' : hasAnyFails ? '<span class=\"state-icon warn\">\\u26A0</span>' : '<span class=\"state-icon pass\">\\u2713</span>';\n return `\n <div class=\"location-row ${statusClass}\" data-location-key=\"${keyJson}\">\n ${stateIcon}\n <div class=\"location-main\" onclick=\"window.opener ? window.opener.__scenetest_showSequence && window.opener.__scenetest_showSequence(${keyJson}) : window.__scenetest_showSequence && window.__scenetest_showSequence(${keyJson})\">\n <div class=\"location-info\">\n <span class=\"location-file\">${escapeHtml(formatLocationShort(group.location))}</span>\n <span class=\"location-desc\">${escapeHtml(group.description)}</span>\n </div>\n <div class=\"location-stats\">\n <div class=\"status-dots\">${dots}</div>\n <span class=\"location-count\">${total} run${total === 1 ? \"\" : \"s\"}</span>\n <div class=\"location-summary\">\n ${passCount2 > 0 ? `<span class=\"stat pass\">\\u2713${passCount2}</span>` : \"\"}\n ${failCount2 > 0 ? `<span class=\"stat fail\">\\u2717${failCount2}</span>` : \"\"}\n </div>\n </div>\n </div>\n <div class=\"location-actions\">\n <button class=\"loc-btn\" onclick=\"event.stopPropagation(); window.opener ? window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${JSON.stringify(group.location).replace(/\"/g, \""\")}) : window.__scenetest_openInEditor && window.__scenetest_openInEditor(${JSON.stringify(group.location).replace(/\"/g, \""\")})\" title=\"Open in editor\">\\u270E</button>\n </div>\n </div>\n `;\n }\n function renderSequenceEntry(entry, location, isFirst = false, isLast = false) {\n return `\n <div class=\"sequence-entry ${entry.result ? \"pass\" : \"fail\"}\">\n <div class=\"timeline-track\">\n <div class=\"timeline-line ${isFirst ? \"first\" : \"\"} ${isLast ? \"last\" : \"\"}\"></div>\n <div class=\"timeline-dot ${entry.result ? \"pass\" : \"fail\"}\">\n ${entry.result ? \"\" : '<span class=\"dot-x\">\\u2717</span>'}\n </div>\n </div>\n <div class=\"content\">\n <div class=\"sequence-time\">${formatTime(entry.timestamp)}</div>\n <div class=\"desc\">${escapeHtml(entry.description)}</div>\n ${entry.context ? `<div class=\"context\">${escapeHtml(formatContext(entry.context))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderSequenceHeader(group) {\n const locJson = JSON.stringify(group.location).replace(/\"/g, \""\");\n const passCount2 = group.entries.filter((e) => e.result).length;\n const failCount2 = group.entries.filter((e) => !e.result).length;\n return `\n <div class=\"sequence-header\">\n <div class=\"sequence-location\">\n <span class=\"sequence-file\" onclick=\"window.opener ? window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${locJson}) : window.__scenetest_openInEditor && window.__scenetest_openInEditor(${locJson})\">${escapeHtml(formatLocation(group.location))}</span>\n </div>\n <div class=\"sequence-summary\">\n <span class=\"sequence-total\">${group.entries.length} run${group.entries.length === 1 ? \"\" : \"s\"}</span>\n <div class=\"sequence-stats\">\n ${passCount2 > 0 ? `<span class=\"stat pass\">\\u2713 ${passCount2}</span>` : \"\"}\n ${failCount2 > 0 ? `<span class=\"stat fail\">\\u2717 ${failCount2}</span>` : \"\"}\n </div>\n </div>\n </div>\n `;\n }\n\n // src/dev-panel/styles.ts\n var panelStyles = `\n#scenetest-panel {\n position: fixed;\n bottom: 16px;\n right: 16px;\n width: 400px;\n max-height: 450px;\n background: #1a1a2e;\n border: 1px solid #4a4a6a;\n border-radius: 8px;\n font-family: ui-monospace, monospace;\n font-size: 12px;\n color: #e0e0e0;\n z-index: 999999;\n box-shadow: 0 4px 24px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n}\n#scenetest-panel.collapsed {\n max-height: none;\n width: auto;\n}\n#scenetest-panel.collapsed #scenetest-list,\n#scenetest-panel.collapsed #scenetest-actions {\n display: none;\n}\n#scenetest-header {\n padding: 10px 12px;\n background: #252542;\n border-radius: 8px 8px 0 0;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n user-select: none;\n border-bottom: 1px solid #4a4a6a;\n}\n#scenetest-header:hover {\n background: #2a2a4a;\n}\n#scenetest-title {\n font-weight: 600;\n color: #a0a0ff;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.scenetest-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n border-radius: 50%;\n background: #a0a0ff;\n font-size: 12px;\n filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));\n}\n.scenetest-icon span {\n filter: drop-shadow(0px 0px 4px #ffffff);\n}\n#scenetest-counts {\n display: flex;\n gap: 8px;\n align-items: center;\n}\n.scenetest-count {\n padding: 2px 8px;\n border-radius: 4px;\n font-weight: 500;\n cursor: pointer;\n transition: opacity 0.15s;\n}\n.scenetest-count:hover {\n opacity: 0.8;\n}\n.scenetest-count.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.scenetest-count.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.scenetest-count.active {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n#scenetest-actions {\n display: flex;\n gap: 8px;\n padding: 6px 12px;\n background: #202038;\n border-bottom: 1px solid #3a3a5a;\n flex-wrap: wrap;\n align-items: center;\n}\n.scenetest-btn-group {\n display: flex;\n border: 1px solid #4a4a6a;\n border-radius: 4px;\n overflow: hidden;\n}\n.scenetest-btn-group .scenetest-btn {\n border: none;\n border-radius: 0;\n border-right: 1px solid #4a4a6a;\n}\n.scenetest-btn-group .scenetest-btn:last-child {\n border-right: none;\n}\n.scenetest-btn {\n background: none;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 4px 10px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 11px;\n font-family: inherit;\n transition: all 0.15s;\n}\n.scenetest-btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n.scenetest-btn.active {\n background: #4a4a6a;\n color: #fff;\n}\n.scenetest-separator {\n width: 1px;\n height: 16px;\n background: #3a3a5a;\n}\n#scenetest-list {\n overflow-y: auto;\n max-height: 340px;\n padding: 8px 0;\n}\n.scenetest-group {\n margin: 4px 8px;\n border: 1px solid #3a3a5a;\n border-radius: 6px;\n overflow: hidden;\n}\n.scenetest-group-header {\n padding: 6px 10px;\n background: #252542;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n font-size: 11px;\n}\n.scenetest-group-header:hover {\n background: #2a2a4a;\n}\n.scenetest-group-summary {\n display: flex;\n gap: 8px;\n align-items: center;\n}\n.scenetest-group-time {\n color: #6a6a8a;\n}\n.scenetest-group-stats {\n display: flex;\n gap: 6px;\n}\n.scenetest-group-stat {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 3px;\n}\n.scenetest-group-stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.scenetest-group-stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.scenetest-group-stat.zero {\n background: #2a2a3a;\n color: #6a6a8a;\n}\n.scenetest-group-items {\n border-top: 1px solid #3a3a5a;\n}\n.scenetest-group.collapsed .scenetest-group-items {\n display: none;\n}\n.scenetest-group-toggle {\n color: #6a6a8a;\n font-size: 10px;\n}\n.scenetest-item {\n padding: 6px 10px;\n border-bottom: 1px solid #2a2a4a;\n display: flex;\n gap: 8px;\n align-items: flex-start;\n cursor: pointer;\n position: relative;\n}\n.scenetest-item:hover {\n background: #252545;\n}\n.scenetest-item:last-child {\n border-bottom: none;\n}\n.scenetest-item.pass .scenetest-icon {\n color: #4ade80;\n}\n.scenetest-item.fail .scenetest-icon {\n color: #f87171;\n}\n.scenetest-icon {\n flex-shrink: 0;\n width: 14px;\n text-align: center;\n}\n.scenetest-content {\n flex: 1;\n min-width: 0;\n}\n.scenetest-desc {\n word-break: break-word;\n}\n.scenetest-item.fail .scenetest-desc {\n color: #f87171;\n}\n.scenetest-desc.negated {\n text-decoration: line-through;\n opacity: 0.7;\n}\n.scenetest-location {\n font-size: 9px;\n color: #6a6a8a;\n margin-top: 2px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.scenetest-location:hover {\n color: #a0a0ff;\n text-decoration: underline;\n}\n.scenetest-context {\n font-size: 10px;\n color: #8a8aaa;\n margin-top: 3px;\n padding: 4px 6px;\n background: #12122a;\n border-radius: 3px;\n font-family: ui-monospace, monospace;\n max-height: 60px;\n overflow: auto;\n white-space: pre-wrap;\n word-break: break-all;\n}\n.scenetest-time {\n color: #6a6a8a;\n flex-shrink: 0;\n font-size: 10px;\n}\n#scenetest-empty {\n padding: 20px;\n text-align: center;\n color: #6a6a8a;\n}\n.scenetest-ungrouped {\n padding: 4px 8px;\n}\n.scenetest-history {\n font-size: 9px;\n color: #8a8aaa;\n margin-top: 2px;\n font-style: italic;\n}\n`;\n var fullscreenStyles = `\n* { box-sizing: border-box; }\nbody {\n margin: 0;\n padding: 0;\n background: #0f0f1a;\n color: #e0e0e0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;\n font-size: 13px;\n}\n#header {\n position: sticky;\n top: 0;\n background: #1a1a2e;\n border-bottom: 1px solid #4a4a6a;\n padding: 16px 24px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n z-index: 100;\n flex-wrap: wrap;\n gap: 12px;\n}\n#title {\n font-size: 18px;\n font-weight: 600;\n color: #a0a0ff;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n#title .icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border-radius: 50%;\n background: #a0a0ff;\n font-size: 18px;\n filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));\n}\n#title .icon span {\n filter: drop-shadow(0px 0px 5px #ffffff);\n}\n#controls {\n display: flex;\n gap: 12px;\n align-items: center;\n flex-wrap: wrap;\n}\n#counts {\n display: flex;\n gap: 12px;\n align-items: center;\n}\n.count {\n padding: 4px 12px;\n border-radius: 6px;\n font-weight: 600;\n font-size: 14px;\n}\n.count.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.count.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.btn {\n background: #252542;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 8px 16px;\n border-radius: 6px;\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n transition: all 0.15s;\n}\n.btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n.btn.active {\n background: #4a4a6a;\n color: #fff;\n}\n#filters {\n display: flex;\n gap: 0;\n}\n.btn-group {\n display: flex;\n border: 1px solid #4a4a6a;\n border-radius: 6px;\n overflow: hidden;\n}\n.btn-group .btn {\n border: none;\n border-radius: 0;\n border-right: 1px solid #4a4a6a;\n}\n.btn-group .btn:last-child {\n border-right: none;\n}\n.separator {\n width: 1px;\n height: 24px;\n background: #4a4a6a;\n}\n#list {\n padding: 16px;\n}\n.ungrouped-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.group {\n margin-bottom: 16px;\n border: 1px solid #3a3a5a;\n border-radius: 8px;\n overflow: hidden;\n}\n.group-header {\n padding: 12px 16px;\n background: #1a1a2e;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n}\n.group-header:hover {\n background: #252542;\n}\n.group-info {\n display: flex;\n gap: 16px;\n align-items: center;\n}\n.group-time {\n color: #a0a0ff;\n font-weight: 500;\n}\n.group-stats {\n display: flex;\n gap: 8px;\n}\n.group-stat {\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 12px;\n}\n.group-stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.group-stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.group-toggle {\n color: #6a6a8a;\n font-size: 12px;\n}\n.group-items {\n border-top: 1px solid #3a3a5a;\n}\n.group.collapsed .group-items {\n display: none;\n}\n.item {\n padding: 10px 16px;\n background: #12121f;\n display: flex;\n gap: 12px;\n align-items: flex-start;\n border-bottom: 1px solid #2a2a4a;\n}\n.item:last-child {\n border-bottom: none;\n}\n.item.fail {\n background: #1a1212;\n}\n.icon {\n font-size: 14px;\n width: 18px;\n text-align: center;\n flex-shrink: 0;\n}\n.item.pass .icon { color: #4ade80; }\n.item.fail .icon { color: #f87171; }\n.content {\n flex: 1;\n}\n.desc {\n margin-bottom: 4px;\n word-break: break-word;\n}\n.item.fail .desc {\n color: #f87171;\n}\n.desc.negated {\n text-decoration: line-through;\n opacity: 0.7;\n}\n.location {\n font-size: 11px;\n color: #6a6a8a;\n margin-top: 4px;\n cursor: pointer;\n}\n.location:hover {\n color: #a0a0ff;\n text-decoration: underline;\n}\n.context {\n margin-top: 8px;\n padding: 8px;\n background: #0a0a12;\n border-radius: 4px;\n font-size: 11px;\n color: #a0a0c0;\n white-space: pre-wrap;\n word-break: break-all;\n max-height: 100px;\n overflow: auto;\n}\n.meta {\n font-size: 11px;\n color: #6a6a8a;\n}\n.stack {\n margin-top: 8px;\n padding: 8px;\n background: #0a0a12;\n border-radius: 4px;\n font-size: 11px;\n color: #8a8a9a;\n white-space: pre-wrap;\n word-break: break-all;\n}\n.history {\n font-size: 11px;\n color: #8a8aaa;\n margin-top: 4px;\n font-style: italic;\n}\n#empty {\n text-align: center;\n padding: 60px 20px;\n color: #6a6a8a;\n}\n#empty-icon {\n font-size: 48px;\n margin-bottom: 16px;\n}\n.group.highlighted {\n box-shadow: 0 0 0 3px #a0a0ff, 0 0 20px rgba(160, 160, 255, 0.4);\n}\n\n/* View mode toggle */\n#view-modes {\n display: flex;\n gap: 0;\n}\n\n/* Location view styles */\n.location-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 16px;\n background: #12121f;\n border: 1px solid #3a3a5a;\n border-radius: 8px;\n margin-bottom: 8px;\n cursor: pointer;\n transition: all 0.15s;\n}\n.location-row:hover {\n background: #1a1a2e;\n border-color: #4a4a6a;\n}\n.location-row.all-pass {\n border-left: 3px solid #4ade80;\n}\n.location-row.has-fails {\n border-left: 3px solid #f59e0b;\n}\n.location-row.last-fail {\n border-left: 3px solid #f87171;\n background: #1a1212;\n}\n.location-main {\n flex: 1;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 16px;\n}\n.location-info {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n.location-file {\n font-size: 12px;\n color: #a0a0ff;\n font-weight: 500;\n}\n.location-desc {\n font-size: 13px;\n color: #e0e0e0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.location-stats {\n display: flex;\n align-items: center;\n gap: 12px;\n flex-shrink: 0;\n}\n/* Current state icon - prominent indicator on the left */\n.state-icon {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 16px;\n font-weight: bold;\n border-radius: 50%;\n flex-shrink: 0;\n margin-right: 12px;\n}\n.state-icon.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.state-icon.warn {\n background: #3a2a0a;\n color: #f59e0b;\n}\n.state-icon.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n\n.status-dots {\n display: flex;\n gap: 4px;\n align-items: center;\n}\n.status-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n transition: transform 0.15s;\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n.status-dot.pass {\n background: #4ade80;\n}\n.status-dot.fail {\n background: #f87171;\n}\n/* Accessibility: X marker on failure dots for colorblind users */\n.status-dot .dot-x {\n position: absolute;\n color: white;\n font-size: 9px;\n font-weight: bold;\n line-height: 1;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n text-shadow: 0 0 2px rgba(0,0,0,0.5);\n}\n.status-dot:last-child {\n animation: pulse-dot 0.5s ease-out;\n}\n@keyframes pulse-dot {\n 0% { transform: scale(1.5); }\n 100% { transform: scale(1); }\n}\n.location-count {\n font-size: 11px;\n color: #6a6a8a;\n}\n.location-summary {\n display: flex;\n gap: 6px;\n}\n.location-summary .stat {\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 11px;\n}\n.location-summary .stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.location-summary .stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.location-actions {\n margin-left: 12px;\n}\n.loc-btn {\n background: #252542;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 4px 8px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n}\n.loc-btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n\n/* Sequence view styles */\n.sequence-header {\n background: #1a1a2e;\n border: 1px solid #4a4a6a;\n border-radius: 8px;\n padding: 16px;\n margin-bottom: 8px;\n}\n.sequence-location {\n margin-bottom: 8px;\n}\n.sequence-file {\n color: #a0a0ff;\n font-size: 14px;\n cursor: pointer;\n}\n.sequence-file:hover {\n text-decoration: underline;\n}\n.sequence-summary {\n display: flex;\n align-items: center;\n gap: 16px;\n}\n.sequence-total {\n color: #6a6a8a;\n font-size: 12px;\n}\n.sequence-stats {\n display: flex;\n gap: 8px;\n}\n.sequence-stats .stat {\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 12px;\n}\n.sequence-stats .stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.sequence-stats .stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n\n/* Direction hint */\n.sequence-direction-hint {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 16px;\n margin-bottom: 8px;\n color: #6a6a8a;\n font-size: 11px;\n background: #0f0f1a;\n border-radius: 4px;\n}\n.direction-arrow {\n color: #a0a0ff;\n font-size: 14px;\n}\n\n/* Timeline track for sequence entries */\n.sequence-list {\n position: relative;\n padding-left: 8px;\n}\n.sequence-entry {\n padding: 12px 16px 12px 0;\n background: #12121f;\n display: flex;\n gap: 0;\n align-items: stretch;\n border: 1px solid #3a3a5a;\n border-radius: 6px;\n margin-bottom: 0;\n margin-left: 20px;\n position: relative;\n}\n.sequence-entry.fail {\n background: #1a1212;\n border-color: #4a2a2a;\n}\n\n/* Timeline track with connecting line */\n.timeline-track {\n width: 40px;\n display: flex;\n flex-direction: column;\n align-items: center;\n position: relative;\n flex-shrink: 0;\n}\n.timeline-line {\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n width: 2px;\n background: #3a3a5a;\n top: -8px;\n bottom: -8px;\n}\n.timeline-line.first {\n top: 50%;\n}\n.timeline-line.last {\n bottom: 50%;\n}\n.timeline-line.first.last {\n display: none;\n}\n.timeline-dot {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n position: relative;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-top: 4px;\n}\n.timeline-dot.pass {\n background: #4ade80;\n box-shadow: 0 0 0 3px #1a3a1a;\n}\n.timeline-dot.fail {\n background: #f87171;\n box-shadow: 0 0 0 3px #3a1a1a;\n}\n.timeline-dot .dot-x {\n color: white;\n font-size: 10px;\n font-weight: bold;\n line-height: 1;\n text-shadow: 0 0 2px rgba(0,0,0,0.5);\n}\n\n.sequence-entry .content {\n flex: 1;\n padding-left: 8px;\n}\n.sequence-time {\n font-size: 11px;\n color: #6a6a8a;\n margin-bottom: 4px;\n}\n\n/* End label */\n.sequence-end-label {\n margin-left: 20px;\n padding: 8px 16px 8px 40px;\n color: #6a6a8a;\n font-size: 11px;\n position: relative;\n}\n.sequence-end-label::before {\n content: '';\n position: absolute;\n left: 28px;\n top: 0;\n width: 2px;\n height: 8px;\n background: #3a3a5a;\n}\n\n.back-btn {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 16px;\n color: #a0a0ff;\n cursor: pointer;\n font-size: 13px;\n}\n.back-btn:hover {\n text-decoration: underline;\n}\n`;\n\n // src/dev-panel/fullscreen.ts\n function getFullscreenHTML() {\n return `\n <!DOCTYPE html>\n <html>\n <head>\n <title>scenetest - Inline Assertions</title>\n <style>${fullscreenStyles}</style>\n </head>\n <body>\n <div id=\"header\">\n <span id=\"title\"><span class=\"icon\"><span>\\u{1F3AC}</span></span>scenetest</span>\n <div id=\"controls\">\n <div id=\"counts\">\n <span class=\"count pass\" id=\"pass-count\">\\u2713 0</span>\n <span class=\"count fail\" id=\"fail-count\">\\u2717 0</span>\n </div>\n <div id=\"view-modes\" class=\"btn-group\">\n <button class=\"btn active\" id=\"view-grouped\" title=\"Group by time\">Timeline</button>\n <button class=\"btn\" id=\"view-byLocation\" title=\"Group by code location\">By Location</button>\n </div>\n <div id=\"filters\" class=\"btn-group\">\n <button class=\"btn active\" id=\"filter-all\">All</button>\n <button class=\"btn\" id=\"filter-fails\">Errors</button>\n <button class=\"btn\" id=\"filter-passes\">Passes</button>\n </div>\n <span class=\"separator\"></span>\n <button class=\"btn\" id=\"scenetest-clear-full\">Clear</button>\n </div>\n </div>\n <div id=\"list\">\n <div id=\"empty\">\n <div id=\"empty-icon\">\\u{1F3AC}</div>\n <div>Interact with your app to see inline assertions appear here...</div>\n </div>\n </div>\n </body>\n </html>\n `;\n }\n function setFullscreenFilter(newFilter) {\n setFilter(newFilter);\n if (panel) {\n panel.querySelector(\"#scenetest-filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n panel.querySelector(\"#scenetest-filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n panel.querySelector(\"#scenetest-pass\")?.classList.toggle(\"active\", filter === \"passes\");\n panel.querySelector(\"#scenetest-fail\")?.classList.toggle(\"active\", filter === \"fails\");\n }\n updatePanel();\n updateFullscreenWindow();\n }\n function setFullscreenViewMode(newMode) {\n setViewMode(newMode);\n updateFullscreenWindow();\n }\n function showSequence(locationKey) {\n setSequenceLocation(locationKey);\n updateFullscreenWindow();\n }\n var scrollToGroupId = null;\n function openFullscreen(groupId) {\n scrollToGroupId = groupId ?? null;\n if (fullscreenWindow && !fullscreenWindow.closed) {\n fullscreenWindow.focus();\n if (scrollToGroupId !== null) {\n scrollToGroup(scrollToGroupId);\n }\n return;\n }\n const win = window.open(\"\", \"scenetest-fullscreen\", \"width=900,height=700\");\n if (!win) {\n alert(\"Please allow popups for this site to use fullscreen mode.\");\n return;\n }\n setFullscreenWindow(win);\n win.document.write(getFullscreenHTML());\n win.document.close();\n const doc = win.document;\n doc.getElementById(\"scenetest-clear-full\")?.addEventListener(\"click\", () => {\n clearAll();\n updatePanel();\n updateFullscreenWindow();\n });\n doc.getElementById(\"filter-all\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"all\");\n });\n doc.getElementById(\"filter-fails\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"fails\");\n });\n doc.getElementById(\"filter-passes\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"passes\");\n });\n doc.getElementById(\"view-grouped\")?.addEventListener(\"click\", () => {\n setFullscreenViewMode(\"grouped\");\n });\n doc.getElementById(\"view-byLocation\")?.addEventListener(\"click\", () => {\n setFullscreenViewMode(\"byLocation\");\n });\n updateFullscreenWindow();\n if (scrollToGroupId !== null) {\n scrollToGroup(scrollToGroupId);\n scrollToGroupId = null;\n }\n }\n function scrollToGroup(groupId) {\n if (!fullscreenWindow || fullscreenWindow.closed) return;\n const doc = fullscreenWindow.document;\n const groupEl = doc.querySelector(`[data-group-id=\"${groupId}\"]`);\n if (groupEl) {\n groupEl.classList.remove(\"collapsed\");\n groupEl.scrollIntoView({ behavior: \"auto\", block: \"start\" });\n const prevHighlighted = doc.querySelector(\".group.highlighted\");\n if (prevHighlighted) prevHighlighted.classList.remove(\"highlighted\");\n groupEl.classList.add(\"highlighted\");\n }\n }\n function openFullscreenToGroup(groupId) {\n openFullscreen(groupId);\n }\n function updateFullscreenWindow() {\n if (!fullscreenWindow || fullscreenWindow.closed) return;\n const doc = fullscreenWindow.document;\n const passCountEl = doc.getElementById(\"pass-count\");\n const failCountEl = doc.getElementById(\"fail-count\");\n if (passCountEl) passCountEl.textContent = `\\u2713 ${passCount}`;\n if (failCountEl) failCountEl.textContent = `\\u2717 ${failCount}`;\n doc.getElementById(\"filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n doc.getElementById(\"filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n doc.getElementById(\"filter-passes\")?.classList.toggle(\"active\", filter === \"passes\");\n const isSequenceView = viewMode === \"sequence\";\n doc.getElementById(\"view-grouped\")?.classList.toggle(\"active\", viewMode === \"grouped\");\n doc.getElementById(\"view-byLocation\")?.classList.toggle(\"active\", viewMode === \"byLocation\" || isSequenceView);\n const listEl2 = doc.getElementById(\"list\");\n if (!listEl2) return;\n if (viewMode === \"sequence\" && sequenceLocationKey) {\n renderSequenceView(doc, listEl2);\n return;\n }\n if (viewMode === \"byLocation\") {\n renderByLocationView(doc, listEl2);\n return;\n }\n renderGroupedView(doc, listEl2);\n }\n function renderGroupedView(doc, listEl2) {\n const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;\n const isScrolled = scrollTop > 50;\n let anchorGroupId = null;\n let anchorOffset = 0;\n if (isScrolled) {\n const groupEls = listEl2.querySelectorAll(\"[data-group-id]\");\n for (const el of groupEls) {\n const rect = el.getBoundingClientRect();\n if (rect.top >= -rect.height / 2) {\n anchorGroupId = parseInt(el.getAttribute(\"data-group-id\") || \"\", 10);\n anchorOffset = rect.top;\n break;\n }\n }\n }\n const filteredGroups = groups.map((g) => ({\n ...g,\n items: filterItems(g.items)\n })).filter((g) => g.items.length > 0);\n if (filteredGroups.length === 0) {\n const icon = filter === \"fails\" ? \"\\u2713\" : \"\\u{1F3AC}\";\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Interact with your app to see inline assertions appear here...\";\n listEl2.innerHTML = `\n <div id=\"empty\">\n <div id=\"empty-icon\">${icon}</div>\n <div>${message}</div>\n </div>\n `;\n return;\n }\n listEl2.innerHTML = filteredGroups.map((g) => renderFullscreenGroup(g)).reverse().join(\"\");\n if (anchorGroupId !== null) {\n const anchorEl = listEl2.querySelector(`[data-group-id=\"${anchorGroupId}\"]`);\n if (anchorEl) {\n const newRect = anchorEl.getBoundingClientRect();\n const scrollAdjustment = newRect.top - anchorOffset;\n doc.documentElement.scrollTop += scrollAdjustment;\n doc.body.scrollTop += scrollAdjustment;\n }\n }\n }\n function renderByLocationView(_doc, listEl2) {\n const locations = Array.from(locationGroups.values()).sort((a, b) => b.lastTimestamp - a.lastTimestamp);\n let filteredLocations = locations;\n if (filter === \"fails\") {\n filteredLocations = locations.filter((loc) => loc.entries.some((e) => !e.result));\n } else if (filter === \"passes\") {\n filteredLocations = locations.filter((loc) => loc.entries.some((e) => e.result));\n }\n if (filteredLocations.length === 0) {\n const icon = filter === \"fails\" ? \"\\u2713\" : \"\\u{1F3AC}\";\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Interact with your app to see inline assertions appear here...\";\n listEl2.innerHTML = `\n <div id=\"empty\">\n <div id=\"empty-icon\">${icon}</div>\n <div>${message}</div>\n </div>\n `;\n return;\n }\n listEl2.innerHTML = `\n <div class=\"location-list\">\n ${filteredLocations.map((loc) => renderLocationRow(loc)).join(\"\")}\n </div>\n `;\n }\n function renderSequenceView(_doc, listEl2) {\n const group = locationGroups.get(sequenceLocationKey);\n if (!group) {\n setViewMode(\"byLocation\");\n renderByLocationView(_doc, listEl2);\n return;\n }\n let entries = group.entries;\n if (filter === \"fails\") {\n entries = entries.filter((e) => !e.result);\n } else if (filter === \"passes\") {\n entries = entries.filter((e) => e.result);\n }\n const backHandler = `window.opener ? window.opener.__scenetest_setViewMode && window.opener.__scenetest_setViewMode('byLocation') : window.__scenetest_setViewMode && window.__scenetest_setViewMode('byLocation')`;\n const reversedEntries = entries.slice().reverse();\n const entryCount = reversedEntries.length;\n listEl2.innerHTML = `\n <div class=\"back-btn\" onclick=\"${backHandler}\">\\u2190 Back to all locations</div>\n ${renderSequenceHeader(group)}\n <div class=\"sequence-direction-hint\">\n <span class=\"direction-arrow\">\\u2191</span> Most recent at top\n </div>\n <div class=\"sequence-list\">\n ${reversedEntries.map((entry, i) => {\n const isFirst = i === 0;\n const isLast = i === entryCount - 1;\n return renderSequenceEntry(entry, group.location, isFirst, isLast);\n }).join(\"\")}\n </div>\n ${entryCount > 1 ? '<div class=\"sequence-end-label\">First event</div>' : \"\"}\n `;\n }\n\n // src/dev-panel/panel.ts\n function getPanelHTML() {\n return `\n <style>${panelStyles}</style>\n <div id=\"scenetest-header\">\n <span id=\"scenetest-title\"><span class=\"scenetest-icon\"><span>\\u{1F3AC}</span></span>scenetest</span>\n <span id=\"scenetest-counts\">\n <span class=\"scenetest-count pass\" id=\"scenetest-pass\" title=\"Click to filter passes\">\\u2713 0</span>\n <span class=\"scenetest-count fail\" id=\"scenetest-fail\" title=\"Click to filter failures\">\\u2717 0</span>\n </span>\n </div>\n <div id=\"scenetest-actions\">\n <div class=\"scenetest-btn-group\">\n <button class=\"scenetest-btn active\" id=\"scenetest-filter-all\">all</button>\n <button class=\"scenetest-btn\" id=\"scenetest-filter-fails\">errors</button>\n </div>\n <span class=\"scenetest-separator\"></span>\n <button class=\"scenetest-btn\" id=\"scenetest-fullscreen\">fullscreen</button>\n <button class=\"scenetest-btn\" id=\"scenetest-clear\">clear</button>\n </div>\n <div id=\"scenetest-list\">\n <div id=\"scenetest-empty\">Click around to see inline assertions...</div>\n </div>\n `;\n }\n function handleSetFilter(newFilter) {\n setFilter(newFilter);\n panel?.querySelector(\"#scenetest-filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n panel?.querySelector(\"#scenetest-filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n panel?.querySelector(\"#scenetest-pass\")?.classList.toggle(\"active\", filter === \"passes\");\n panel?.querySelector(\"#scenetest-fail\")?.classList.toggle(\"active\", filter === \"fails\");\n updatePanel();\n updateFullscreenWindow();\n }\n function createPanel() {\n const panelEl = document.createElement(\"div\");\n panelEl.id = \"scenetest-panel\";\n panelEl.innerHTML = getPanelHTML();\n document.body.appendChild(panelEl);\n setPanel(panelEl);\n setListEl(panelEl.querySelector(\"#scenetest-list\"));\n panelEl.querySelector(\"#scenetest-header\")?.addEventListener(\"click\", (e) => {\n const target = e.target;\n if (target.classList.contains(\"scenetest-count\")) return;\n panelEl.classList.toggle(\"collapsed\");\n });\n panelEl.querySelector(\"#scenetest-pass\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(filter === \"passes\" ? \"all\" : \"passes\");\n });\n panelEl.querySelector(\"#scenetest-fail\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(filter === \"fails\" ? \"all\" : \"fails\");\n });\n panelEl.querySelector(\"#scenetest-filter-all\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(\"all\");\n });\n panelEl.querySelector(\"#scenetest-filter-fails\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(\"fails\");\n });\n panelEl.querySelector(\"#scenetest-clear\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n clearAll();\n updatePanel();\n updateFullscreenWindow();\n });\n panelEl.querySelector(\"#scenetest-fullscreen\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openFullscreen();\n });\n }\n function updatePanel() {\n if (!panel || !listEl) return;\n const passEl = panel.querySelector(\"#scenetest-pass\");\n const failEl = panel.querySelector(\"#scenetest-fail\");\n if (passEl) passEl.textContent = `\\u2713 ${passCount}`;\n if (failEl) failEl.textContent = `\\u2717 ${failCount}`;\n const filteredGroups = groups.map((g) => ({\n ...g,\n items: filterItems(g.items)\n })).filter((g) => g.items.length > 0);\n if (filteredGroups.length === 0) {\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Click around to see inline assertions...\";\n listEl.innerHTML = `<div id=\"scenetest-empty\">${message}</div>`;\n return;\n }\n listEl.innerHTML = filteredGroups.map((g) => renderPanelGroup(g)).reverse().join(\"\");\n }\n\n // src/dev-panel/index.ts\n if (!window.__scenetest_panel) {\n let addToGroup = function(result) {\n const now = Date.now();\n if (pendingGroup && now - pendingGroup.timestamp < GROUP_THRESHOLD_MS) {\n pendingGroup.items.push(result);\n } else {\n const newGroup = {\n id: groups.length,\n timestamp: now,\n items: [result],\n collapsed: collapsedMode\n // Use collapsedMode to determine initial state\n };\n setPendingGroup(newGroup);\n groups.push(newGroup);\n }\n if (groupTimeout) clearTimeout(groupTimeout);\n setGroupTimeout(\n setTimeout(() => {\n setPendingGroup(null);\n updatePanel();\n updateFullscreenWindow();\n }, GROUP_THRESHOLD_MS)\n );\n };\n addToGroup2 = addToGroup;\n window.__scenetest_panel = true;\n window.__scenetest_openInEditor = openInEditor;\n window.__scenetest_openFullscreenToGroup = openFullscreenToGroup;\n window.__scenetest_showSequence = showSequence;\n window.__scenetest_setViewMode = (mode) => {\n setViewMode(mode);\n updateFullscreenWindow();\n };\n const existingReport = window.__scenetest_report;\n window.__scenetest_report = function(result) {\n if (existingReport) {\n try {\n existingReport(result);\n } catch {\n }\n }\n const index = assertions.length;\n result._index = index;\n assertions.push(result);\n trackAssertion(result, index);\n trackLocationGroup(result, index);\n if (result.result) {\n incrementPassCount();\n } else {\n incrementFailCount();\n }\n addToGroup(result);\n if (!panel && document.body) {\n createPanel();\n }\n updatePanel();\n updateFullscreenWindow();\n const icon = result.result ? \"\\u2713\" : \"\\u2717\";\n const style = result.result ? \"color: #4ade80\" : \"color: #f87171\";\n console.log(`%c${icon} [scenetest] ${result.description}`, style);\n };\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", createPanel);\n } else {\n createPanel();\n }\n }\n var addToGroup2;\n})();\n";
|
|
9
|
+
//# sourceMappingURL=dev-panel.generated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-panel.generated.d.ts","sourceRoot":"","sources":["../src/dev-panel.generated.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,cAAc,wx9CAAwx9C,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated dev panel script
|
|
3
|
+
* DO NOT EDIT - this file is generated by scripts/bundle-dev-panel.mjs
|
|
4
|
+
*
|
|
5
|
+
* To modify the dev panel, edit files in src/dev-panel/ and run:
|
|
6
|
+
* pnpm run build:dev-panel
|
|
7
|
+
*/
|
|
8
|
+
export const devPanelScript = "\"use strict\";\n(() => {\n // src/dev-panel/state.ts\n var assertions = [];\n var groups = [];\n var assertionHistory = /* @__PURE__ */ new Map();\n var locationGroups = /* @__PURE__ */ new Map();\n var passCount = 0;\n var failCount = 0;\n function incrementPassCount() {\n passCount++;\n }\n function incrementFailCount() {\n failCount++;\n }\n var panel = null;\n var listEl = null;\n var fullscreenWindow = null;\n var filter = \"all\";\n var collapsedMode = true;\n var viewMode = \"grouped\";\n var sequenceLocationKey = null;\n function setPanel(el) {\n panel = el;\n }\n function setListEl(el) {\n listEl = el;\n }\n function setFullscreenWindow(win) {\n fullscreenWindow = win;\n }\n function setFilter(newFilter) {\n filter = newFilter;\n }\n function setViewMode(mode) {\n viewMode = mode;\n if (mode !== \"sequence\") {\n sequenceLocationKey = null;\n }\n }\n function setSequenceLocation(key) {\n sequenceLocationKey = key;\n if (key !== null) {\n viewMode = \"sequence\";\n }\n }\n function getLocationKey(result) {\n if (!result.location) return null;\n return `${result.location.file}:${result.location.line}`;\n }\n function trackLocationGroup(result, index) {\n const key = getLocationKey(result);\n if (!key || !result.location) return;\n let group = locationGroups.get(key);\n if (!group) {\n group = {\n key,\n location: result.location,\n description: result.description,\n entries: [],\n lastResult: result.result,\n lastTimestamp: result.timestamp\n };\n locationGroups.set(key, group);\n }\n group.description = result.description;\n group.lastResult = result.result;\n group.lastTimestamp = result.timestamp;\n group.entries.push({\n result: result.result,\n timestamp: result.timestamp,\n index,\n description: result.description,\n context: result.context\n });\n }\n var GROUP_THRESHOLD_MS = 50;\n var pendingGroup = null;\n var groupTimeout = null;\n function setPendingGroup(group) {\n pendingGroup = group;\n }\n function setGroupTimeout(timeout) {\n groupTimeout = timeout;\n }\n function clearAll() {\n assertions.length = 0;\n groups.length = 0;\n assertionHistory.clear();\n locationGroups.clear();\n passCount = 0;\n failCount = 0;\n pendingGroup = null;\n sequenceLocationKey = null;\n if (groupTimeout) {\n clearTimeout(groupTimeout);\n groupTimeout = null;\n }\n }\n\n // src/dev-panel/history.ts\n function trackAssertion(result, index) {\n const key = result.description;\n if (!assertionHistory.has(key)) {\n assertionHistory.set(key, []);\n }\n const history = assertionHistory.get(key);\n history.push({ result: result.result, timestamp: result.timestamp, index });\n return history;\n }\n function getHistoryStats(description, currentIndex) {\n const history = assertionHistory.get(description);\n if (!history || history.length <= 1) return null;\n let priorPassed = 0;\n let priorFailed = 0;\n let afterPassed = 0;\n let afterFailed = 0;\n for (const entry of history) {\n if (entry.index < currentIndex) {\n if (entry.result) priorPassed++;\n else priorFailed++;\n } else if (entry.index > currentIndex) {\n if (entry.result) afterPassed++;\n else afterFailed++;\n }\n }\n const total = history.length - 1;\n if (total === 0) return null;\n return { priorPassed, priorFailed, afterPassed, afterFailed, total };\n }\n function formatHistorySummary(stats) {\n if (!stats) return \"\";\n const parts = [];\n const priorTotal = stats.priorPassed + stats.priorFailed;\n const afterTotal = stats.afterPassed + stats.afterFailed;\n if (priorTotal > 0) {\n if (stats.priorFailed === 0) {\n parts.push(`${priorTotal} prior \\u2713`);\n } else if (stats.priorPassed === 0) {\n parts.push(`${priorTotal} prior \\u2717`);\n } else {\n parts.push(`${priorTotal} prior (${stats.priorPassed}\\u2713 ${stats.priorFailed}\\u2717)`);\n }\n }\n if (afterTotal > 0) {\n if (stats.afterFailed === 0) {\n parts.push(`${afterTotal} after \\u2713`);\n } else if (stats.afterPassed === 0) {\n parts.push(`${afterTotal} after \\u2717`);\n } else {\n parts.push(`${afterTotal} after (${stats.afterPassed}\\u2713 ${stats.afterFailed}\\u2717)`);\n }\n }\n return parts.length > 0 ? `(${parts.join(\", \")})` : \"\";\n }\n\n // src/dev-panel/utils.ts\n function escapeHtml(str) {\n return str.replace(/&/g, \"&\").replace(/</g, \"<\").replace(/>/g, \">\");\n }\n function formatContext(ctx) {\n if (!ctx) return \"\";\n try {\n return JSON.stringify(ctx, null, 2);\n } catch {\n return String(ctx);\n }\n }\n function formatLocation(loc) {\n if (!loc) return \"\";\n const file = loc.file.replace(/^.*\\/src\\//, \"src/\");\n return `${file}:${loc.line}${loc.column ? \":\" + loc.column : \"\"}`;\n }\n function formatTime(ts) {\n const d = new Date(ts);\n return d.toLocaleTimeString(\"en-US\", { hour12: false }) + \".\" + String(d.getMilliseconds()).padStart(3, \"0\");\n }\n function openInEditor(loc) {\n if (!loc) return;\n fetch(`/__open-in-editor?file=${encodeURIComponent(loc.file)}&line=${loc.line}${loc.column ? \"&column=\" + loc.column : \"\"}`).catch(() => {\n window.open(`vscode://file${loc.file}:${loc.line}${loc.column ? \":\" + loc.column : \"\"}`);\n });\n }\n function filterItems(items) {\n if (filter === \"all\") return items;\n if (filter === \"fails\") return items.filter((a) => !a.result);\n if (filter === \"passes\") return items.filter((a) => a.result);\n return items;\n }\n function getGroupStats(items) {\n let passCount2 = 0;\n let failCount2 = 0;\n for (const item of items) {\n if (item.result) passCount2++;\n else failCount2++;\n }\n return { passCount: passCount2, failCount: failCount2 };\n }\n function formatLocationShort(loc) {\n if (!loc) return \"\";\n const file = loc.file.split(\"/\").pop() || loc.file;\n return `${file}:${loc.line}`;\n }\n\n // src/dev-panel/render.ts\n function renderPanelItem(a, groupId) {\n const titleAttr = a.context ? escapeHtml(formatContext(a.context)) : a.location ? escapeHtml(formatLocation(a.location)) : \"\";\n return `\n <div class=\"scenetest-item ${a.result ? \"pass\" : \"fail\"}\"\n onclick=\"if(window.__scenetest_openFullscreenToGroup)window.__scenetest_openFullscreenToGroup(${groupId})\"\n title=\"${titleAttr}\">\n <span class=\"scenetest-icon\">${a.result ? \"\\u2713\" : \"\\u2717\"}</span>\n <div class=\"scenetest-content\">\n <div class=\"scenetest-desc${a.type === \"fail\" && a.result ? \" negated\" : \"\"}\">${escapeHtml(a.description)}</div>\n ${a.location ? `<div class=\"scenetest-location\">${escapeHtml(formatLocation(a.location))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderPanelGroup(g) {\n const stats = getGroupStats(g.items);\n return `\n <div class=\"scenetest-group${g.collapsed ? \" collapsed\" : \"\"}\" data-group-id=\"${g.id}\">\n <div class=\"scenetest-group-header\" onclick=\"this.parentElement.classList.toggle('collapsed')\">\n <div class=\"scenetest-group-summary\">\n <span class=\"scenetest-group-time\">${formatTime(g.timestamp)}</span>\n <div class=\"scenetest-group-stats\">\n <span class=\"scenetest-group-stat pass\">\\u2713${stats.passCount}</span>\n <span class=\"scenetest-group-stat ${stats.failCount > 0 ? \"fail\" : \"zero\"}\">\\u2717${stats.failCount}</span>\n </div>\n </div>\n <span class=\"scenetest-group-toggle\">\\u25BC</span>\n </div>\n <div class=\"scenetest-group-items\">\n ${g.items.map((a) => renderPanelItem(a, g.id)).join(\"\")}\n </div>\n </div>\n `;\n }\n function renderFullscreenItem(a) {\n const histStats = getHistoryStats(a.description, a._index ?? 0);\n const histSummary = formatHistorySummary(histStats);\n const locJson = a.location ? JSON.stringify(a.location).replace(/\"/g, \""\") : \"null\";\n return `\n <div class=\"item ${a.result ? \"pass\" : \"fail\"}\">\n <span class=\"icon\">${a.result ? \"\\u2713\" : \"\\u2717\"}</span>\n <div class=\"content\">\n <div class=\"desc${a.type === \"fail\" && a.result ? \" negated\" : \"\"}\">${escapeHtml(a.description)}</div>\n ${a.location ? `<div class=\"location\" onclick=\"window.opener && window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${locJson})\">${escapeHtml(formatLocation(a.location))}</div>` : \"\"}\n ${histSummary ? `<div class=\"history\">${histSummary}</div>` : \"\"}\n ${a.context ? `<div class=\"context\">${escapeHtml(formatContext(a.context))}</div>` : \"\"}\n ${a.stack && !a.context ? `<div class=\"stack\">${escapeHtml(a.stack.split(\"\\n\").slice(0, 3).join(\"\\n\"))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderFullscreenGroup(g) {\n const stats = getGroupStats(g.items);\n return `\n <div class=\"group\" data-group-id=\"${g.id}\">\n <div class=\"group-header\" onclick=\"this.parentElement.classList.toggle('collapsed')\">\n <div class=\"group-info\">\n <span class=\"group-time\">${formatTime(g.timestamp)}</span>\n <div class=\"group-stats\">\n ${stats.passCount > 0 ? `<span class=\"group-stat pass\">\\u2713 ${stats.passCount}</span>` : \"\"}\n ${stats.failCount > 0 ? `<span class=\"group-stat fail\">\\u2717 ${stats.failCount}</span>` : \"\"}\n </div>\n <span style=\"color: #6a6a8a\">${g.items.length} assertion${g.items.length === 1 ? \"\" : \"s\"}</span>\n </div>\n <span class=\"group-toggle\">\\u25BC</span>\n </div>\n <div class=\"group-items\">\n ${g.items.map((a) => renderFullscreenItem(a)).join(\"\")}\n </div>\n </div>\n `;\n }\n function renderLocationRow(group) {\n const passCount2 = group.entries.filter((e) => e.result).length;\n const failCount2 = group.entries.filter((e) => !e.result).length;\n const total = group.entries.length;\n const keyJson = JSON.stringify(group.key).replace(/\"/g, \""\");\n const recentEntries = group.entries.slice(-10);\n const dots = recentEntries.map((e) => {\n if (e.result) {\n return `<span class=\"status-dot pass\" title=\"${formatTime(e.timestamp)}\"></span>`;\n } else {\n return `<span class=\"status-dot fail\" title=\"${formatTime(e.timestamp)}\"><span class=\"dot-x\">\\u2717</span></span>`;\n }\n }).join(\"\");\n const hasAnyFails = failCount2 > 0;\n const lastFailed = !group.lastResult;\n const statusClass = lastFailed ? \"last-fail\" : hasAnyFails ? \"has-fails\" : \"all-pass\";\n const stateIcon = lastFailed ? '<span class=\"state-icon fail\">\\u2717</span>' : hasAnyFails ? '<span class=\"state-icon warn\">\\u26A0</span>' : '<span class=\"state-icon pass\">\\u2713</span>';\n return `\n <div class=\"location-row ${statusClass}\" data-location-key=\"${keyJson}\">\n ${stateIcon}\n <div class=\"location-main\" onclick=\"window.opener ? window.opener.__scenetest_showSequence && window.opener.__scenetest_showSequence(${keyJson}) : window.__scenetest_showSequence && window.__scenetest_showSequence(${keyJson})\">\n <div class=\"location-info\">\n <span class=\"location-file\">${escapeHtml(formatLocationShort(group.location))}</span>\n <span class=\"location-desc\">${escapeHtml(group.description)}</span>\n </div>\n <div class=\"location-stats\">\n <div class=\"status-dots\">${dots}</div>\n <span class=\"location-count\">${total} run${total === 1 ? \"\" : \"s\"}</span>\n <div class=\"location-summary\">\n ${passCount2 > 0 ? `<span class=\"stat pass\">\\u2713${passCount2}</span>` : \"\"}\n ${failCount2 > 0 ? `<span class=\"stat fail\">\\u2717${failCount2}</span>` : \"\"}\n </div>\n </div>\n </div>\n <div class=\"location-actions\">\n <button class=\"loc-btn\" onclick=\"event.stopPropagation(); window.opener ? window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${JSON.stringify(group.location).replace(/\"/g, \""\")}) : window.__scenetest_openInEditor && window.__scenetest_openInEditor(${JSON.stringify(group.location).replace(/\"/g, \""\")})\" title=\"Open in editor\">\\u270E</button>\n </div>\n </div>\n `;\n }\n function renderSequenceEntry(entry, location, isFirst = false, isLast = false) {\n return `\n <div class=\"sequence-entry ${entry.result ? \"pass\" : \"fail\"}\">\n <div class=\"timeline-track\">\n <div class=\"timeline-line ${isFirst ? \"first\" : \"\"} ${isLast ? \"last\" : \"\"}\"></div>\n <div class=\"timeline-dot ${entry.result ? \"pass\" : \"fail\"}\">\n ${entry.result ? \"\" : '<span class=\"dot-x\">\\u2717</span>'}\n </div>\n </div>\n <div class=\"content\">\n <div class=\"sequence-time\">${formatTime(entry.timestamp)}</div>\n <div class=\"desc\">${escapeHtml(entry.description)}</div>\n ${entry.context ? `<div class=\"context\">${escapeHtml(formatContext(entry.context))}</div>` : \"\"}\n </div>\n </div>\n `;\n }\n function renderSequenceHeader(group) {\n const locJson = JSON.stringify(group.location).replace(/\"/g, \""\");\n const passCount2 = group.entries.filter((e) => e.result).length;\n const failCount2 = group.entries.filter((e) => !e.result).length;\n return `\n <div class=\"sequence-header\">\n <div class=\"sequence-location\">\n <span class=\"sequence-file\" onclick=\"window.opener ? window.opener.__scenetest_openInEditor && window.opener.__scenetest_openInEditor(${locJson}) : window.__scenetest_openInEditor && window.__scenetest_openInEditor(${locJson})\">${escapeHtml(formatLocation(group.location))}</span>\n </div>\n <div class=\"sequence-summary\">\n <span class=\"sequence-total\">${group.entries.length} run${group.entries.length === 1 ? \"\" : \"s\"}</span>\n <div class=\"sequence-stats\">\n ${passCount2 > 0 ? `<span class=\"stat pass\">\\u2713 ${passCount2}</span>` : \"\"}\n ${failCount2 > 0 ? `<span class=\"stat fail\">\\u2717 ${failCount2}</span>` : \"\"}\n </div>\n </div>\n </div>\n `;\n }\n\n // src/dev-panel/styles.ts\n var panelStyles = `\n#scenetest-panel {\n position: fixed;\n bottom: 16px;\n right: 16px;\n width: 400px;\n max-height: 450px;\n background: #1a1a2e;\n border: 1px solid #4a4a6a;\n border-radius: 8px;\n font-family: ui-monospace, monospace;\n font-size: 12px;\n color: #e0e0e0;\n z-index: 999999;\n box-shadow: 0 4px 24px rgba(0,0,0,0.4);\n display: flex;\n flex-direction: column;\n}\n#scenetest-panel.collapsed {\n max-height: none;\n width: auto;\n}\n#scenetest-panel.collapsed #scenetest-list,\n#scenetest-panel.collapsed #scenetest-actions {\n display: none;\n}\n#scenetest-header {\n padding: 10px 12px;\n background: #252542;\n border-radius: 8px 8px 0 0;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n user-select: none;\n border-bottom: 1px solid #4a4a6a;\n}\n#scenetest-header:hover {\n background: #2a2a4a;\n}\n#scenetest-title {\n font-weight: 600;\n color: #a0a0ff;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.scenetest-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n border-radius: 50%;\n background: #a0a0ff;\n font-size: 12px;\n filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));\n}\n.scenetest-icon span {\n filter: drop-shadow(0px 0px 4px #ffffff);\n}\n#scenetest-counts {\n display: flex;\n gap: 8px;\n align-items: center;\n}\n.scenetest-count {\n padding: 2px 8px;\n border-radius: 4px;\n font-weight: 500;\n cursor: pointer;\n transition: opacity 0.15s;\n}\n.scenetest-count:hover {\n opacity: 0.8;\n}\n.scenetest-count.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.scenetest-count.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.scenetest-count.active {\n outline: 2px solid currentColor;\n outline-offset: 1px;\n}\n#scenetest-actions {\n display: flex;\n gap: 8px;\n padding: 6px 12px;\n background: #202038;\n border-bottom: 1px solid #3a3a5a;\n flex-wrap: wrap;\n align-items: center;\n}\n.scenetest-btn-group {\n display: flex;\n border: 1px solid #4a4a6a;\n border-radius: 4px;\n overflow: hidden;\n}\n.scenetest-btn-group .scenetest-btn {\n border: none;\n border-radius: 0;\n border-right: 1px solid #4a4a6a;\n}\n.scenetest-btn-group .scenetest-btn:last-child {\n border-right: none;\n}\n.scenetest-btn {\n background: none;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 4px 10px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 11px;\n font-family: inherit;\n transition: all 0.15s;\n}\n.scenetest-btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n.scenetest-btn.active {\n background: #4a4a6a;\n color: #fff;\n}\n.scenetest-separator {\n width: 1px;\n height: 16px;\n background: #3a3a5a;\n}\n#scenetest-list {\n overflow-y: auto;\n max-height: 340px;\n padding: 8px 0;\n}\n.scenetest-group {\n margin: 4px 8px;\n border: 1px solid #3a3a5a;\n border-radius: 6px;\n overflow: hidden;\n}\n.scenetest-group-header {\n padding: 6px 10px;\n background: #252542;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n font-size: 11px;\n}\n.scenetest-group-header:hover {\n background: #2a2a4a;\n}\n.scenetest-group-summary {\n display: flex;\n gap: 8px;\n align-items: center;\n}\n.scenetest-group-time {\n color: #6a6a8a;\n}\n.scenetest-group-stats {\n display: flex;\n gap: 6px;\n}\n.scenetest-group-stat {\n font-size: 10px;\n padding: 1px 5px;\n border-radius: 3px;\n}\n.scenetest-group-stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.scenetest-group-stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.scenetest-group-stat.zero {\n background: #2a2a3a;\n color: #6a6a8a;\n}\n.scenetest-group-items {\n border-top: 1px solid #3a3a5a;\n}\n.scenetest-group.collapsed .scenetest-group-items {\n display: none;\n}\n.scenetest-group-toggle {\n color: #6a6a8a;\n font-size: 10px;\n}\n.scenetest-item {\n padding: 6px 10px;\n border-bottom: 1px solid #2a2a4a;\n display: flex;\n gap: 8px;\n align-items: flex-start;\n cursor: pointer;\n position: relative;\n}\n.scenetest-item:hover {\n background: #252545;\n}\n.scenetest-item:last-child {\n border-bottom: none;\n}\n.scenetest-item.pass .scenetest-icon {\n color: #4ade80;\n}\n.scenetest-item.fail .scenetest-icon {\n color: #f87171;\n}\n.scenetest-icon {\n flex-shrink: 0;\n width: 14px;\n text-align: center;\n}\n.scenetest-content {\n flex: 1;\n min-width: 0;\n}\n.scenetest-desc {\n word-break: break-word;\n}\n.scenetest-item.fail .scenetest-desc {\n color: #f87171;\n}\n.scenetest-desc.negated {\n text-decoration: line-through;\n opacity: 0.7;\n}\n.scenetest-location {\n font-size: 9px;\n color: #6a6a8a;\n margin-top: 2px;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.scenetest-location:hover {\n color: #a0a0ff;\n text-decoration: underline;\n}\n.scenetest-context {\n font-size: 10px;\n color: #8a8aaa;\n margin-top: 3px;\n padding: 4px 6px;\n background: #12122a;\n border-radius: 3px;\n font-family: ui-monospace, monospace;\n max-height: 60px;\n overflow: auto;\n white-space: pre-wrap;\n word-break: break-all;\n}\n.scenetest-time {\n color: #6a6a8a;\n flex-shrink: 0;\n font-size: 10px;\n}\n#scenetest-empty {\n padding: 20px;\n text-align: center;\n color: #6a6a8a;\n}\n.scenetest-ungrouped {\n padding: 4px 8px;\n}\n.scenetest-history {\n font-size: 9px;\n color: #8a8aaa;\n margin-top: 2px;\n font-style: italic;\n}\n`;\n var fullscreenStyles = `\n* { box-sizing: border-box; }\nbody {\n margin: 0;\n padding: 0;\n background: #0f0f1a;\n color: #e0e0e0;\n font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;\n font-size: 13px;\n}\n#header {\n position: sticky;\n top: 0;\n background: #1a1a2e;\n border-bottom: 1px solid #4a4a6a;\n padding: 16px 24px;\n display: flex;\n justify-content: space-between;\n align-items: center;\n z-index: 100;\n flex-wrap: wrap;\n gap: 12px;\n}\n#title {\n font-size: 18px;\n font-weight: 600;\n color: #a0a0ff;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n#title .icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 32px;\n height: 32px;\n border-radius: 50%;\n background: #a0a0ff;\n font-size: 18px;\n filter: drop-shadow(0 2px 6px rgba(0,0,0,0.3));\n}\n#title .icon span {\n filter: drop-shadow(0px 0px 5px #ffffff);\n}\n#controls {\n display: flex;\n gap: 12px;\n align-items: center;\n flex-wrap: wrap;\n}\n#counts {\n display: flex;\n gap: 12px;\n align-items: center;\n}\n.count {\n padding: 4px 12px;\n border-radius: 6px;\n font-weight: 600;\n font-size: 14px;\n}\n.count.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.count.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.btn {\n background: #252542;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 8px 16px;\n border-radius: 6px;\n cursor: pointer;\n font-size: 13px;\n font-family: inherit;\n transition: all 0.15s;\n}\n.btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n.btn.active {\n background: #4a4a6a;\n color: #fff;\n}\n#filters {\n display: flex;\n gap: 0;\n}\n.btn-group {\n display: flex;\n border: 1px solid #4a4a6a;\n border-radius: 6px;\n overflow: hidden;\n}\n.btn-group .btn {\n border: none;\n border-radius: 0;\n border-right: 1px solid #4a4a6a;\n}\n.btn-group .btn:last-child {\n border-right: none;\n}\n.separator {\n width: 1px;\n height: 24px;\n background: #4a4a6a;\n}\n#list {\n padding: 16px;\n}\n.ungrouped-list {\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n.group {\n margin-bottom: 16px;\n border: 1px solid #3a3a5a;\n border-radius: 8px;\n overflow: hidden;\n}\n.group-header {\n padding: 12px 16px;\n background: #1a1a2e;\n display: flex;\n justify-content: space-between;\n align-items: center;\n cursor: pointer;\n}\n.group-header:hover {\n background: #252542;\n}\n.group-info {\n display: flex;\n gap: 16px;\n align-items: center;\n}\n.group-time {\n color: #a0a0ff;\n font-weight: 500;\n}\n.group-stats {\n display: flex;\n gap: 8px;\n}\n.group-stat {\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 12px;\n}\n.group-stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.group-stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.group-toggle {\n color: #6a6a8a;\n font-size: 12px;\n}\n.group-items {\n border-top: 1px solid #3a3a5a;\n}\n.group.collapsed .group-items {\n display: none;\n}\n.item {\n padding: 10px 16px;\n background: #12121f;\n display: flex;\n gap: 12px;\n align-items: flex-start;\n border-bottom: 1px solid #2a2a4a;\n}\n.item:last-child {\n border-bottom: none;\n}\n.item.fail {\n background: #1a1212;\n}\n.icon {\n font-size: 14px;\n width: 18px;\n text-align: center;\n flex-shrink: 0;\n}\n.item.pass .icon { color: #4ade80; }\n.item.fail .icon { color: #f87171; }\n.content {\n flex: 1;\n}\n.desc {\n margin-bottom: 4px;\n word-break: break-word;\n}\n.item.fail .desc {\n color: #f87171;\n}\n.desc.negated {\n text-decoration: line-through;\n opacity: 0.7;\n}\n.location {\n font-size: 11px;\n color: #6a6a8a;\n margin-top: 4px;\n cursor: pointer;\n}\n.location:hover {\n color: #a0a0ff;\n text-decoration: underline;\n}\n.context {\n margin-top: 8px;\n padding: 8px;\n background: #0a0a12;\n border-radius: 4px;\n font-size: 11px;\n color: #a0a0c0;\n white-space: pre-wrap;\n word-break: break-all;\n max-height: 100px;\n overflow: auto;\n}\n.meta {\n font-size: 11px;\n color: #6a6a8a;\n}\n.stack {\n margin-top: 8px;\n padding: 8px;\n background: #0a0a12;\n border-radius: 4px;\n font-size: 11px;\n color: #8a8a9a;\n white-space: pre-wrap;\n word-break: break-all;\n}\n.history {\n font-size: 11px;\n color: #8a8aaa;\n margin-top: 4px;\n font-style: italic;\n}\n#empty {\n text-align: center;\n padding: 60px 20px;\n color: #6a6a8a;\n}\n#empty-icon {\n font-size: 48px;\n margin-bottom: 16px;\n}\n.group.highlighted {\n box-shadow: 0 0 0 3px #a0a0ff, 0 0 20px rgba(160, 160, 255, 0.4);\n}\n\n/* View mode toggle */\n#view-modes {\n display: flex;\n gap: 0;\n}\n\n/* Location view styles */\n.location-row {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 16px;\n background: #12121f;\n border: 1px solid #3a3a5a;\n border-radius: 8px;\n margin-bottom: 8px;\n cursor: pointer;\n transition: all 0.15s;\n}\n.location-row:hover {\n background: #1a1a2e;\n border-color: #4a4a6a;\n}\n.location-row.all-pass {\n border-left: 3px solid #4ade80;\n}\n.location-row.has-fails {\n border-left: 3px solid #f59e0b;\n}\n.location-row.last-fail {\n border-left: 3px solid #f87171;\n background: #1a1212;\n}\n.location-main {\n flex: 1;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 16px;\n}\n.location-info {\n display: flex;\n flex-direction: column;\n gap: 4px;\n min-width: 0;\n}\n.location-file {\n font-size: 12px;\n color: #a0a0ff;\n font-weight: 500;\n}\n.location-desc {\n font-size: 13px;\n color: #e0e0e0;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.location-stats {\n display: flex;\n align-items: center;\n gap: 12px;\n flex-shrink: 0;\n}\n/* Current state icon - prominent indicator on the left */\n.state-icon {\n width: 28px;\n height: 28px;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 16px;\n font-weight: bold;\n border-radius: 50%;\n flex-shrink: 0;\n margin-right: 12px;\n}\n.state-icon.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.state-icon.warn {\n background: #3a2a0a;\n color: #f59e0b;\n}\n.state-icon.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n\n.status-dots {\n display: flex;\n gap: 4px;\n align-items: center;\n}\n.status-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n transition: transform 0.15s;\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n}\n.status-dot.pass {\n background: #4ade80;\n}\n.status-dot.fail {\n background: #f87171;\n}\n/* Accessibility: X marker on failure dots for colorblind users */\n.status-dot .dot-x {\n position: absolute;\n color: white;\n font-size: 9px;\n font-weight: bold;\n line-height: 1;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n text-shadow: 0 0 2px rgba(0,0,0,0.5);\n}\n.status-dot:last-child {\n animation: pulse-dot 0.5s ease-out;\n}\n@keyframes pulse-dot {\n 0% { transform: scale(1.5); }\n 100% { transform: scale(1); }\n}\n.location-count {\n font-size: 11px;\n color: #6a6a8a;\n}\n.location-summary {\n display: flex;\n gap: 6px;\n}\n.location-summary .stat {\n padding: 2px 6px;\n border-radius: 4px;\n font-size: 11px;\n}\n.location-summary .stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.location-summary .stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n.location-actions {\n margin-left: 12px;\n}\n.loc-btn {\n background: #252542;\n border: 1px solid #4a4a6a;\n color: #a0a0a0;\n padding: 4px 8px;\n border-radius: 4px;\n cursor: pointer;\n font-size: 12px;\n}\n.loc-btn:hover {\n background: #3a3a5a;\n color: #e0e0e0;\n}\n\n/* Sequence view styles */\n.sequence-header {\n background: #1a1a2e;\n border: 1px solid #4a4a6a;\n border-radius: 8px;\n padding: 16px;\n margin-bottom: 8px;\n}\n.sequence-location {\n margin-bottom: 8px;\n}\n.sequence-file {\n color: #a0a0ff;\n font-size: 14px;\n cursor: pointer;\n}\n.sequence-file:hover {\n text-decoration: underline;\n}\n.sequence-summary {\n display: flex;\n align-items: center;\n gap: 16px;\n}\n.sequence-total {\n color: #6a6a8a;\n font-size: 12px;\n}\n.sequence-stats {\n display: flex;\n gap: 8px;\n}\n.sequence-stats .stat {\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 12px;\n}\n.sequence-stats .stat.pass {\n background: #1a3a1a;\n color: #4ade80;\n}\n.sequence-stats .stat.fail {\n background: #3a1a1a;\n color: #f87171;\n}\n\n/* Direction hint */\n.sequence-direction-hint {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 8px 16px;\n margin-bottom: 8px;\n color: #6a6a8a;\n font-size: 11px;\n background: #0f0f1a;\n border-radius: 4px;\n}\n.direction-arrow {\n color: #a0a0ff;\n font-size: 14px;\n}\n\n/* Timeline track for sequence entries */\n.sequence-list {\n position: relative;\n padding-left: 8px;\n}\n.sequence-entry {\n padding: 12px 16px 12px 0;\n background: #12121f;\n display: flex;\n gap: 0;\n align-items: stretch;\n border: 1px solid #3a3a5a;\n border-radius: 6px;\n margin-bottom: 0;\n margin-left: 20px;\n position: relative;\n}\n.sequence-entry.fail {\n background: #1a1212;\n border-color: #4a2a2a;\n}\n\n/* Timeline track with connecting line */\n.timeline-track {\n width: 40px;\n display: flex;\n flex-direction: column;\n align-items: center;\n position: relative;\n flex-shrink: 0;\n}\n.timeline-line {\n position: absolute;\n left: 50%;\n transform: translateX(-50%);\n width: 2px;\n background: #3a3a5a;\n top: -8px;\n bottom: -8px;\n}\n.timeline-line.first {\n top: 50%;\n}\n.timeline-line.last {\n bottom: 50%;\n}\n.timeline-line.first.last {\n display: none;\n}\n.timeline-dot {\n width: 14px;\n height: 14px;\n border-radius: 50%;\n position: relative;\n z-index: 1;\n display: flex;\n align-items: center;\n justify-content: center;\n margin-top: 4px;\n}\n.timeline-dot.pass {\n background: #4ade80;\n box-shadow: 0 0 0 3px #1a3a1a;\n}\n.timeline-dot.fail {\n background: #f87171;\n box-shadow: 0 0 0 3px #3a1a1a;\n}\n.timeline-dot .dot-x {\n color: white;\n font-size: 10px;\n font-weight: bold;\n line-height: 1;\n text-shadow: 0 0 2px rgba(0,0,0,0.5);\n}\n\n.sequence-entry .content {\n flex: 1;\n padding-left: 8px;\n}\n.sequence-time {\n font-size: 11px;\n color: #6a6a8a;\n margin-bottom: 4px;\n}\n\n/* End label */\n.sequence-end-label {\n margin-left: 20px;\n padding: 8px 16px 8px 40px;\n color: #6a6a8a;\n font-size: 11px;\n position: relative;\n}\n.sequence-end-label::before {\n content: '';\n position: absolute;\n left: 28px;\n top: 0;\n width: 2px;\n height: 8px;\n background: #3a3a5a;\n}\n\n.back-btn {\n display: flex;\n align-items: center;\n gap: 6px;\n margin-bottom: 16px;\n color: #a0a0ff;\n cursor: pointer;\n font-size: 13px;\n}\n.back-btn:hover {\n text-decoration: underline;\n}\n`;\n\n // src/dev-panel/fullscreen.ts\n function getFullscreenHTML() {\n return `\n <!DOCTYPE html>\n <html>\n <head>\n <title>scenetest - Inline Assertions</title>\n <style>${fullscreenStyles}</style>\n </head>\n <body>\n <div id=\"header\">\n <span id=\"title\"><span class=\"icon\"><span>\\u{1F3AC}</span></span>scenetest</span>\n <div id=\"controls\">\n <div id=\"counts\">\n <span class=\"count pass\" id=\"pass-count\">\\u2713 0</span>\n <span class=\"count fail\" id=\"fail-count\">\\u2717 0</span>\n </div>\n <div id=\"view-modes\" class=\"btn-group\">\n <button class=\"btn active\" id=\"view-grouped\" title=\"Group by time\">Timeline</button>\n <button class=\"btn\" id=\"view-byLocation\" title=\"Group by code location\">By Location</button>\n </div>\n <div id=\"filters\" class=\"btn-group\">\n <button class=\"btn active\" id=\"filter-all\">All</button>\n <button class=\"btn\" id=\"filter-fails\">Errors</button>\n <button class=\"btn\" id=\"filter-passes\">Passes</button>\n </div>\n <span class=\"separator\"></span>\n <button class=\"btn\" id=\"scenetest-clear-full\">Clear</button>\n </div>\n </div>\n <div id=\"list\">\n <div id=\"empty\">\n <div id=\"empty-icon\">\\u{1F3AC}</div>\n <div>Interact with your app to see inline assertions appear here...</div>\n </div>\n </div>\n </body>\n </html>\n `;\n }\n function setFullscreenFilter(newFilter) {\n setFilter(newFilter);\n if (panel) {\n panel.querySelector(\"#scenetest-filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n panel.querySelector(\"#scenetest-filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n panel.querySelector(\"#scenetest-pass\")?.classList.toggle(\"active\", filter === \"passes\");\n panel.querySelector(\"#scenetest-fail\")?.classList.toggle(\"active\", filter === \"fails\");\n }\n updatePanel();\n updateFullscreenWindow();\n }\n function setFullscreenViewMode(newMode) {\n setViewMode(newMode);\n updateFullscreenWindow();\n }\n function showSequence(locationKey) {\n setSequenceLocation(locationKey);\n updateFullscreenWindow();\n }\n var scrollToGroupId = null;\n function openFullscreen(groupId) {\n scrollToGroupId = groupId ?? null;\n if (fullscreenWindow && !fullscreenWindow.closed) {\n fullscreenWindow.focus();\n if (scrollToGroupId !== null) {\n scrollToGroup(scrollToGroupId);\n }\n return;\n }\n const win = window.open(\"\", \"scenetest-fullscreen\", \"width=900,height=700\");\n if (!win) {\n alert(\"Please allow popups for this site to use fullscreen mode.\");\n return;\n }\n setFullscreenWindow(win);\n win.document.write(getFullscreenHTML());\n win.document.close();\n const doc = win.document;\n doc.getElementById(\"scenetest-clear-full\")?.addEventListener(\"click\", () => {\n clearAll();\n updatePanel();\n updateFullscreenWindow();\n });\n doc.getElementById(\"filter-all\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"all\");\n });\n doc.getElementById(\"filter-fails\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"fails\");\n });\n doc.getElementById(\"filter-passes\")?.addEventListener(\"click\", () => {\n setFullscreenFilter(\"passes\");\n });\n doc.getElementById(\"view-grouped\")?.addEventListener(\"click\", () => {\n setFullscreenViewMode(\"grouped\");\n });\n doc.getElementById(\"view-byLocation\")?.addEventListener(\"click\", () => {\n setFullscreenViewMode(\"byLocation\");\n });\n updateFullscreenWindow();\n if (scrollToGroupId !== null) {\n scrollToGroup(scrollToGroupId);\n scrollToGroupId = null;\n }\n }\n function scrollToGroup(groupId) {\n if (!fullscreenWindow || fullscreenWindow.closed) return;\n const doc = fullscreenWindow.document;\n const groupEl = doc.querySelector(`[data-group-id=\"${groupId}\"]`);\n if (groupEl) {\n groupEl.classList.remove(\"collapsed\");\n groupEl.scrollIntoView({ behavior: \"auto\", block: \"start\" });\n const prevHighlighted = doc.querySelector(\".group.highlighted\");\n if (prevHighlighted) prevHighlighted.classList.remove(\"highlighted\");\n groupEl.classList.add(\"highlighted\");\n }\n }\n function openFullscreenToGroup(groupId) {\n openFullscreen(groupId);\n }\n function updateFullscreenWindow() {\n if (!fullscreenWindow || fullscreenWindow.closed) return;\n const doc = fullscreenWindow.document;\n const passCountEl = doc.getElementById(\"pass-count\");\n const failCountEl = doc.getElementById(\"fail-count\");\n if (passCountEl) passCountEl.textContent = `\\u2713 ${passCount}`;\n if (failCountEl) failCountEl.textContent = `\\u2717 ${failCount}`;\n doc.getElementById(\"filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n doc.getElementById(\"filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n doc.getElementById(\"filter-passes\")?.classList.toggle(\"active\", filter === \"passes\");\n const isSequenceView = viewMode === \"sequence\";\n doc.getElementById(\"view-grouped\")?.classList.toggle(\"active\", viewMode === \"grouped\");\n doc.getElementById(\"view-byLocation\")?.classList.toggle(\"active\", viewMode === \"byLocation\" || isSequenceView);\n const listEl2 = doc.getElementById(\"list\");\n if (!listEl2) return;\n if (viewMode === \"sequence\" && sequenceLocationKey) {\n renderSequenceView(doc, listEl2);\n return;\n }\n if (viewMode === \"byLocation\") {\n renderByLocationView(doc, listEl2);\n return;\n }\n renderGroupedView(doc, listEl2);\n }\n function renderGroupedView(doc, listEl2) {\n const scrollTop = doc.documentElement.scrollTop || doc.body.scrollTop;\n const isScrolled = scrollTop > 50;\n let anchorGroupId = null;\n let anchorOffset = 0;\n if (isScrolled) {\n const groupEls = listEl2.querySelectorAll(\"[data-group-id]\");\n for (const el of groupEls) {\n const rect = el.getBoundingClientRect();\n if (rect.top >= -rect.height / 2) {\n anchorGroupId = parseInt(el.getAttribute(\"data-group-id\") || \"\", 10);\n anchorOffset = rect.top;\n break;\n }\n }\n }\n const filteredGroups = groups.map((g) => ({\n ...g,\n items: filterItems(g.items)\n })).filter((g) => g.items.length > 0);\n if (filteredGroups.length === 0) {\n const icon = filter === \"fails\" ? \"\\u2713\" : \"\\u{1F3AC}\";\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Interact with your app to see inline assertions appear here...\";\n listEl2.innerHTML = `\n <div id=\"empty\">\n <div id=\"empty-icon\">${icon}</div>\n <div>${message}</div>\n </div>\n `;\n return;\n }\n listEl2.innerHTML = filteredGroups.map((g) => renderFullscreenGroup(g)).reverse().join(\"\");\n if (anchorGroupId !== null) {\n const anchorEl = listEl2.querySelector(`[data-group-id=\"${anchorGroupId}\"]`);\n if (anchorEl) {\n const newRect = anchorEl.getBoundingClientRect();\n const scrollAdjustment = newRect.top - anchorOffset;\n doc.documentElement.scrollTop += scrollAdjustment;\n doc.body.scrollTop += scrollAdjustment;\n }\n }\n }\n function renderByLocationView(_doc, listEl2) {\n const locations = Array.from(locationGroups.values()).sort((a, b) => b.lastTimestamp - a.lastTimestamp);\n let filteredLocations = locations;\n if (filter === \"fails\") {\n filteredLocations = locations.filter((loc) => loc.entries.some((e) => !e.result));\n } else if (filter === \"passes\") {\n filteredLocations = locations.filter((loc) => loc.entries.some((e) => e.result));\n }\n if (filteredLocations.length === 0) {\n const icon = filter === \"fails\" ? \"\\u2713\" : \"\\u{1F3AC}\";\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Interact with your app to see inline assertions appear here...\";\n listEl2.innerHTML = `\n <div id=\"empty\">\n <div id=\"empty-icon\">${icon}</div>\n <div>${message}</div>\n </div>\n `;\n return;\n }\n listEl2.innerHTML = `\n <div class=\"location-list\">\n ${filteredLocations.map((loc) => renderLocationRow(loc)).join(\"\")}\n </div>\n `;\n }\n function renderSequenceView(_doc, listEl2) {\n const group = locationGroups.get(sequenceLocationKey);\n if (!group) {\n setViewMode(\"byLocation\");\n renderByLocationView(_doc, listEl2);\n return;\n }\n let entries = group.entries;\n if (filter === \"fails\") {\n entries = entries.filter((e) => !e.result);\n } else if (filter === \"passes\") {\n entries = entries.filter((e) => e.result);\n }\n const backHandler = `window.opener ? window.opener.__scenetest_setViewMode && window.opener.__scenetest_setViewMode('byLocation') : window.__scenetest_setViewMode && window.__scenetest_setViewMode('byLocation')`;\n const reversedEntries = entries.slice().reverse();\n const entryCount = reversedEntries.length;\n listEl2.innerHTML = `\n <div class=\"back-btn\" onclick=\"${backHandler}\">\\u2190 Back to all locations</div>\n ${renderSequenceHeader(group)}\n <div class=\"sequence-direction-hint\">\n <span class=\"direction-arrow\">\\u2191</span> Most recent at top\n </div>\n <div class=\"sequence-list\">\n ${reversedEntries.map((entry, i) => {\n const isFirst = i === 0;\n const isLast = i === entryCount - 1;\n return renderSequenceEntry(entry, group.location, isFirst, isLast);\n }).join(\"\")}\n </div>\n ${entryCount > 1 ? '<div class=\"sequence-end-label\">First event</div>' : \"\"}\n `;\n }\n\n // src/dev-panel/panel.ts\n function getPanelHTML() {\n return `\n <style>${panelStyles}</style>\n <div id=\"scenetest-header\">\n <span id=\"scenetest-title\"><span class=\"scenetest-icon\"><span>\\u{1F3AC}</span></span>scenetest</span>\n <span id=\"scenetest-counts\">\n <span class=\"scenetest-count pass\" id=\"scenetest-pass\" title=\"Click to filter passes\">\\u2713 0</span>\n <span class=\"scenetest-count fail\" id=\"scenetest-fail\" title=\"Click to filter failures\">\\u2717 0</span>\n </span>\n </div>\n <div id=\"scenetest-actions\">\n <div class=\"scenetest-btn-group\">\n <button class=\"scenetest-btn active\" id=\"scenetest-filter-all\">all</button>\n <button class=\"scenetest-btn\" id=\"scenetest-filter-fails\">errors</button>\n </div>\n <span class=\"scenetest-separator\"></span>\n <button class=\"scenetest-btn\" id=\"scenetest-fullscreen\">fullscreen</button>\n <button class=\"scenetest-btn\" id=\"scenetest-clear\">clear</button>\n </div>\n <div id=\"scenetest-list\">\n <div id=\"scenetest-empty\">Click around to see inline assertions...</div>\n </div>\n `;\n }\n function handleSetFilter(newFilter) {\n setFilter(newFilter);\n panel?.querySelector(\"#scenetest-filter-all\")?.classList.toggle(\"active\", filter === \"all\");\n panel?.querySelector(\"#scenetest-filter-fails\")?.classList.toggle(\"active\", filter === \"fails\");\n panel?.querySelector(\"#scenetest-pass\")?.classList.toggle(\"active\", filter === \"passes\");\n panel?.querySelector(\"#scenetest-fail\")?.classList.toggle(\"active\", filter === \"fails\");\n updatePanel();\n updateFullscreenWindow();\n }\n function createPanel() {\n const panelEl = document.createElement(\"div\");\n panelEl.id = \"scenetest-panel\";\n panelEl.innerHTML = getPanelHTML();\n document.body.appendChild(panelEl);\n setPanel(panelEl);\n setListEl(panelEl.querySelector(\"#scenetest-list\"));\n panelEl.querySelector(\"#scenetest-header\")?.addEventListener(\"click\", (e) => {\n const target = e.target;\n if (target.classList.contains(\"scenetest-count\")) return;\n panelEl.classList.toggle(\"collapsed\");\n });\n panelEl.querySelector(\"#scenetest-pass\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(filter === \"passes\" ? \"all\" : \"passes\");\n });\n panelEl.querySelector(\"#scenetest-fail\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(filter === \"fails\" ? \"all\" : \"fails\");\n });\n panelEl.querySelector(\"#scenetest-filter-all\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(\"all\");\n });\n panelEl.querySelector(\"#scenetest-filter-fails\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n handleSetFilter(\"fails\");\n });\n panelEl.querySelector(\"#scenetest-clear\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n clearAll();\n updatePanel();\n updateFullscreenWindow();\n });\n panelEl.querySelector(\"#scenetest-fullscreen\")?.addEventListener(\"click\", (e) => {\n e.stopPropagation();\n openFullscreen();\n });\n }\n function updatePanel() {\n if (!panel || !listEl) return;\n const passEl = panel.querySelector(\"#scenetest-pass\");\n const failEl = panel.querySelector(\"#scenetest-fail\");\n if (passEl) passEl.textContent = `\\u2713 ${passCount}`;\n if (failEl) failEl.textContent = `\\u2717 ${failCount}`;\n const filteredGroups = groups.map((g) => ({\n ...g,\n items: filterItems(g.items)\n })).filter((g) => g.items.length > 0);\n if (filteredGroups.length === 0) {\n const message = filter === \"fails\" ? \"No errors! All assertions passed.\" : \"Click around to see inline assertions...\";\n listEl.innerHTML = `<div id=\"scenetest-empty\">${message}</div>`;\n return;\n }\n listEl.innerHTML = filteredGroups.map((g) => renderPanelGroup(g)).reverse().join(\"\");\n }\n\n // src/dev-panel/index.ts\n if (!window.__scenetest_panel) {\n let addToGroup = function(result) {\n const now = Date.now();\n if (pendingGroup && now - pendingGroup.timestamp < GROUP_THRESHOLD_MS) {\n pendingGroup.items.push(result);\n } else {\n const newGroup = {\n id: groups.length,\n timestamp: now,\n items: [result],\n collapsed: collapsedMode\n // Use collapsedMode to determine initial state\n };\n setPendingGroup(newGroup);\n groups.push(newGroup);\n }\n if (groupTimeout) clearTimeout(groupTimeout);\n setGroupTimeout(\n setTimeout(() => {\n setPendingGroup(null);\n updatePanel();\n updateFullscreenWindow();\n }, GROUP_THRESHOLD_MS)\n );\n };\n addToGroup2 = addToGroup;\n window.__scenetest_panel = true;\n window.__scenetest_openInEditor = openInEditor;\n window.__scenetest_openFullscreenToGroup = openFullscreenToGroup;\n window.__scenetest_showSequence = showSequence;\n window.__scenetest_setViewMode = (mode) => {\n setViewMode(mode);\n updateFullscreenWindow();\n };\n const existingReport = window.__scenetest_report;\n window.__scenetest_report = function(result) {\n if (existingReport) {\n try {\n existingReport(result);\n } catch {\n }\n }\n const index = assertions.length;\n result._index = index;\n assertions.push(result);\n trackAssertion(result, index);\n trackLocationGroup(result, index);\n if (result.result) {\n incrementPassCount();\n } else {\n incrementFailCount();\n }\n addToGroup(result);\n if (!panel && document.body) {\n createPanel();\n }\n updatePanel();\n updateFullscreenWindow();\n const icon = result.result ? \"\\u2713\" : \"\\u2717\";\n const style = result.result ? \"color: #4ade80\" : \"color: #f87171\";\n console.log(`%c${icon} [scenetest] ${result.description}`, style);\n };\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", createPanel);\n } else {\n createPanel();\n }\n }\n var addToGroup2;\n})();\n";
|
|
9
|
+
//# sourceMappingURL=dev-panel.generated.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dev-panel.generated.js","sourceRoot":"","sources":["../src/dev-panel.generated.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,CAAC,MAAM,cAAc,GAAG,qx9CAAqx9C,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Plugin } from 'vite';
|
|
2
|
+
export interface ScenetestPluginOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Whether to strip scenetest code in this build.
|
|
5
|
+
* Defaults to true in production builds, false otherwise.
|
|
6
|
+
*/
|
|
7
|
+
strip?: boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Show the dev panel UI for viewing assertions in real-time.
|
|
10
|
+
* Defaults to true in development mode.
|
|
11
|
+
*/
|
|
12
|
+
devPanel?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Vite plugin for Scenetest
|
|
16
|
+
*
|
|
17
|
+
* In development/test mode: transforms assertion() calls and serves serverFn via middleware
|
|
18
|
+
* In production mode: strips all scenetest imports and function calls via AST transform
|
|
19
|
+
*/
|
|
20
|
+
export declare function scenetest(options?: ScenetestPluginOptions): Plugin;
|
|
21
|
+
export { stripScenetest } from './strip.js';
|
|
22
|
+
export { defineScenetestConfig } from './config.js';
|
|
23
|
+
export { should, failed } from './middleware.js';
|
|
24
|
+
export default scenetest;
|
|
25
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAA;AAejD,MAAM,WAAW,sBAAsB;IACrC;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;IAEf;;;OAGG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,OAAO,GAAE,sBAA2B,GAAG,MAAM,CA2ItE;AAGD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAG3C,OAAO,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAA;AAGnD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAA;AAEhD,eAAe,SAAS,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { stripScenetest } from './strip.js';
|
|
2
|
+
import { devPanelScript } from './dev-panel.generated.js';
|
|
3
|
+
import { transformAssertions } from './transform.js';
|
|
4
|
+
import { registerAssertions, removeAssertionsForFile, clearRegistry, VIRTUAL_MODULE_ID, RESOLVED_VIRTUAL_MODULE_ID, generateVirtualModuleCode, } from './virtual-module.js';
|
|
5
|
+
import { clearConfigCache, isConfigFile } from './config.js';
|
|
6
|
+
import { createScenetestMiddleware } from './middleware.js';
|
|
7
|
+
/**
|
|
8
|
+
* Vite plugin for Scenetest
|
|
9
|
+
*
|
|
10
|
+
* In development/test mode: transforms assertion() calls and serves serverFn via middleware
|
|
11
|
+
* In production mode: strips all scenetest imports and function calls via AST transform
|
|
12
|
+
*/
|
|
13
|
+
export function scenetest(options = {}) {
|
|
14
|
+
let shouldStrip = false;
|
|
15
|
+
let showDevPanel = false;
|
|
16
|
+
let mode = 'development';
|
|
17
|
+
let root = process.cwd();
|
|
18
|
+
let server;
|
|
19
|
+
return {
|
|
20
|
+
name: 'vite-plugin-scenetest',
|
|
21
|
+
config(_config, env) {
|
|
22
|
+
mode = env.mode;
|
|
23
|
+
// Default: strip in production, keep in dev/test
|
|
24
|
+
shouldStrip = options.strip ?? env.mode === 'production';
|
|
25
|
+
// Default: show dev panel in development mode
|
|
26
|
+
showDevPanel = options.devPanel ?? env.mode === 'development';
|
|
27
|
+
},
|
|
28
|
+
configResolved(config) {
|
|
29
|
+
root = config.root;
|
|
30
|
+
},
|
|
31
|
+
configureServer(devServer) {
|
|
32
|
+
server = devServer;
|
|
33
|
+
// Install the scenetest middleware for handling RPC requests
|
|
34
|
+
server.middlewares.use(createScenetestMiddleware(server, root));
|
|
35
|
+
},
|
|
36
|
+
resolveId(id) {
|
|
37
|
+
if (id === VIRTUAL_MODULE_ID) {
|
|
38
|
+
return RESOLVED_VIRTUAL_MODULE_ID;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
},
|
|
42
|
+
load(id) {
|
|
43
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
44
|
+
return generateVirtualModuleCode();
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
},
|
|
48
|
+
transform(code, id) {
|
|
49
|
+
// Only process JS/TS files
|
|
50
|
+
if (!/\.(js|mjs|cjs|ts|mts|cts|jsx|tsx)$/.test(id)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Skip node_modules
|
|
54
|
+
if (id.includes('node_modules')) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
// Quick check - skip if no scenetest
|
|
58
|
+
if (!code.includes('scenetest')) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (shouldStrip) {
|
|
62
|
+
// Production mode: strip scenetest code via AST transform
|
|
63
|
+
const result = stripScenetest(code, {
|
|
64
|
+
filename: id,
|
|
65
|
+
sourceMap: true,
|
|
66
|
+
});
|
|
67
|
+
if (!result) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
code: result.code,
|
|
72
|
+
map: result.map,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Dev mode: transform assertion() calls
|
|
76
|
+
const transformResult = transformAssertions(code, {
|
|
77
|
+
filename: id,
|
|
78
|
+
sourceMap: true,
|
|
79
|
+
});
|
|
80
|
+
if (transformResult) {
|
|
81
|
+
// Register extracted assertions for the virtual module
|
|
82
|
+
registerAssertions(transformResult.extractedAssertions);
|
|
83
|
+
// Invalidate the virtual module so it regenerates with new assertions
|
|
84
|
+
if (server) {
|
|
85
|
+
const virtualMod = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
|
|
86
|
+
if (virtualMod) {
|
|
87
|
+
server.moduleGraph.invalidateModule(virtualMod);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
code: transformResult.code,
|
|
92
|
+
map: transformResult.map,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
},
|
|
97
|
+
transformIndexHtml(html) {
|
|
98
|
+
if (!showDevPanel) {
|
|
99
|
+
return html;
|
|
100
|
+
}
|
|
101
|
+
// Inject the dev panel script before </body>
|
|
102
|
+
const script = `<script>${devPanelScript}</script>`;
|
|
103
|
+
return html.replace('</body>', `${script}</body>`);
|
|
104
|
+
},
|
|
105
|
+
buildStart() {
|
|
106
|
+
// Clear registries on build start
|
|
107
|
+
clearRegistry();
|
|
108
|
+
clearConfigCache();
|
|
109
|
+
if (shouldStrip) {
|
|
110
|
+
console.log('[vite-plugin-scenetest] Production build - stripping scenetest code');
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
console.log(`[vite-plugin-scenetest] ${mode} mode - scenetest assertions active`);
|
|
114
|
+
if (showDevPanel) {
|
|
115
|
+
console.log('[vite-plugin-scenetest] Dev panel enabled - open your app to see assertions');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
handleHotUpdate({ file }) {
|
|
120
|
+
// Clear config cache if config file changed
|
|
121
|
+
if (isConfigFile(file, root)) {
|
|
122
|
+
clearConfigCache();
|
|
123
|
+
console.log('[vite-plugin-scenetest] Config file changed - reloading');
|
|
124
|
+
}
|
|
125
|
+
// Remove assertions from the file being updated (they'll be re-registered on transform)
|
|
126
|
+
removeAssertionsForFile(file);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Re-export strip function for testing
|
|
131
|
+
export { stripScenetest } from './strip.js';
|
|
132
|
+
// Re-export config helper for user config files
|
|
133
|
+
export { defineScenetestConfig } from './config.js';
|
|
134
|
+
// Re-export server-side should/failed for use in scenetest.config.ts
|
|
135
|
+
export { should, failed } from './middleware.js';
|
|
136
|
+
export default scenetest;
|
|
137
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Connect, ViteDevServer } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Server-side should() function that collects results in AsyncLocalStorage
|
|
4
|
+
*/
|
|
5
|
+
export declare function should(description: string, condition: boolean, context?: Record<string, unknown>): void;
|
|
6
|
+
/**
|
|
7
|
+
* Server-side failed() function - past-tense failure marker
|
|
8
|
+
*/
|
|
9
|
+
export declare function failed(description: string, context?: Record<string, unknown>): void;
|
|
10
|
+
/**
|
|
11
|
+
* Create the scenetest middleware for handling RPC requests
|
|
12
|
+
*/
|
|
13
|
+
export declare function createScenetestMiddleware(server: ViteDevServer, root: string): Connect.NextHandleFunction;
|
|
14
|
+
//# sourceMappingURL=middleware.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.d.ts","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,MAAM,MAAM,CAAA;AAWlD;;GAEG;AACH,wBAAgB,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWvG;AAED;;GAEG;AACH,wBAAgB,MAAM,CAAC,WAAW,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWnF;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,CA2FzG"}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
2
|
+
import { RESOLVED_VIRTUAL_MODULE_ID } from './virtual-module.js';
|
|
3
|
+
import { loadConfig } from './config.js';
|
|
4
|
+
/**
|
|
5
|
+
* AsyncLocalStorage for collecting assertion results within a serverFn execution
|
|
6
|
+
*/
|
|
7
|
+
const assertionStorage = new AsyncLocalStorage();
|
|
8
|
+
/**
|
|
9
|
+
* Server-side should() function that collects results in AsyncLocalStorage
|
|
10
|
+
*/
|
|
11
|
+
export function should(description, condition, context) {
|
|
12
|
+
const results = assertionStorage.getStore();
|
|
13
|
+
if (results) {
|
|
14
|
+
results.push({
|
|
15
|
+
type: condition ? 'pass' : 'fail',
|
|
16
|
+
description,
|
|
17
|
+
result: condition,
|
|
18
|
+
timestamp: Date.now(),
|
|
19
|
+
context,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Server-side failed() function - past-tense failure marker
|
|
25
|
+
*/
|
|
26
|
+
export function failed(description, context) {
|
|
27
|
+
const results = assertionStorage.getStore();
|
|
28
|
+
if (results) {
|
|
29
|
+
results.push({
|
|
30
|
+
type: 'fail',
|
|
31
|
+
description,
|
|
32
|
+
result: false,
|
|
33
|
+
timestamp: Date.now(),
|
|
34
|
+
context,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create the scenetest middleware for handling RPC requests
|
|
40
|
+
*/
|
|
41
|
+
export function createScenetestMiddleware(server, root) {
|
|
42
|
+
return async (req, res, next) => {
|
|
43
|
+
// Only handle POST /__scenetest/run
|
|
44
|
+
if (req.method !== 'POST' || req.url !== '/__scenetest/run') {
|
|
45
|
+
return next();
|
|
46
|
+
}
|
|
47
|
+
// Parse request body
|
|
48
|
+
let body = '';
|
|
49
|
+
for await (const chunk of req) {
|
|
50
|
+
body += chunk;
|
|
51
|
+
}
|
|
52
|
+
let payload;
|
|
53
|
+
try {
|
|
54
|
+
payload = JSON.parse(body);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
res.statusCode = 400;
|
|
58
|
+
res.setHeader('Content-Type', 'application/json');
|
|
59
|
+
res.end(JSON.stringify({ success: false, results: [], error: 'Invalid JSON payload' }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { id, title, data } = payload;
|
|
63
|
+
try {
|
|
64
|
+
// Load the virtual module containing all serverFns
|
|
65
|
+
const virtualModule = await server.ssrLoadModule(RESOLVED_VIRTUAL_MODULE_ID);
|
|
66
|
+
const assertions = virtualModule.assertions;
|
|
67
|
+
// Get the serverFn for this ID
|
|
68
|
+
const serverFn = assertions[id];
|
|
69
|
+
if (!serverFn) {
|
|
70
|
+
const response = {
|
|
71
|
+
success: false,
|
|
72
|
+
results: [],
|
|
73
|
+
error: `No serverFn found for id: ${id}`,
|
|
74
|
+
};
|
|
75
|
+
res.statusCode = 200;
|
|
76
|
+
res.setHeader('Content-Type', 'application/json');
|
|
77
|
+
res.end(JSON.stringify(response));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Load config to get server functions
|
|
81
|
+
const config = await loadConfig(root, (id) => server.ssrLoadModule(id));
|
|
82
|
+
const serverContext = (config.serverFunctions || {});
|
|
83
|
+
// Execute serverFn with AsyncLocalStorage for result collection
|
|
84
|
+
const results = [];
|
|
85
|
+
await assertionStorage.run(results, async () => {
|
|
86
|
+
try {
|
|
87
|
+
// Pass the should/failed helpers directly to the serverFn
|
|
88
|
+
await serverFn(serverContext, data, { should, failed });
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
results.push({
|
|
92
|
+
type: 'fail',
|
|
93
|
+
description: `${title}: serverFn threw an error`,
|
|
94
|
+
result: false,
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
context: { error: err instanceof Error ? err.message : String(err) },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
const response = {
|
|
101
|
+
success: true,
|
|
102
|
+
results,
|
|
103
|
+
};
|
|
104
|
+
res.statusCode = 200;
|
|
105
|
+
res.setHeader('Content-Type', 'application/json');
|
|
106
|
+
res.end(JSON.stringify(response));
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
console.error('[vite-plugin-scenetest] Middleware error:', err);
|
|
110
|
+
const response = {
|
|
111
|
+
success: false,
|
|
112
|
+
results: [],
|
|
113
|
+
error: err instanceof Error ? err.message : String(err),
|
|
114
|
+
};
|
|
115
|
+
res.statusCode = 500;
|
|
116
|
+
res.setHeader('Content-Type', 'application/json');
|
|
117
|
+
res.end(JSON.stringify(response));
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
//# sourceMappingURL=middleware.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"middleware.js","sourceRoot":"","sources":["../src/middleware.ts"],"names":[],"mappings":"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"}
|
package/dist/strip.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import MagicString from 'magic-string';
|
|
2
|
+
export interface StripResult {
|
|
3
|
+
code: string;
|
|
4
|
+
map: ReturnType<MagicString['generateMap']> | null;
|
|
5
|
+
}
|
|
6
|
+
export interface StripOptions {
|
|
7
|
+
/** Generate source map (default: true) */
|
|
8
|
+
sourceMap?: boolean;
|
|
9
|
+
/** File path for source map */
|
|
10
|
+
filename?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Strip all scenetest imports and function calls from source code.
|
|
14
|
+
*
|
|
15
|
+
* This function:
|
|
16
|
+
* 1. Parses the code with Babel (handles JS, TS, JSX)
|
|
17
|
+
* 2. Finds and tracks all imports from scenetest packages
|
|
18
|
+
* 3. Removes those import statements
|
|
19
|
+
* 4. Removes all calls to the imported functions
|
|
20
|
+
*
|
|
21
|
+
* @param code Source code to transform
|
|
22
|
+
* @param options Transform options
|
|
23
|
+
* @returns Transformed code and optional source map
|
|
24
|
+
*/
|
|
25
|
+
export declare function stripScenetest(code: string, options?: StripOptions): StripResult | null;
|
|
26
|
+
//# sourceMappingURL=strip.d.ts.map
|