12/**3 * Helper module for dynamic import shim generation.4 * Called by desktop-plugin-smoke.mjs to avoid template literal escaping issues.5 */6import { readFileSync } from 'node:fs'7import { dirname, resolve } from 'node:path'8import { fileURLToPath } from 'node:url'910const __dirname = dirname(fileURLToPath(import.meta.url))11const pluginPath = resolve(__dirname, '../desktop/plugin.js')12const src = readFileSync(pluginPath, 'utf8')1314// Shim @hermes/plugin-sdk15const sdkExports = [16 'cn', 'Codicon', 'useQuery',17 'Loader', 'EmptyState', 'ErrorState', 'Badge', 'Button',18 'CopyButton', 'Dialog', 'DialogContent', 'Input', 'ScrollArea', 'SearchField',19 'Select', 'SelectContent', 'SelectItem', 'SelectTrigger', 'SelectValue',20 'SegmentedControl', 'Tabs', 'TabsList', 'TabsTrigger'21].map(n => 'export const ' + n + '=(...a)=>a').join(';')22// ROUTES_AREA and SIDEBAR_NAV_AREA are string constants, not functions23const sdkShim = sdkExports24 + ';export const ROUTES_AREA="routes"'25 + ';export const SIDEBAR_NAV_AREA="sidebar.nav"'26 + ';export default {}'2728// Shim react29const reactShim = 'export function useState(v){return[v,function(){},function(){},v]};'30 + 'export function useEffect(f,d){return undefined};'31 + 'export function useRef(v){return{current:v}};'32 + 'export function useCallback(f,d){return f};'33 + 'export function useMemo(f,d){return f()};'34 + 'export function useContext(c){return undefined};'35 + 'export default {}'3637// Shim react/jsx-runtime38const jsxShim = 'export function jsx(c,p){return{component:c,props:p,_t:"jsx"}}'39 + 'export function jsxs(c,p){return{component:c,props:p,_t:"jsxs"}}'40 + 'export function Fragment(p){return p&&p.children}'4142const sdkUrl = 'data:text/javascript,' + encodeURIComponent(sdkShim)43const reactUrl = 'data:text/javascript,' + encodeURIComponent(reactShim)44const jsxUrl = 'data:text/javascript,' + encodeURIComponent(jsxShim)4546const blobSrc = src47 .replace(/from\s+['"]@hermes\/plugin-sdk['"]/g, "from '" + sdkUrl + "'")48 .replace(/from\s+['"]react\/jsx-runtime['"]/g, "from '" + jsxUrl + "'")49 .replace(/from\s+['"]react['"]/g, "from '" + reactUrl + "'")5051const blobUrl = 'data:text/javascript,' + encodeURIComponent(blobSrc)52const mod = await import(blobUrl)53const plugin = mod.default54const __test = mod.__test5556// Simulate register()57const contribs = []58let restCalls = []59const mockCtx = {60 register(c) { contribs.push(c) },61 registerMany(arr) { for (const c of arr) contribs.push(c) },62 rest(path, opts) { restCalls.push({ path: path, opts: opts || null }); return Promise.resolve({}) }63}6465try {66 plugin.register(mockCtx)67} catch(e) {68 // register may fail if __test not available69}7071// Export results as JSON72const routeContrib = contribs.find(c => c.area === 'routes')73let renderResult = null74if (routeContrib && typeof routeContrib.render === 'function') {75 try {76 const rendered = routeContrib.render()77 renderResult = { returned: rendered != null, isJsx: rendered && (rendered._t === 'jsx' || rendered._t === 'jsxs') }78 } catch(e) {79 renderResult = { error: e.message }80 }81}82const results = {83 id: plugin && plugin.id,84 name: plugin && plugin.name,85 hasRegister: typeof (plugin && plugin.register) === 'function',86 contribCount: contribs.length,87 routeContrib: routeContrib,88 routeContribHasRender: routeContrib && typeof routeContrib.render === 'function',89 renderResult: renderResult,90 navContrib: contribs.find(c => c.area === 'sidebar.nav'),91 restCalls: restCalls,92 __testKeys: __test ? Object.keys(__test) : [],93 __testAvailable: !!__test,94}9596// Test __test functions if available97if (__test) {98 try {99 results.pathBuilders = {}100101 if (__test.sourcesPath) results.pathBuilders.sources = __test.sourcesPath()102 if (__test.changePath) {103 results.pathBuilders.change = __test.changePath('src-1', 'my-change')104 results.pathBuilders.changeSpecial = __test.changePath('src-1', 'feat/with spaces & special')105 }106 if (__test.ideaPath) {107 results.pathBuilders.idea = __test.ideaPath('src-1', 'my-idea')108 results.pathBuilders.ideaSpecial = __test.ideaPath('src-1', 'feat/with spaces & special')109 }110 if (__test.specBrowserPath) {111 results.pathBuilders.specBrowser = __test.specBrowserPath('src-1')112 results.pathBuilders.specBrowserDirty = __test.specBrowserPath('src-1', { dirty: true })113 }114 if (__test.specPath) {115 results.pathBuilders.spec = __test.specPath('src-1', 'specs/readme.md')116 results.pathBuilders.specSpecial = __test.specPath('src-1', 'specs/with spaces.md')117 }118119 // Source selection120 results.sourceSelection = {}121 if (__test.selectFirstValid) {122 const sources = [123 { id: 'src-1', valid: false, error: 'bad' },124 { id: 'src-2', valid: true, name: 'good' },125 { id: 'src-3', valid: true, name: 'also-good' }126 ]127 results.sourceSelection.firstValid = __test.selectFirstValid(sources)128 const allInvalid = [129 { id: 'src-1', valid: false, error: 'bad' },130 { id: 'src-2', valid: false, error: 'worse' }131 ]132 results.sourceSelection.allInvalid = __test.selectFirstValid(allInvalid)133 results.sourceSelection.noSources = __test.selectFirstValid([])134 }135136 // Board helpers137 results.board = {}138 if (__test.normalizeStatus) {139 results.board.normalizeInProgress = __test.normalizeStatus('in-progress')140 results.board.normalizeDraft = __test.normalizeStatus('draft')141 results.board.normalizeArchived = __test.normalizeStatus('archived')142 results.board.normalizeIdeas = __test.normalizeStatus('ideas')143 results.board.normalizeUnknown = __test.normalizeStatus('something-new')144 }145 if (__test.groupByStatus) {146 const items = [147 { name: 'a', status: 'draft', token: 'A', sequence: 2 },148 { name: 'b', status: 'in-progress', token: 'B', sequence: 1 },149 { name: 'c', status: 'done', token: 'C', sequence: 3 },150 { name: 'd', status: 'archived', token: 'D', sequence: 0 },151 { name: 'idea', status: 'ideas', token: 'IDEA', sequence: 4 },152 ]153 const groups = __test.groupByStatus(items, false)154 results.board.groupKeys = Object.keys(groups)155 results.board.groupCounts = {}156 for (const k in groups) results.board.groupCounts[k] = groups[k].length157 }158 if (__test.sortItems) {159 const sortItems = [160 { name: 'b', sequence: 2, title: 'Alpha' },161 { name: 'a', sequence: 1, title: 'Beta' },162 { name: 'c', sequence: 1, title: 'Alpha' },163 ]164 results.board.sorted = __test.sortItems(sortItems).map(i => i.name)165 }166 if (__test.filterItems) {167 const filterItems = [168 { name: 'alpha-change', title: 'Alpha Feature', token: 'ALPHA' },169 { name: 'beta-fix', title: 'Beta Bugfix', token: 'BETA' },170 { name: 'gamma-refactor', title: 'Gamma Refactor', token: 'GAMMA' },171 ]172 results.board.filteredAlpha = __test.filterItems(filterItems, 'alpha').map(i => i.name)173 results.board.filteredBeta = __test.filterItems(filterItems, 'BETA').map(i => i.name)174 results.board.filteredEmpty = __test.filterItems(filterItems, 'delta').map(i => i.name)175 results.board.filteredClear = __test.filterItems(filterItems, '').map(i => i.name)176 }177 if (__test.toggleArchived) {178 results.board.withArchived = __test.toggleArchived(true)179 results.board.withoutArchived = __test.toggleArchived(false)180 }181182 // in_progress (underscore) grouping — backend emits this spelling183 if (__test.groupByStatus && __test.normalizeStatus) {184 var inProgressItems = [185 { name: 'item-a', status: 'in_progress', token: 'A' },186 { name: 'item-b', status: 'in-progress', token: 'B' },187 { name: 'item-c', status: 'inprogress', token: 'C' },188 ]189 var ipGroups = __test.groupByStatus(inProgressItems, false)190 results.board.inProgressGroupCount = (ipGroups['in-progress'] || []).length191 results.board.inProgressNormalized = __test.normalizeStatus('in_progress')192 }193194 // Task parser195 results.tasks = {}196 if (__test.parseTasks) {197 const taskContent = '## Section 1\n- [x] Done task\n- [ ] Pending task\n- [x] Another done\n\n## Section 2\n- [ ] Open task\n'198 const parsed = __test.parseTasks(taskContent)199 results.tasks.total = parsed.total200 results.tasks.done = parsed.done201 results.tasks.sections = parsed.sections202 }203204 // Markdown parser205 results.markdown = {}206 if (__test.renderMarkdown) {207 const md = '# Heading\n\nA paragraph with **bold** and `code`.\n\n- item 1\n- item 2'208 const rendered = __test.renderMarkdown(md)209 results.markdown.isArray = Array.isArray(rendered)210 results.markdown.length = rendered && rendered.length211 }212213 // State classifier214 if (__test.classifyState) {215 results.markdown.stateLoading = __test.classifyState({ isLoading: true, error: null, data: null })216 results.markdown.stateError = __test.classifyState({ isLoading: false, error: new Error('fail'), data: null })217 results.markdown.stateEmpty = __test.classifyState({ isLoading: false, error: null, data: { items: [] } })218 results.markdown.stateContent = __test.classifyState({ isLoading: false, error: null, data: { items: [1] } })219 }220221 // Diff helpers222 results.diff = {}223 if (__test.classifyDiffLine) {224 results.diff.added = __test.classifyDiffLine('+added line')225 results.diff.removed = __test.classifyDiffLine('-removed line')226 results.diff.hunk = __test.classifyDiffLine('@@ -1,3 +1,4 @@')227 results.diff.context = __test.classifyDiffLine(' unchanged line')228 }229 if (__test.hasDiffMode) {230 results.diff.hasSemantic = __test.hasDiffMode({ semantic_diff: { groups: [] } }, 'semantic')231 results.diff.hasSplit = __test.hasDiffMode({ before: 'a', after: 'b' }, 'split')232 results.diff.hasRaw = __test.hasDiffMode({ diff: '+line' }, 'raw')233 results.diff.noSemantic = __test.hasDiffMode({ before: 'a', after: 'b' }, 'semantic')234 }235236 // --- Behavioral fixture tests: backend contract alignment ---237238 // 1. Semantic groups normalization — exact backend shape from spec_parser.py239 results.semanticGroups = {}240 if (__test.normalizeSemanticGroups) {241 // Backend returns {status, requirements: {added, modified, removed, unchanged}}242 var backendDiff = {243 status: 'modified',244 requirements: {245 added: [{ name: 'Auth', description: 'User auth via OAuth', scenarios: [] }],246 modified: [{247 name: 'Data Model',248 before: { description: 'Old schema with 3 fields', scenarios: [] },249 after: { description: 'New schema with 5 fields', scenarios: [] },250 scenarios_added: [],251 scenarios_modified: [],252 scenarios_removed: [],253 }],254 removed: [{ name: 'Legacy', description: 'Deprecated endpoint', scenarios: [] }],255 unchanged: ['Logging'],256 }257 }258 var groups = __test.normalizeSemanticGroups(backendDiff)259 results.semanticGroups.isArray = Array.isArray(groups)260 results.semanticGroups.length = groups.length261 // Verify labels262 results.semanticGroups.addedLabel = groups[0] && groups[0].label263 results.semanticGroups.modifiedLabel = groups[1] && groups[1].label264 results.semanticGroups.removedLabel = groups[2] && groups[2].label265 // Verify no [object Object] in descriptions266 results.semanticGroups.noObjectString = groups.every(function(g) {267 return !String(g.description || '').includes('[object Object]')268 && !String(g.before || '').includes('[object Object]')269 && !String(g.after || '').includes('[object Object]')270 })271 // Verify names preserved272 results.semanticGroups.addedName = groups[0] && groups[0].name273 results.semanticGroups.modifiedName = groups[1] && groups[1].name274 results.semanticGroups.removedName = groups[2] && groups[2].name275 // Verify before/after extracted from object-valued fields276 results.semanticGroups.beforeDesc = groups[1] && groups[1].before277 results.semanticGroups.afterDesc = groups[1] && groups[1].after278 // Verify crash: old code did data.requirements.map() which throws279 results.semanticGroups.noCrash = true280 try {281 // This is what the old code did — should have thrown282 backendDiff.requirements.map(function() {})283 results.semanticGroups.oldCodeWouldThrow = false284 } catch (e) {285 results.semanticGroups.oldCodeWouldThrow = true286 }287 }288289 // 2. Card artifacts from has* booleans — exact shape from plugin_api.py290 results.cardArtifacts = {}291 if (__test.hasDiffMode) {292 // Backend source summary shape from plugin_api.py:218-241293 var changeSummary = {294 id: 'src-1/my-change',295 token: 'my-change',296 name: 'my-change',297 title: 'My Change',298 status: 'in-progress',299 hasProposal: true,300 hasTasks: true,301 hasDesign: false,302 hasSpecs: true,303 taskStats: { total: 5, done: 2 },304 }305 // Test that has* booleans are the source of truth (not artifacts/artifactNames)306 results.cardArtifacts.hasProposal = changeSummary.hasProposal === true307 results.cardArtifacts.hasTasks = changeSummary.hasTasks === true308 results.cardArtifacts.hasDesign = changeSummary.hasDesign === false309 results.cardArtifacts.hasSpecs = changeSummary.hasSpecs === true310 results.cardArtifacts.noArtifactsField = changeSummary.artifacts === undefined311 results.cardArtifacts.noArtifactNamesField = changeSummary.artifactNames === undefined312 }313314 // 3. effectiveDiffMode fallback315 results.effectiveDiffMode = {}316 if (__test.effectiveDiffMode) {317 // When desired mode is available, return it318 results.effectiveDiffMode.semanticAvailable = __test.effectiveDiffMode('semantic', { semantic_diff: { requirements: {} } })319 // When semantic unavailable but split available, fallback to split320 results.effectiveDiffMode.fallbackToSplit = __test.effectiveDiffMode('semantic', { before: 'a', after: 'b' })321 // When only raw available, fallback to raw322 results.effectiveDiffMode.fallbackToRaw = __test.effectiveDiffMode('semantic', { diff: '+line' })323 // When nothing available, return desired (no crash)324 results.effectiveDiffMode.nothingAvailable = __test.effectiveDiffMode('semantic', {})325 // Split desired but only raw available326 results.effectiveDiffMode.splitToRaw = __test.effectiveDiffMode('split', { diff: '+line' })327 }328329 // RetryErrorState wrapper — verify structure via __test330 results.retryErrorState = null331 if (__test.RetryErrorState) {332 // Call RetryErrorState with a test callback and title/description333 var testCallback = function() { return 'retried' }334 var descriptor = __test.RetryErrorState({ title: 'Error', description: 'fail', onRetry: testCallback })335 if (descriptor && descriptor._t) {336 var res = { rootIsErrorState: false, hasButtonChild: false, buttonOnClickIsFunction: false, buttonLabel: null, onClickInvokesCallback: false }337 // Root should be ErrorState — the shim stores the function reference338 // (not the string 'ErrorState'), so verify it's a function339 res.rootIsErrorState = typeof descriptor.component === 'function'340 // Children should contain a Button341 var children = descriptor.props && descriptor.props.children342 if (children) {343 var childArr = Array.isArray(children) ? children : [children]344 for (var ci = 0; ci < childArr.length; ci++) {345 var child = childArr[ci]346 if (child && child._t && child.component && typeof child.component === 'function') {347 res.hasButtonChild = true348 res.buttonLabel = child.props && child.props.children349 var onClick = child.props && child.props.onClick350 res.buttonOnClickIsFunction = typeof onClick === 'function'351 if (res.buttonOnClickIsFunction) {352 var result = onClick()353 res.onClickInvokesCallback = result === 'retried'354 }355 break356 }357 }358 }359 results.retryErrorState = res360 }361 }362363 // Lane layout helpers364 results.laneLayout = {}365 if (__test.autoCollapseEmptyLanes) {366 var groupedMixed = { 'ideas': [], 'draft': [{ name: 'a' }], 'todo': [{ name: 'b' }], 'in-progress': [], 'done': [], 'archived': [] }367 var collapsed = __test.autoCollapseEmptyLanes(groupedMixed, ['ideas', 'draft', 'todo', 'in-progress', 'done', 'archived'])368 results.laneLayout.collapseEmptyWhenWork = !!collapsed['ideas']369 results.laneLayout.expandNonEmpty = !collapsed['draft']370 results.laneLayout.expandNonEmptyTodo = !collapsed['todo']371 results.laneLayout.collapseInProgress = !!collapsed['in-progress']372 results.laneLayout.collapseDone = !!collapsed['done']373374 // Board with no work: nothing collapses375 var allEmpty = { 'ideas': [], 'draft': [], 'todo': [], 'in-progress': [], 'done': [], 'archived': [] }376 var noCollapse = __test.autoCollapseEmptyLanes(allEmpty, ['ideas', 'draft', 'todo', 'in-progress', 'done', 'archived'])377 var anyCollapsed = false378 for (var k in noCollapse) { if (noCollapse[k]) anyCollapsed = true }379 results.laneLayout.noWorkNoCollapse = !anyCollapsed380 }381 if (__test.laneWidth) {382 results.laneLayout.expandedWidth = __test.laneWidth('draft', {})383 results.laneLayout.collapsedWidth = __test.laneWidth('ideas', { 'ideas': true })384 results.laneLayout.noOverridesExpanded = __test.laneWidth('draft', { 'ideas': true })385 }386 if (__test.LANE_EXPANDED_WIDTH) {387 results.laneLayout.expandedIs256 = __test.LANE_EXPANDED_WIDTH === 256388 }389 if (__test.LANE_COLLAPSED_WIDTH) {390 results.laneLayout.collapsedIs32 = __test.LANE_COLLAPSED_WIDTH === 32391 }392 if (__test.railStyle) {393 var rs = __test.railStyle('Ideas')394 results.laneLayout.railWidth = rs && rs.width395 results.laneLayout.railFlexShrink = rs && rs.flexShrink396 results.laneLayout.railCursor = rs && rs.cursor397 }398 if (__test.expandedStyle) {399 var es = __test.expandedStyle()400 results.laneLayout.expandedWidthPx = es && es.width401 results.laneLayout.expandedFlexShrink = es && es.flexShrink402 results.laneLayout.expandedOverflow = es && es.overflow403 results.laneLayout.expandedHasBg = es && es.background && es.background.includes('color-mix')404 results.laneLayout.expandedHasRounded = es && es.borderRadius === '8px'405 results.laneLayout.expandedHasPadding = es && es.padding === '8px'406 }407408 // STATUS_TONE mapping409 results.statusTone = {}410 if (__test.STATUS_TONE && __test.statusTone) {411 results.statusTone.ideas = __test.statusTone('ideas')412 results.statusTone.draft = __test.statusTone('draft')413 results.statusTone.todo = __test.statusTone('todo')414 results.statusTone.inProgress = __test.statusTone('in-progress')415 results.statusTone.done = __test.statusTone('done')416 results.statusTone.archived = __test.statusTone('archived')417 results.statusTone.unknown = __test.statusTone('unknown-status')418 results.statusTone.ideasIsVar = __test.STATUS_TONE['ideas'] === 'var(--ui-text-tertiary)'419 results.statusTone.draftIsPurple = __test.STATUS_TONE['draft'] === '#a78bfa'420 results.statusTone.todoIsVar = __test.STATUS_TONE['todo'] === 'var(--ui-text-secondary)'421 results.statusTone.inProgressIsGreen = __test.STATUS_TONE['in-progress'] === '#34d399'422 results.statusTone.doneIsVar = __test.STATUS_TONE['done'] === 'var(--ui-text-tertiary)'423 results.statusTone.archivedIsVar = __test.STATUS_TONE['archived'] === 'var(--ui-text-quaternary)'424 }425426 // URL safety427 results.urlSafety = {}428 if (__test.isSafeUrl) {429 results.urlSafety.httpsAllowed = __test.isSafeUrl('https://example.com')430 results.urlSafety.httpAllowed = __test.isSafeUrl('http://example.com')431 results.urlSafety.relativeAllowed = __test.isSafeUrl('/path/to/page')432 results.urlSafety.fragmentAllowed = __test.isSafeUrl('#section')433 results.urlSafety.queryAllowed = __test.isSafeUrl('?key=val')434 results.urlSafety.mailtoAllowed = __test.isSafeUrl('mailto:user@example.com')435 results.urlSafety.javascriptBlocked = !__test.isSafeUrl('javascript:alert(1)')436 results.urlSafety.dataBlocked = !__test.isSafeUrl('data:text/html,<script>')437 results.urlSafety.fileBlocked = !__test.isSafeUrl('file:///etc/passwd')438 results.urlSafety.vbscriptBlocked = !__test.isSafeUrl('vbscript:MsgBox(1)')439 results.urlSafety.emptyBlocked = !__test.isSafeUrl('')440 results.urlSafety.nullBlocked = !__test.isSafeUrl(null)441 // Whitespace/control obfuscation tests442 results.urlSafety.wsJavascriptBlocked = !__test.isSafeUrl(' javascript:alert(1)')443 results.urlSafety.tabJavascriptBlocked = !__test.isSafeUrl('\tjavascript:alert(1)')444 results.urlSafety.newlineJavascriptBlocked = !__test.isSafeUrl('\njavascript:alert(1)')445 results.urlSafety.controlJavascriptBlocked = !__test.isSafeUrl('\x00javascript:alert(1)')446 results.urlSafety.uppercaseJavascriptBlocked = !__test.isSafeUrl('JAVASCRIPT:alert(1)')447 results.urlSafety.mixedCaseDataBlocked = !__test.isSafeUrl('DaTa:text/html,<script>')448 }449450 // __test is frozen451 results.testFrozen = Object.isFrozen(__test)452453 // renderInline returns JSX (not plain objects)454 results.inlineJsx = {}455 if (__test.renderMarkdown && __test.MarkdownElement && __test.renderInline) {456 // Parse markdown into blocks457 var inlineResult = __test.renderMarkdown('Text with **bold** and `code` and [link](https://example.com)')458 results.inlineJsx.isArray = Array.isArray(inlineResult)459 results.inlineJsx.hasParagraph = inlineResult.some(function(el) { return el && el.type === 'paragraph' })460461 // EXECUTABLE RENDER CHECK: invoke MarkdownElement on each parsed block462 // and verify it returns JSX descriptors (with _t property from jsx shim), not raw parser objects463 var allBlocksAreJsx = true464 var anyBlockFailed = false465 for (var bi = 0; bi < inlineResult.length; bi++) {466 var block = inlineResult[bi]467 if (!block || !block.type) continue468 var rendered = __test.MarkdownElement(block)469 // JSX shim returns {component, props, _t: "jsx"} — parser objects have no _t470 if (!rendered || typeof rendered !== 'object' || !rendered._t) {471 allBlocksAreJsx = false472 anyBlockFailed = true473 break474 }475 }476 results.inlineJsx.blocksRenderToJsx = allBlocksAreJsx477 results.inlineJsx.anyBlockFailed = anyBlockFailed478479 // EXECUTABLE RENDER CHECK: invoke renderInline directly on inline text480 // and verify each inline element returns JSX, not plain objects481 var inlineParts = __test.renderInline('Hello **bold** world')482 results.inlineJsx.inline = {}483 results.inlineJsx.inline.isArray = Array.isArray(inlineParts)484 var inlineAllJsx = true485 if (Array.isArray(inlineParts)) {486 for (var ii = 0; ii < inlineParts.length; ii++) {487 var part = inlineParts[ii]488 // Strings are fine (plain text between markers)489 if (typeof part === 'string') continue490 // JSX elements must have _t property491 if (!part || typeof part !== 'object' || !part._t) {492 inlineAllJsx = false493 break494 }495 }496 }497 results.inlineJsx.inline.allJsx = inlineAllJsx498499 // Mixed bold/code/link rendering — verify each inline type produces JSX500 var mixedParts = __test.renderInline('**bold** and `code` and [click](https://x.com)')501 results.inlineJsx.mixed = {}502 if (Array.isArray(mixedParts)) {503 var jsxParts = mixedParts.filter(function(p) { return p && typeof p === 'object' && p._t })504 results.inlineJsx.mixed.count = jsxParts.length505 // Should have 3 JSX elements: strong, code, a506 results.inlineJsx.mixed.hasThree = jsxParts.length === 3507 // Verify component types508 var componentNames = jsxParts.map(function(p) {509 return p.props && p.props.children ? (typeof p.props.children === 'string' ? p.props.children.substring(0, 10) : '?') : '?'510 })511 results.inlineJsx.mixed.componentChildren = componentNames512 }513 }514515 } catch (e) {516 results.__testError = e.message517 }518519 // ═══════════════════════════════════════════════════════════════════════════════520 // Lane override behavior tests521 // ═══════════════════════════════════════════════════════════════════════════════522 try {523 var SO = ['todo', 'in-progress', 'done']524525 // 1. computeCollapsedSet526 results.collapsedSet = {}527 if (__test.computeCollapsedSet) {528 var auto = { todo: true } // todo empty, in-progress & done have work529 var empty = __test.computeCollapsedSet(auto, {}, SO)530 results.collapsedSet.autoOnlyEmpty = empty.todo === true531 results.collapsedSet.autoOnlyPopulated = empty['in-progress'] === false532 var withOverride = __test.computeCollapsedSet(auto, { todo: false }, SO)533 results.collapsedSet.overrideEmpty = withOverride.todo === false534 var withOverrideFull = __test.computeCollapsedSet(auto, { 'in-progress': true }, SO)535 results.collapsedSet.overridePopulated = withOverrideFull['in-progress'] === true536 }537538 // 2. toggleOverride539 results.toggle = {}540 if (__test.toggleOverride) {541 var autoMap = { todo: true } // todo is auto-collapsed (empty lane)542 var t1 = __test.toggleOverride({}, 'todo', autoMap)543 results.toggle.firstToggleEmpty = t1.todo544 var t2 = __test.toggleOverride(t1, 'todo', autoMap)545 results.toggle.secondToggleEmpty = t2.todo546547 var autoMapFull = { 'in-progress': false } // in-progress is auto-expanded (has work)548 var t3 = __test.toggleOverride({}, 'in-progress', autoMapFull)549 results.toggle.firstTogglePopulated = t3['in-progress']550 var t4 = __test.toggleOverride(t3, 'in-progress', autoMapFull)551 results.toggle.secondTogglePopulated = t4['in-progress']552 }553554 // 3. computeLanePhase555 results.lanePhase = {}556 if (__test.computeLanePhase) {557 var g1 = { todo: [], 'in-progress': [{x:1}], done: [] }558 results.lanePhase.empty = __test.computeLanePhase(g1, ['todo', 'in-progress', 'done'])559 var g2 = { todo: [{x:1}], 'in-progress': [], done: [{x:1}] }560 results.lanePhase.full = __test.computeLanePhase(g2, ['todo', 'in-progress', 'done'])561 results.lanePhase.nullGrouped = __test.computeLanePhase(null, SO)562 }563564 // 4. pruneStaleOverrides565 results.prune = {}566 if (__test.pruneStaleOverrides) {567 // Phase changed on override lane → pruned568 var prev1 = 'todo:empty|in-progress:full|done:empty'569 var cur1 = 'todo:full|in-progress:full|done:empty' // todo flipped empty→full570 var pruned1 = __test.pruneStaleOverrides({ todo: false, done: true }, prev1, cur1)571 results.prune.changedLanePruned = pruned1.todo === undefined && pruned1.done === true572573 // Phase unchanged → kept574 var prev2 = 'todo:empty|in-progress:full|done:empty'575 var cur2 = 'todo:empty|in-progress:full|done:empty'576 var pruned2 = __test.pruneStaleOverrides({ todo: false }, prev2, cur2)577 results.prune.unchangedLaneKept = pruned2.todo === false578579 // Phase changed but no override → harmless580 var prev3 = 'todo:empty|in-progress:full'581 var cur3 = 'todo:full|in-progress:full'582 var pruned3 = __test.pruneStaleOverrides({}, prev3, cur3)583 results.prune.changedNoOverride = Object.keys(pruned3).length === 0584585 // Null previous phase → no pruning586 var pruned4 = __test.pruneStaleOverrides({ todo: false }, null, 'todo:full|in-progress:full')587 results.prune.nullPrevPhase = pruned4.todo === false588589 // Mixed: two overrides, only affected one pruned590 var prev5 = 'todo:empty|in-progress:full|done:empty'591 var cur5 = 'todo:full|in-progress:full|done:full' // todo and done flipped592 var pruned5 = __test.pruneStaleOverrides({ todo: false, 'in-progress': true, done: true }, prev5, cur5)593 results.prune.mixedPruning = pruned5.todo === undefined && pruned5['in-progress'] === true && pruned5.done === undefined594 }595596 // 5. Toggle integration597 results.toggleIntegration = {}598 if (__test.toggleOverride && __test.computeCollapsedSet) {599 var autoEmpty = { todo: true }600 var autoFull = { 'in-progress': false }601602 // Toggle auto-collapsed twice → back to auto603 var o1 = __test.toggleOverride({}, 'todo', autoEmpty)604 var o2 = __test.toggleOverride(o1, 'todo', autoEmpty)605 var cs1 = __test.computeCollapsedSet(autoEmpty, o2, ['todo'])606 results.toggleIntegration.autoCollapsedDeleted = cs1.todo === true // auto wins607608 // Toggle auto-expanded twice → back to auto609 var o3 = __test.toggleOverride({}, 'in-progress', autoFull)610 var o4 = __test.toggleOverride(o3, 'in-progress', autoFull)611 var cs2 = __test.computeCollapsedSet(autoFull, o4, ['in-progress'])612 results.toggleIntegration.autoExpandedDeleted = cs2['in-progress'] === false // auto wins613614 // Partial toggle: only one lane, other preserved615 var o5 = __test.toggleOverride({}, 'todo', autoEmpty)616 var cs3 = __test.computeCollapsedSet(autoEmpty, o5, ['todo', 'in-progress'])617 results.toggleIntegration.partialToggleKept = o5.todo === false && cs3['in-progress'] === false618 }619620 // 6. trimPath621 results.trimPath = {}622 if (__test.trimPath) {623 results.trimPath.basicTrim = __test.trimPath(' /path/to/repo ')624 results.trimPath.noTrim = __test.trimPath('/path/to/repo')625 results.trimPath.emptyString = __test.trimPath('')626 results.trimPath.whitespaceOnly = __test.trimPath(' ')627 results.trimPath.nonString = __test.trimPath(null)628 results.trimPath.number = __test.trimPath(42)629 }630631 // 7. extractApiError632 results.extractApiError = {}633 if (__test.extractApiError) {634 results.extractApiError.nullInput = __test.extractApiError(null)635 results.extractApiError.stringInput = __test.extractApiError('bad request')636 results.extractApiError.messageObj = __test.extractApiError({ message: 'not found' })637 results.extractApiError.detailObj = __test.extractApiError({ detail: 'conflict' })638 results.extractApiError.duplicateSource = __test.extractApiError({ message: "Error invoking remote method 'hermes:api': Error: 409: {\"detail\":\"Source already registered\"}" })639 results.extractApiError.fallback = __test.extractApiError({ code: 500 })640 }641642 // 8. Source mutation adapter — create a fresh api via register and test mutation methods643 results.sourceMutation = {}644 try {645 // Create a fresh mock ctx to capture the api object646 let capturedApi = null647 const testCtx = {648 register() {},649 registerMany(arr) {650 for (const c of arr) {651 if (c.render) {652 // Intercept render to capture the api from its closure653 const origRender = c.render654 c.render = function() {655 // The api is created in register() and passed to OpenSpecPage656 // We can't directly access it, so we'll test via __test657 return origRender()658 }659 }660 }661 },662 rest(path, opts) {663 results.sourceMutation.lastRestPath = path664 results.sourceMutation.lastRestOpts = opts || null665 return Promise.resolve({ ok: true })666 }667 }668 plugin.register(testCtx)669670 // Test addSource path and body via a direct rest mock671 let addRestCalls = []672 const addCtx = {673 register() {},674 registerMany() {},675 rest(path, opts) { addRestCalls.push({ path, opts }); return Promise.resolve({}) }676 }677 results.sourceMutation.addSourcePath = __test.sourcesPath ? __test.sourcesPath() : null678 results.sourceMutation.updatePathEncodes = __test.changePath ? __test.changePath('src-1', 'test') : null679 if (__test.createApi) {680 const initCalls = []681 const initApi = __test.createApi({ rest(path, opts) {682 initCalls.push({ path, opts })683 return Promise.resolve({ ok: true })684 } })685 await initApi.initSource('source id')686 results.sourceMutation.initSourcePath = initCalls[0] && initCalls[0].path687 results.sourceMutation.initSourceMethod = initCalls[0] && initCalls[0].opts && initCalls[0].opts.method688 results.sourceMutation.initSourceBody = initCalls[0] && initCalls[0].opts && initCalls[0].opts.body689 results.sourceMutation.needsInit = __test.sourceNeedsInitialization({ source: { id: 'os-1', valid: false, error: 'No openspec/ directory found' } })690 results.sourceMutation.validNeedsNoInit = __test.sourceNeedsInitialization({ source: { id: 'os-1', valid: true, error: null } })691 // Exact endpoint allowlist: the four dashboard source endpoints only,692 // with encoded identifiers, exact methods, and object request bodies.693 const mutCalls = []694 const mutApi = __test.createApi({ rest(path, opts) {695 mutCalls.push({ path, opts })696 return Promise.resolve({ ok: true, source: { id: 'src-1' } })697 } })698 await mutApi.sources()699 await mutApi.addSource('/repos/acme', 'Acme')700 await mutApi.addSource('/repos/bare')701 await mutApi.updateSource('src id/with slash', '/repos/acme-new', 'Acme New')702 await mutApi.initSource('src id')703 await mutApi.removeSource('src id')704 const addCall = mutCalls.find((c) => c.path === '/sources' && c.opts && c.opts.method === 'POST' && c.opts.body && c.opts.body.name === 'Acme')705 const addBareCall = mutCalls.find((c) => c.path === '/sources' && c.opts && c.opts.method === 'POST' && c.opts.body && c.opts.body.path === '/repos/bare')706 const updateCall = mutCalls.find((c) => c.path === '/sources/src%20id%2Fwith%20slash' && c.opts && c.opts.method === 'PUT')707 const removeCall = mutCalls.find((c) => c.path === '/sources/src%20id' && c.opts && c.opts.method === 'DELETE')708 const sourcesCall = mutCalls.find((c) => c.path === '/sources' && !c.opts)709 results.sourceMutation.addCallPath = addCall && addCall.path710 results.sourceMutation.addCallMethod = addCall && addCall.opts.method711 results.sourceMutation.addCallBody = addCall && addCall.opts.body712 results.sourceMutation.addBareCallBody = addBareCall && addBareCall.opts.body713 results.sourceMutation.updateCallPath = updateCall && updateCall.path714 results.sourceMutation.updateCallMethod = updateCall && updateCall.opts.method715 results.sourceMutation.updateCallBody = updateCall && updateCall.opts.body716 results.sourceMutation.removeCallPath = removeCall && removeCall.path717 results.sourceMutation.removeCallMethod = removeCall && removeCall.opts.method718 results.sourceMutation.removeCallBody = removeCall && removeCall.opts.body719 results.sourceMutation.sourcesCallMethod = sourcesCall && sourcesCall.opts ? sourcesCall.opts.method : undefined720 const apiKeys = Object.keys(mutApi).sort()721 results.sourceMutation.mutationMethods = ['addSource', 'updateSource', 'initSource', 'removeSource'].filter((k) => apiKeys.includes(k))722 results.sourceMutation.allowlistExact = JSON.stringify(apiKeys) === JSON.stringify(['addSource', 'change', 'idea', 'initSource', 'removeSource', 'sources', 'spec', 'specBrowser', 'updateSource'].sort())723 results.sourceMutation.noGenericRequest = !apiKeys.includes('request')724 }725 } catch (e) {726 results.sourceMutation.error = e.message727 }728729 } catch (e) {730 results.__laneOverrideError = e.message731 }732}733734process.stdout.write(JSON.stringify(results))